-
Notifications
You must be signed in to change notification settings - Fork 0
feat(sim): playback speed, edge animation, template scenarios #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat/interactive-simulation
Are you sure you want to change the base?
Changes from all commits
3dd3f09
db6e1bd
b98655a
d9de56c
bec9967
c531edb
413aee0
d8b780a
4037c72
bfd2b10
728ac41
b7fcba2
06f19ff
3e8477b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,11 @@ | ||
| import { useState, useCallback, useEffect } from 'react' | ||
| import type { UseSimulationReturn } from '../hooks/useSimulation' | ||
| import type { TemplateScenario } from '../data/template-scenarios' | ||
|
|
||
| export interface SimulationPanelProps { | ||
| simulation: UseSimulationReturn | ||
| scenarios?: TemplateScenario[] | ||
| onSelectScenario?: (scenario: TemplateScenario | null) => void | ||
| } | ||
|
|
||
| const panelStyle: React.CSSProperties = { | ||
|
|
@@ -103,23 +106,91 @@ function StatusBadge({ status }: { status: string }) { | |
| } | ||
|
|
||
| function buildCumulativeContext( | ||
| steps: { stepOutput?: { nodeId: string; value: unknown } }[], | ||
| steps: { | ||
| stepOutput?: { nodeId: string; value: unknown } | ||
| branchOutputs?: Record<string, unknown> | ||
| }[], | ||
| upToIndex: number, | ||
| ): Record<string, unknown> { | ||
| const ctx: Record<string, unknown> = {} | ||
| for (let i = 0; i <= upToIndex; i++) { | ||
| const out = steps[i]?.stepOutput | ||
| if (out) ctx[out.nodeId] = out.value | ||
| const step = steps[i] | ||
| if (step?.stepOutput) ctx[step.stepOutput.nodeId] = step.stepOutput.value | ||
| if (step?.branchOutputs) { | ||
|
Comment on lines
+108
to
+119
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Finding type: Want Baz to fix this for you? Activate Fixer |
||
| for (const [branchId, value] of Object.entries(step.branchOutputs)) { | ||
| ctx[branchId] = value | ||
| } | ||
| } | ||
| } | ||
| return ctx | ||
| } | ||
|
|
||
| export function SimulationPanel({ simulation }: SimulationPanelProps) { | ||
| const selectStyle: React.CSSProperties = { | ||
| width: '100%', | ||
| padding: '6px 8px', | ||
| fontSize: 12, | ||
| fontFamily: 'var(--fp-font-sans, system-ui, sans-serif)', | ||
| background: '#1C1B25', | ||
| color: '#E8E7F4', | ||
| border: '1px solid #2E2D3D', | ||
| borderRadius: 6, | ||
| cursor: 'pointer', | ||
| } | ||
|
|
||
| export function SimulationPanel({ simulation, scenarios, onSelectScenario }: SimulationPanelProps) { | ||
| const [inputText, setInputText] = useState('{}') | ||
| const [fixturesText, setFixturesText] = useState('') | ||
| const [showFixtures, setShowFixtures] = useState(false) | ||
| const [showContext, setShowContext] = useState(false) | ||
| const [inputError, setInputError] = useState<string | null>(null) | ||
| const [selectedScenarioId, setSelectedScenarioId] = useState<string | null>(null) | ||
|
|
||
| const hasScenarios = scenarios && scenarios.length > 0 | ||
| const selectedScenario = hasScenarios | ||
| ? scenarios.find((s) => s.id === selectedScenarioId) ?? null | ||
| : null | ||
|
|
||
| const handleSelectScenario = useCallback( | ||
| (scenarioId: string) => { | ||
| if (scenarioId === '') { | ||
| setSelectedScenarioId(null) | ||
| onSelectScenario?.(null) | ||
| return | ||
| } | ||
| const scenario = scenarios?.find((s) => s.id === scenarioId) | ||
| if (!scenario) return | ||
| setSelectedScenarioId(scenarioId) | ||
| setInputText(JSON.stringify(scenario.input, null, 2)) | ||
| setFixturesText(JSON.stringify(scenario.fixtures ?? {}, null, 2)) | ||
| if (scenario.fixtures && Object.keys(scenario.fixtures).length > 0) { | ||
| setShowFixtures(true) | ||
| } | ||
| setInputError(null) | ||
| onSelectScenario?.(scenario) | ||
| }, | ||
| [scenarios, onSelectScenario], | ||
| ) | ||
|
|
||
| const handleInputChange = useCallback( | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Important — Unnecessary re-renders] Since Suggestion: Remove the guard and dependency: const handleInputChange = useCallback(
(value: string) => {
setInputText(value)
setSelectedScenarioId(null)
},
[],
) |
||
| (value: string) => { | ||
| setInputText(value) | ||
| // Clear the scenario label but keep rules — user is customizing the input | ||
| if (selectedScenarioId) { | ||
| setSelectedScenarioId(null) | ||
| } | ||
| }, | ||
| [selectedScenarioId], | ||
| ) | ||
|
|
||
| const handleFixturesChange = useCallback( | ||
|
Comment on lines
+174
to
+185
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Finding type: Want Baz to fix this for you? Activate Fixer Other fix methodsPrompt for AI Agents: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Commit 728ac41 addressed this comment by preventing handleInputChange and handleFixturesChange from invoking onSelectScenario(null), so editing no longer clears the parent-selected scenario and its rules, and by merging scenario-specific rules into simulationRules so scenario data persists even while the local selectedScenarioId is null. |
||
| (value: string) => { | ||
| setFixturesText(value) | ||
| if (selectedScenarioId) { | ||
| setSelectedScenarioId(null) | ||
| } | ||
| }, | ||
| [selectedScenarioId], | ||
| ) | ||
|
|
||
| const { | ||
| isActive, | ||
|
|
@@ -136,6 +207,8 @@ export function SimulationPanel({ simulation }: SimulationPanelProps) { | |
| reset, | ||
| setAutoPlay, | ||
| isAutoPlaying, | ||
| playbackSpeed, | ||
| setPlaybackSpeed, | ||
| } = simulation | ||
|
|
||
| const handleRun = useCallback(() => { | ||
|
|
@@ -207,13 +280,38 @@ export function SimulationPanel({ simulation }: SimulationPanelProps) { | |
| </button> | ||
| </div> | ||
|
|
||
| {hasScenarios && ( | ||
| <div style={inputAreaStyle}> | ||
| <label style={{ display: 'block', fontSize: 11, marginBottom: 4, color: '#8887A5' }}> | ||
| Example Scenario | ||
| </label> | ||
| <select | ||
| value={selectedScenarioId ?? ''} | ||
| onChange={(e) => { handleSelectScenario(e.target.value) }} | ||
| style={selectStyle} | ||
| > | ||
| <option value="">Custom input</option> | ||
| {scenarios.map((s) => ( | ||
| <option key={s.id} value={s.id}> | ||
| {s.name} | ||
| </option> | ||
| ))} | ||
| </select> | ||
| {selectedScenario && ( | ||
| <div style={{ fontSize: 11, color: '#8887A5', marginTop: 4 }}> | ||
| {selectedScenario.description} | ||
| </div> | ||
| )} | ||
| </div> | ||
| )} | ||
|
|
||
| <div style={inputAreaStyle}> | ||
| <label style={{ display: 'block', fontSize: 11, marginBottom: 4, color: '#8887A5' }}> | ||
| Input JSON | ||
| </label> | ||
| <textarea | ||
| value={inputText} | ||
| onChange={(e) => { setInputText(e.target.value) }} | ||
| onChange={(e) => { handleInputChange(e.target.value) }} | ||
| style={textareaStyle} | ||
| placeholder='{"key": "value"}' | ||
| /> | ||
|
|
@@ -236,7 +334,7 @@ export function SimulationPanel({ simulation }: SimulationPanelProps) { | |
| </label> | ||
| <textarea | ||
| value={fixturesText} | ||
| onChange={(e) => { setFixturesText(e.target.value) }} | ||
| onChange={(e) => { handleFixturesChange(e.target.value) }} | ||
| style={textareaStyle} | ||
| placeholder='{"wait_node_id": {"event": "data"}}' | ||
| /> | ||
|
|
@@ -306,6 +404,21 @@ export function SimulationPanel({ simulation }: SimulationPanelProps) { | |
| > | ||
| {isAutoPlaying ? 'Pause' : 'Play'} | ||
| </button> | ||
|
|
||
| {/* Speed buttons */} | ||
| <span style={{ color: '#8887A5', fontSize: 10, marginLeft: 4 }}>Speed:</span> | ||
| {[0.5, 1, 2, 4].map((speed) => ( | ||
| <button | ||
| key={speed} | ||
| type="button" | ||
| onClick={() => { setPlaybackSpeed(speed) }} | ||
| style={playbackSpeed === speed ? btnActiveStyle : btnStyle} | ||
| title={`${String(speed)}x speed`} | ||
| > | ||
| {speed}x | ||
| </button> | ||
| ))} | ||
|
|
||
| <button type="button" onClick={stop} style={{ ...btnStyle, color: '#f38ba8' }}> | ||
| Stop | ||
| </button> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| import { describe, it, expect } from 'vitest' | ||
| import { simulateGraph } from '@ruminaider/flowprint-engine/browser' | ||
| import type { RulesDocument } from '@ruminaider/flowprint-engine/browser' | ||
| import type { RulesDataMap } from '@ruminaider/flowprint-editor' | ||
| import { getTemplates, loadTemplate } from './templates' | ||
| import { getScenarios } from './template-scenarios' | ||
|
|
||
| function convertRulesData(map: RulesDataMap): Record<string, RulesDocument> { | ||
| const result: Record<string, RulesDocument> = {} | ||
| for (const [key, entry] of Object.entries(map)) { | ||
| if (entry.data) { | ||
| result[key] = entry.data as unknown as RulesDocument | ||
| } | ||
| } | ||
| return result | ||
| } | ||
|
|
||
| describe('template scenarios e2e', () => { | ||
| const templates = getTemplates() | ||
|
|
||
| for (const tmpl of templates) { | ||
| const doc = loadTemplate(tmpl.id) | ||
| const docName = doc.name | ||
| const scenarios = getScenarios(docName) | ||
|
|
||
| if (scenarios.length === 0) continue | ||
|
|
||
| describe(docName, () => { | ||
| for (const scenario of scenarios) { | ||
| it(`${scenario.id}: ${scenario.name}`, async () => { | ||
| const trace = await simulateGraph(doc, { | ||
| input: scenario.input, | ||
| fixtures: scenario.fixtures, | ||
| rulesData: scenario.rulesData ? convertRulesData(scenario.rulesData) : {}, | ||
| }) | ||
|
|
||
| // 1. No engine crash | ||
| expect( | ||
| trace.status, | ||
| `Engine error: ${trace.error ?? 'unknown'}`, | ||
| ).not.toBe('error') | ||
|
|
||
| // 2. Produced steps | ||
| expect(trace.steps.length).toBeGreaterThan(0) | ||
|
|
||
| // 3. Last step is terminal | ||
| const lastStep = trace.steps[trace.steps.length - 1] | ||
| expect(lastStep?.type, 'Last step should be terminal').toBe('terminal') | ||
|
|
||
| // 4. No switch fell through (no-match means missing fixture/case) | ||
| const noMatchSteps = trace.steps.filter((s) => s.status === 'no-match') | ||
| expect( | ||
| noMatchSteps, | ||
| `Switch nodes fell through: ${noMatchSteps.map((s) => s.node_id).join(', ')}`, | ||
| ).toHaveLength(0) | ||
|
|
||
| // 5. No unexpected errors (error-caught is OK) | ||
| const errorSteps = trace.steps.filter( | ||
| (s) => s.status === 'error' && s.type !== 'error', | ||
| ) | ||
| expect( | ||
| errorSteps, | ||
| `Unexpected errors: ${errorSteps.map((s) => `${s.node_id}: ${s.error}`).join(', ')}`, | ||
| ).toHaveLength(0) | ||
|
|
||
| // 6. No infinite loops | ||
| expect( | ||
| trace.steps.length, | ||
| `Possible infinite loop: ${trace.steps.length} steps`, | ||
| ).toBeLessThan(50) | ||
| }) | ||
| } | ||
| }) | ||
| } | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
handleSelectScenariowrites scenario rules (or{}) directly into the sharedrulesDataMapthat the editor reads, and nothing restores the project's rules after the simulation panel closes, so selecting a template permanently hides the real project rules; can we keep scenario rules scoped to the simulation instead of mutatingrulesDataMap?Finding type:
Logical Bugs| Severity: 🔴 HighWant Baz to fix this for you? Activate Fixer
Other fix methods
Prompt for AI Agents:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Commit 728ac41 addressed this comment by introducing simulationRules state and keeping scenario-specific rules isolated from the shared rulesDataMap. The new effectiveRules combine project rules with simulation rules only when needed and FlowprintEditor now receives those scoped rules, and all simulation stop/close paths clear simulationRules to restore project data.