From 46bc0f6546eec7faad79600eb3936141bbfd95c3 Mon Sep 17 00:00:00 2001 From: ANDY BUM <47109571+spacesarmat@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:04:30 +0300 Subject: [PATCH 01/26] docs: design visual flow editor --- .../2026-11-12-visual-flow-editor-design.md | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 docs/superpowers/specs/2026-11-12-visual-flow-editor-design.md diff --git a/docs/superpowers/specs/2026-11-12-visual-flow-editor-design.md b/docs/superpowers/specs/2026-11-12-visual-flow-editor-design.md new file mode 100644 index 00000000..db3f9e5c --- /dev/null +++ b/docs/superpowers/specs/2026-11-12-visual-flow-editor-design.md @@ -0,0 +1,207 @@ +# Visual Flow Editor Design + +Дата: 2026-11-12 +Статус: ожидает подтверждения Andy +Ветка: `feat/visual-flow-editor` + +## 1. Назначение + +Реализовать визуальный редактор логики OMP Design Editor: ноды и связи на canvas, типизированные условия и действия, пошаговый debugger, проверки и безопасный экспорт в production. + +Подход: одна типизированная `FlowDocument` для редактора, симулятора и production. Произвольный JavaScript запрещён. + +## 2. Текущее состояние + +`InteractionRule` поддерживает только `click`, `server.connected`, `target`, `screen.current`, а действия ограничены `navigate`, `back`, `toast`. `dispatchInteraction` выбирает первое подходящее правило. `LogicEditor` редактирует правила списком, `SimulationPanel` запускает один click. + +Старые документы и поведение обязаны остаться рабочими без миграции пользовательских данных. + +## 3. Цели + +- Визуальное создание и связывание событий, условий, действий и результатов. +- Расширенные события, условия, переменные и документированные действия. +- Пошаговая симуляция с breakpoints, состоянием и traces. +- Проверка циклов, связей, типов и покрытия результатов. +- Общий runtime для preview и production после явного export. +- Совместимость со старыми `FlowDocument`, snapshots, drafts и rebase. + +## 4. Не входит + +- Произвольный JavaScript, eval, HTML или загружаемый пользовательский код. +- Произвольные HTTP-запросы и секреты в variables. +- Асинхронные цепочки и параллельное выполнение в первой версии. +- Автоматический deploy, release или запуск flows на устройствах Andy. +- Замена реальных приложений независимым движком скриптов. + +## 5. Модель данных + +### 5.1 Совместимость + +`FlowDocument.schemaVersion` остаётся 1. Добавляются optional поля `graph`, `variables` и `steps` в существующие правила. Старый `rules` остаётся источником поведения для старых flows. + +Старый `InteractionRule` считается одной цепочкой `trigger -> action`. Новый rule может содержать упорядоченные `steps` и ссылаться на condition graph. Graph metadata хранит представление, а не произвольную логику. + +### 5.2 GraphView + +- `FlowNodeView`: `id`, `kind`, `ruleId?`, `stepId?`, `x`, `y`, `label?`. +- `FlowEdge`: `id`, `from`, `to`, `branch: 'default' | 'true' | 'false' | 'error'`. +- `kind`: `trigger`, `condition`, `action`, `result`. +- Каждый узел ссылается на типизированную часть правила, не на DOM id. +- Удаление узла удаляет связанные edges и предупреждает о потерянных ветвях. +- Позиции узлов не влияют на выполнение. + +### 5.3 Variables + +- `FlowVariable`: `id`, `name`, `type: 'boolean' | 'string' | 'number' | 'enum'`, `initial`, `values?` для enum. +- Значения проходят ту же строгую валидацию, что документ. +- Секреты, токены, пароли и произвольные URL запрещены. +- Контекст содержит переменные пользователя и документированные read-only значения среды. + +### 5.4 Steps + +`InteractionStep` содержит `id`, `condition?` и `action`. Правило имеет trigger, condition и steps. Runtime выполняет steps по порядку. `result` содержит итоговый route, variables, toast и trace. + +## 6. События + +- `click` по elementId. +- `screen.enter` и `screen.leave`. +- `server.connected` и `server.disconnected`. +- `network.online` и `network.offline`. +- `account.signed-in` и `account.signed-out`. +- `playback.started`, `playback.paused`, `playback.stopped`, `playback.finished`. +- `download.started`, `download.completed`, `download.failed`. +- `timer` с ограниченным интервалом и отключённым фоновым запуском по умолчанию. + +События имеют payload только из разрешённых полей. Пользовательские события не создают сетевых вызовов. + +## 7. Условия + +- Группы `all`, `any`, `not`. +- Операторы `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`, `exists`. +- Пути `server.connected`, `target`, `screen.current`, `network.online`, `account.signedIn`, `playback.state`, `download.state`, `variable.`. +- Типы значений проверяются до сохранения: number только числа, boolean только true/false, enum только разрешённые значения. +- Cooldown и once per event ограничивают повторные запуски без изменения смысла условия. + +## 8. Действия + +- `navigate`, `back`, `openScreen`. +- `toast`, `dialog`. +- `playback.playPause`, `playback.stop`, `playback.nextEpisode`. +- `queue.add`, `queue.replace`. +- `server.connect`, `server.reconnect`, `server.retry`. +- `source.retry`, `source.next`. +- `variable.set` с типизированным значением. +- `link.open` только из allowlist текущего проекта и HTTPS. +- `telegram.open` только документированный канал/бот проекта. + +Действия используют существующие приложенческие API. Никаких динамических имен функций или eval. + +## 9. Runtime и execution policy + +- Синхронный детерминированный interpreter без фоновых promises. +- Один event создаёт один execution context и один trace. +- Не более 32 steps на event и не более 16 одновременно зарегистрированных timer flows. +- Циклические edges запрещены. Повторный trigger подчиняется cooldown. +- Ошибка action останавливает текущую ветку и переходит в `error`, если она существует, иначе завершает сценарий. +- `navigate` и другие side effects выполняются через typed dispatcher, а не напрямую из graph. +- Production активирует generated flow только после явного export. Autosave и editor preview production не меняют. + +## 10. Визуальный редактор + +- Canvas с zoom, pan, minimap и grid. +- Добавление узла через toolbar и `Insert`. +- Drag для перемещения узла и drag из output в input для создания связи. +- Multi-select, удаление, copy/paste, auto-layout и fit. +- Панель свойств только для выбранного узла с документированными полями. +- Цвета веток: default, true, false, error. +- Клик по узлу подсвечивает связанный экран/элемент в Design mode. +- Read-only режим для before/snapshot сравнения, как в остальном редакторе. +- Один drag/edit/paste равен одному undo-шагу. + +## 11. Debugger + +- Start, pause, continue, step over, step into, stop и reset. +- Breakpoints на узле и на ruleId. +- Состояние route, variables, server/network/account/playback/download и текущего узла. +- Trace показывает условие, ветку, action и ошибку, а не только итоговое действие. +- Моки server/network/account/playback/download без реальных вызовов и изменения настроек. +- Повтор и сравнение двух traces с подсветкой первого различия. +- Проверка timeout и error-ветки. + +## 12. Проверки + +- Нет edges в несуществующий узел или self-loop. +- Нет циклов и недостижимых действий. +- Нет нескольких неупорядоченных trigger paths к одному результату. +- Типы condition values и variable.set совпадают. +- Навигация только по разрешённым routes. +- Обязательные toast/dialog/message и destination не пустые. +- Каждый trigger имеет путь к result или явному terminal action. +- Экспорт содержит отчёт покрытия: достигнутые/недостижимые узлы и ветки. + +## 13. Шаблоны + +- Подключение сервера -> продолжить -> каталог. +- Нет сети -> ошибка -> повторить. +- Выбор серии -> очередь -> следующая серия. +- Ошибка воспроизведения -> другой источник. +- Возврат с сохранением route, позиции и выбранного элемента. + +Шаблоны создают новые уникальные node/rule/edge ids. Пользовательские изменения не меняют исходный шаблон. + +## 14. Export и rebase + +- Diff показывает добавленные/удалённые узлы, edges и изменённые поля. +- Rebase сливает rules, graph nodes, edges и variables по id. +- Competing changes одного узла, переменной или связи дают понятный `REBASE_CONFLICT` с путём. +- Независимые добавления и правки разных узлов сливаются без конфликта. +- Манифест, baseCommit, safePath, process lock и rollback сохраняются по существующей модели. + +## 15. Проверки поставки + +- Unit: schema, conditions, graph operations, runtime limits, actions, rebase и validation. +- Component: node editing, linking, clipboard, undo, read-only и template insertion. +- Debugger: breakpoints, step into/over, mocks, trace diff и error paths. +- Integration: старый flow не меняет поведение, preview/production совпадают, export защищён. +- Browser: desktop, phone, TV, zoom/pan, keyboard-only и read-only. +- Команды: `npm run test:editor`, `npx tsc --noEmit`, `npm run build:editor`, `npm run build`, `npm run build:mobile`, `npm run build:desktop`, `npm run editor:generate -- --check`, `git diff --check`. +- CI: `build`, `android`, `billing`, desktop smoke и Greptile обязательны. + +## 16. Этапы + +1. Совместимая graph schema, interpreter и проверки. +2. Node canvas, связи, clipboard и инспекторы. +3. Debugger, trace diff и шаблоны. +4. Export, production runtime, документация и acceptance. + +Один PR после выполнения всех этапов и зелёных проверок. Без автоматического deploy или release. + +## 17. Решения после независимой builder check + +### Execution graph + +- Каждое правило имеет один trigger и собственный направленный граф. Связи между разными правилами запрещены. +- `true` и `false` выбирают следующий узел по соответствующей ветке. Если подходящей ветки нет, выполнение завершается без действия. +- `error` выбирается только при ошибке валидации или синхронной ошибке dispatcher. +- Terminal action завершает ветку. Узел `result` не обязателен. Если он присутствует, это единственный терминальный узел ветки, который собирает итоговый route, variables, toast и trace. + +### Совместимость legacy + +- Старые правила выполняются первыми в исходном порядке с прежним `dispatchInteraction` и старой error-семантикой. +- Новые graph-правила выполняются только если ни одно legacy-правило не совпало. +- Cooldown, лимиты steps, graph validation и error-ветки к legacy-правилам не применяются. +- Смешанный документ не меняет порядок старых правил и не меняет их поведение при совпадении. + +### Синхронный dispatcher + +- Dispatcher возвращает `{ ok: boolean, error?: string }` для валидации и синхронных ошибок. +- Async приложенческие API вызываются через существующие adapters, interpreter их не ожидает. +- `dialog` и `telegram.open` являются fire-and-forget и не приостанавливают выполнение. +- Error-ветки не обещают обработку будущего отказа async API. Такие отказы остаются в существующих приложенческих обработчиках. + +### Production события и variables + +- Все заявленные типы событий получают publishers через существующие приложенческие API. Редактор использует те же typed events для моков. +- Timer registry сбрасывается при каждом export. До export фоновые timers не запускаются. +- Node editor работает только на desktop. Phone и TV относятся к preview и production flows, а не к отдельному мобильному интерфейсу редактора. +- Variables живут только в execution context текущей сессии. Позиция воспроизведения и история страниц сохраняются через существующие хранилища приложения. From e6f7ff4165f148fde1db17ca3c73c76f1c7f3e52 Mon Sep 17 00:00:00 2001 From: ANDY BUM <47109571+spacesarmat@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:06:54 +0300 Subject: [PATCH 02/26] docs: plan visual flow editor implementation --- .../plans/2026-11-12-visual-flow-editor.md | 350 ++++++++++++++++++ 1 file changed, 350 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-12-visual-flow-editor.md diff --git a/docs/superpowers/plans/2026-11-12-visual-flow-editor.md b/docs/superpowers/plans/2026-11-12-visual-flow-editor.md new file mode 100644 index 00000000..55d6f981 --- /dev/null +++ b/docs/superpowers/plans/2026-11-12-visual-flow-editor.md @@ -0,0 +1,350 @@ +# Visual Flow Editor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Реализовать визуальный node editor логики с типизированными событиями, условиями, действиями, debugger и общим preview/production runtime. + +**Architecture:** `FlowDocument.schemaVersion` остаётся 1. Legacy rules выполняются первыми через существующий `dispatchInteraction`, новые graph-правила запускаются только при отсутствии legacy match. Все вычисления выполняются синхронно через typed dispatcher, без eval и произвольного JavaScript. + +**Tech Stack:** TypeScript, Preact 11, Vitest, jsdom, существующие editor/design/runtime APIs. + +**Spec:** `docs/superpowers/specs/2026-11-12-visual-flow-editor-design.md` + +## Global Constraints + +- Нет произвольного JavaScript, eval, HTML, HTTP или секретов в variables. +- Сохранить `schemaVersion: 1`, legacy `dispatchInteraction`, snapshots, drafts, rebase и export manifest. +- Production активирует flow только после явного export. Autosave production не меняет. +- Максимум 32 steps на event и 16 активных timer flows. Графы без циклов и self-loops. +- Один event создаёт один execution context и один trace. Variables живут только в текущей сессии. +- Node editor работает только на desktop. Phone и TV используются как preview/production flows. +- Один drag/edit/paste равен одному undo-шагу. Новые тексты используют только дефис. +- Не запускать deploy, release, NUC или устройства. +- Полный `npm test` локально на Windows не запускать, полная проверка выполняется в CI на Linux. + +## Review Focus + +- Legacy и graph правила в одном документе: legacy имеет приоритет и не меняет поведение. Проверяется Task 1 тестом `legacy rules keep precedence over graph rules`. +- Конфликт двух edges в одну ветку: экспорт должен отклонить неоднозначное выполнение. Проверяется Task 2 тестом `rejects ambiguous branch destination`. +- Read-only before/snapshot и clipboard: нельзя изменить скрытый draft, реальные actions не выполняются. Проверяется Task 3 и Task 4. +- Debugger после смены route/orientation: состояние и breakpoints не остаются от предыдущего документа. Проверяется Task 5. +- Повторный export и timers: registry сбрасывается, production не активируется до export. Проверяется Task 7. + +--- + +### Task 1: Graph Schema и совместимый interpreter + +**Files:** +- Modify: `design/schema.ts` +- Modify: `design/validation.ts` +- Modify: `design/defaults.ts` +- Create: `design/flow-graph.ts` +- Modify: `design/interaction.ts` +- Test: `tests/design/flow-graph.test.ts` +- Test: `tests/design/schema.test.ts` + +**Interfaces:** +- Produces: `FlowNodeView`, `FlowEdge`, `FlowVariable`, `InteractionStep`, `ExecutionContext`, `ExecutionResult`. +- Produces: `executeGraphRule(rule, event, dispatcher): ExecutionResult` и `executeFlow(flow, event, dispatcher): ExecutionResult`. + +- [ ] **Step 1: Написать failing tests** + +`legacy rules keep precedence over graph rules`, true/false/error ветки, variables по типам, max 32 steps, один trace, session reset, terminal action без обязательного result, отсутствие eval и network. + +- [ ] **Step 2: Запустить RED** + +Run: `npx vitest run tests/design/flow-graph.test.ts tests/design/schema.test.ts` +Expected: FAIL, graph types и executeFlow отсутствуют. + +- [ ] **Step 3: Реализовать schema и interpreter** + +Legacy вызывается первым существующим `dispatchInteraction`. Graph запускается только без legacy match. Dispatcher возвращает `{ok:boolean,error?:string}` и остаётся синхронным. Не добавлять async API в interpreter. + +- [ ] **Step 4: Запустить GREEN** + +Run: `npx vitest run tests/design` +Expected: все design tests PASS, старые документы без graph сохраняют форму. + +- [ ] **Step 5: Commit** + +```bash +git add design tests/design +git commit -m "feat: add typed flow graph interpreter" +``` + +### Task 2: Graph validation и rebase + +**Files:** +- Create: `design/flow-validation-graph.ts` +- Modify: `design/flow-validation.ts` +- Modify: `editor/design.mjs` +- Test: `tests/design/flow-validation-graph.test.ts` +- Test: `tests/editor/design.test.ts` + +**Interfaces:** +- Produces: `validateFlowGraph(flow, routeIds): FlowIssue[]`. +- Produces: `FlowIssue {code,nodeId?,edgeId?,message}` и `REBASE_CONFLICT` по node/edge/variable id. + +- [ ] **Step 1: Написать failing tests** + +`rejects ambiguous branch destination`, self-loop, cycle, missing node, wrong value type, unreachable result, independent additions by id и competing edits одного node/edge/variable. + +- [ ] **Step 2: Запустить RED** + +Run: `npx vitest run tests/design/flow-validation-graph.test.ts tests/editor/design.test.ts -t "ambiguous|cycle|rebase|flow"` +Expected: FAIL, graph validation отсутствует. + +- [ ] **Step 3: Реализовать validation и merge** + +Проверять edges, types, reachability, routes и messages. Rebase сливать независимые additions по id и отклонять competing changes одного узла, связи или переменной. + +- [ ] **Step 4: Запустить GREEN** + +Run: `npx vitest run tests/design/flow-validation-graph.test.ts tests/editor/design.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add design editor/design.mjs tests +git commit -m "feat: validate and rebase flow graphs" +``` + +### Task 3: Node Canvas и инспекторы + +**Files:** +- Create: `editor/src/flow/nodeOperations.ts` +- Create: `editor/src/components/FlowCanvas.tsx` +- Create: `editor/src/components/FlowNodeInspector.tsx` +- Modify: `editor/src/App.tsx` +- Modify: `editor/src/editor.css` +- Test: `tests/editor/flow-canvas.test.tsx` + +**Interfaces:** +- Produces: `addFlowNode`, `moveFlowNode`, `connectFlowNodes`, `removeFlowNodes`, `autoLayoutFlow`. +- `FlowCanvasProps {controller,screenId,readOnly,onSelectNode,onOpenSimulation}`. + +- [ ] **Step 1: Написать failing tests** + +Добавление узла, drag позиции, связь output-input, запрещённая связь, inspector полей, один undo и read-only non-draft режим. + +- [ ] **Step 2: Запустить RED** + +Run: `npx vitest run tests/editor/flow-canvas.test.tsx` +Expected: FAIL, canvas отсутствует. + +- [ ] **Step 3: Реализовать node editor** + +Canvas с zoom/pan/grid/minimap, toolbar и `Insert`, drag из output в input, typed inspector, связь с Design mode. Не добавлять новую модель данных помимо Task 1. + +- [ ] **Step 4: Запустить GREEN** + +Run: `npx vitest run tests/editor/flow-canvas.test.tsx tests/editor/app.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add editor tests/editor +git commit -m "feat: edit logic flows on node canvas" +``` + +### Task 4: Multi-select, clipboard и auto-layout + +**Files:** +- Create: `editor/src/flow/selection.ts` +- Create: `editor/src/flow/clipboard.ts` +- Modify: `editor/src/components/FlowCanvas.tsx` +- Modify: `editor/src/components/FlowNodeInspector.tsx` +- Test: `tests/editor/flow-selection.test.ts` +- Test: `tests/editor/flow-clipboard.test.ts` + +**Interfaces:** +- Produces: `reduceFlowSelection`, `copyFlowSelection`, `pasteFlowSelection`, `autoLayoutFlow`. +- `FlowClipboard {schemaVersion:1,nodes,edges,variables}` с новыми ids и ограничениями. + +- [ ] **Step 1: Написать failing tests** + +Shift add/remove, групповое перемещение, copy/paste с новыми ids, отсутствие dangling edges, invalid clipboard, один undo и read-only guard. + +- [ ] **Step 2: Запустить RED** + +Run: `npx vitest run tests/editor/flow-selection.test.ts tests/editor/flow-clipboard.test.ts` +Expected: FAIL, selection/clipboard отсутствуют. + +- [ ] **Step 3: Реализовать selection и clipboard** + +Групповые операции одной транзакцией, строгая валидация clipboard, новые ids, auto-layout без перекрытий. + +- [ ] **Step 4: Запустить GREEN** + +Run: `npx vitest run tests/editor/flow-selection.test.ts tests/editor/flow-clipboard.test.ts tests/editor/flow-canvas.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add editor tests/editor +git commit -m "feat: manage flow nodes and clipboard" +``` + +### Task 5: Debugger и trace comparison + +**Files:** +- Create: `editor/src/flow/debugger.ts` +- Create: `editor/src/components/FlowDebugger.tsx` +- Modify: `editor/src/components/SimulationPanel.tsx` +- Test: `tests/editor/flow-debugger.test.ts` +- Test: `tests/editor/flow-debugger-panel.test.tsx` + +**Interfaces:** +- Produces: `createDebugSession`, `startDebug`, `pauseDebug`, `stepOver`, `stepInto`, `stopDebug`, `resetDebug`, `compareTraces`. +- `DebugSession {status,event,context,breakpoints,currentNodeId,trace}`. + +- [ ] **Step 1: Написать failing tests** + +Start/pause/step/stop/reset, breakpoints, variables, error path, trace first difference, смена route/orientation и отсутствие реальных actions. + +- [ ] **Step 2: Запустить RED** + +Run: `npx vitest run tests/editor/flow-debugger.test.ts tests/editor/flow-debugger-panel.test.tsx` +Expected: FAIL, debugger отсутствует. + +- [ ] **Step 3: Реализовать debugger** + +Использовать typed interpreter Task 1, mocks server/network/account/playback/download, не менять реальные настройки. Сбрасывать session при смене route/orientation. + +- [ ] **Step 4: Запустить GREEN** + +Run: `npx vitest run tests/editor/flow-debugger.test.ts tests/editor/flow-debugger-panel.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add editor tests/editor +git commit -m "feat: debug flow execution step by step" +``` + +### Task 6: Библиотека шаблонов + +**Files:** +- Create: `design/flow-templates.ts` +- Create: `editor/src/components/FlowTemplateLibrary.tsx` +- Modify: `editor/src/components/FlowCanvas.tsx` +- Test: `tests/design/flow-templates.test.ts` +- Test: `tests/editor/flow-template-library.test.tsx` + +**Interfaces:** +- Produces: `FLOW_TEMPLATES`, `instantiateFlowTemplate(id, context): FlowDocument`. +- Template ids: `connect-server`, `offline-retry`, `episode-queue`, `playback-fallback`, `restore-position`. + +- [ ] **Step 1: Написать failing tests** + +Все пять шаблонов создают валидные documents, новые ids, отсутствие изменения исходного шаблона, route/variables корректны. + +- [ ] **Step 2: Запустить RED** + +Run: `npx vitest run tests/design/flow-templates.test.ts tests/editor/flow-template-library.test.tsx` +Expected: FAIL, templates отсутствуют. + +- [ ] **Step 3: Реализовать templates** + +Использовать только существующие типы и actions, применять контекст без секретов, генерировать новые node/rule/edge ids. + +- [ ] **Step 4: Запустить GREEN** + +Run: `npx vitest run tests/design/flow-templates.test.ts tests/editor/flow-template-library.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add design editor tests +git commit -m "feat: add reusable logic flow templates" +``` + +### Task 7: Scoped export и production runtime + +**Files:** +- Create: `design/production-flow.ts` +- Create: `design/generated/flow.ts` +- Modify: `src/main.tsx` +- Modify: `mobile/src/main.tsx` +- Modify: `desktop/src/main.tsx` +- Modify: `editor/export.mjs` +- Modify: `editor/export.d.mts` +- Modify: `editor/src/client.ts` +- Modify: `editor/server.mjs` +- Modify: `editor/src/components/ExportPanel.tsx` +- Test: `tests/design/production-flow.test.ts` +- Test: `tests/editor/export-flow.test.ts` + +**Interfaces:** +- Produces: `installProductionFlow(root): () => void` и `createFlowExportPlan(draft,scope): ExportPlan`. +- Production использует generated `PRODUCTION_FLOW` и typed dispatcher. + +- [ ] **Step 1: Написать failing tests** + +Production не активируется до export, legacy/graph parity, publishers, timer reset при повторном export, scoped diff, manifest ownership и rollback. + +- [ ] **Step 2: Запустить RED** + +Run: `npx vitest run tests/design/production-flow.test.ts tests/editor/export-flow.test.ts` +Expected: FAIL, production flow/export отсутствуют. + +- [ ] **Step 3: Реализовать export и production** + +Подключить publishers через существующие API, timer registry только после export, scoped flow diff с node/edge/variable ids, сохранить manifest/process lock/safePath. + +- [ ] **Step 4: Запустить GREEN и builds** + +Run: `npx vitest run tests/design/production-flow.test.ts tests/editor/export-flow.test.ts && npm run build && npm run build:mobile && npm run build:desktop && npm run build:editor && npm run editor:generate -- --check` +Expected: все команды exit 0. + +- [ ] **Step 5: Commit** + +```bash +git add design editor src mobile desktop tests +git commit -m "feat: export reviewed flows into production" +``` + +### Task 8: Документация и acceptance + +**Files:** +- Modify: `docs/editor-guide.md` +- Modify: `CHANGELOG.md` +- Modify: `CHANGELOG.en.md` +- Modify: `AGENTS.md` только если карта проекта изменилась + +**Interfaces:** +- Consumes: все предыдущие public UI и export interfaces. + +- [ ] **Step 1: Обновить инструкцию** + +Описать node canvas, conditions, actions, variables, clipboard, debugger, templates, scoped export и production activation. + +- [ ] **Step 2: Запустить полный regression** + +Run: `npm run test:editor && npx tsc --noEmit && npm run build:editor && npm run editor:generate -- --check` +Expected: все команды exit 0 без unhandled errors. + +- [ ] **Step 3: Запустить production builds и formatting** + +Run: `npm run build && npm run build:mobile && npm run build:desktop && git diff --check` +Expected: все builds exit 0, diff check чистый. + +- [ ] **Step 4: Выполнить browser acceptance** + +Проверить desktop node editing, связи, clipboard, debugger, templates, read-only и export diff. Phone/TV проверять как preview/production flows. Не выполнять deploy и не менять реальные настройки. + +- [ ] **Step 5: Финальное ревью и PR** + +Проверить весь diff, исправить Critical/Important, создать один PR в `main` и дождаться `build`, `android`, `billing`, desktop smoke и Greptile. Не вливать без явного разрешения Andy. + +- [ ] **Step 6: Commit** + +```bash +git add docs CHANGELOG.md CHANGELOG.en.md AGENTS.md +git commit -m "docs: explain visual logic editing and export" +``` From 034592fa38d6affec0cdfd213aeab842ce66908e Mon Sep 17 00:00:00 2001 From: ANDY BUM <47109571+spacesarmat@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:09:59 +0300 Subject: [PATCH 03/26] feat: add typed flow graph interpreter --- design/flow-graph.ts | 509 ++++++++++++++++++++++++++ design/interaction.ts | 49 ++- design/schema.ts | 124 ++++++- design/validation.ts | 228 +++++++++++- editor/src/components/FlowMap.tsx | 5 +- editor/src/components/LogicEditor.tsx | 3 +- tests/design/flow-graph.test.ts | 362 ++++++++++++++++++ tests/design/schema.test.ts | 198 +++++++++- 8 files changed, 1439 insertions(+), 39 deletions(-) create mode 100644 design/flow-graph.ts create mode 100644 tests/design/flow-graph.test.ts diff --git a/design/flow-graph.ts b/design/flow-graph.ts new file mode 100644 index 00000000..dda75111 --- /dev/null +++ b/design/flow-graph.ts @@ -0,0 +1,509 @@ +import { compareCondition, dispatchInteraction, type InteractionContext, type InteractionDispatcher } from './interaction.ts'; +import type { + ConditionPath, + FlowDocument, + FlowEdge, + FlowEdgeBranch, + FlowEnvironment, + FlowEventType, + FlowGraph, + FlowNodeKind, + FlowNodeView, + FlowVariable, + FlowVariableValue, + InteractionAction, + InteractionCondition, + InteractionRule, +} from './schema.ts'; + +// Runtime and execution policy limits (design 2026-11-12, section 9). +export const FLOW_STEP_LIMIT = 32; + +export interface FlowDispatchOutcome { + ok: boolean; + error?: string; +} + +// Typed synchronous dispatcher: graph actions call these instead of executing directly. +// Async application APIs stay in the existing adapters; the interpreter never awaits them. +export interface FlowDispatcher { + navigate(screenId: string): FlowDispatchOutcome; + back(): FlowDispatchOutcome; + openScreen(screenId: string): FlowDispatchOutcome; + toast(message: string): FlowDispatchOutcome; + dialog(title: string, message: string): FlowDispatchOutcome; + playbackPlayPause(): FlowDispatchOutcome; + playbackStop(): FlowDispatchOutcome; + playbackNextEpisode(): FlowDispatchOutcome; + queueAdd(): FlowDispatchOutcome; + queueReplace(): FlowDispatchOutcome; + serverConnect(): FlowDispatchOutcome; + serverReconnect(): FlowDispatchOutcome; + serverRetry(): FlowDispatchOutcome; + sourceRetry(): FlowDispatchOutcome; + sourceNext(): FlowDispatchOutcome; + linkOpen(url: string): FlowDispatchOutcome; + telegramOpen(target: string): FlowDispatchOutcome; +} + +export interface FlowEvent { + type: FlowEventType; + screenId: string; + elementId: string; +} + +// One event creates one execution context: documented read-only environment values plus +// session variables. Variables never leave the session and are dropped by resetFlowSession. +export interface ExecutionContext extends FlowEnvironment { + variables: Record; +} + +export type ExecutionStatus = 'completed' | 'not-matched' | 'error' | 'step-limit'; + +export interface ExecutionTraceEntry { + ruleId: string; + nodeId?: string; + stepId?: string; + kind?: FlowNodeKind; + branch?: FlowEdgeBranch; + condition?: boolean; + action?: string; + target?: string; + message?: string; + error?: string; +} + +export interface ExecutionResult { + status: ExecutionStatus; + matched: 'legacy' | 'graph' | null; + trace: ExecutionTraceEntry[]; + steps: number; + variables: Record; + route?: string; + toast?: string; + error?: string; +} + +const DEFAULT_ENVIRONMENT: FlowEnvironment = { + target: 'desktop-windows', + screenId: 'settings', + serverConnected: false, + networkOnline: true, + accountSignedIn: false, + playbackState: 'idle', + downloadState: 'idle', +}; + +interface FlowSession { + env: FlowEnvironment; + variables: Record; + lastRunAt: Record; +} + +let session: FlowSession | null = null; + +function currentSession(): FlowSession { + if (!session) session = { env: { ...DEFAULT_ENVIRONMENT }, variables: {}, lastRunAt: {} }; + return session; +} + +export function beginFlowSession(env: FlowEnvironment): void { + session = { env: { ...env }, variables: {}, lastRunAt: {} }; +} + +export function resetFlowSession(): void { + session = null; +} + +function createContext(event: FlowEvent): ExecutionContext & { route?: string; toast?: string; error?: string } { + const active = currentSession(); + return { ...active.env, screenId: event.screenId, variables: { ...active.variables } }; +} + +function commitContext(context: ExecutionContext): void { + currentSession().variables = { ...context.variables }; +} + +function ensureDocumentVariables(flow: FlowDocument, active: FlowSession): void { + for (const variable of flow.variables ?? []) { + if (active.variables[variable.id] === undefined) active.variables[variable.id] = variable.initial; + } +} + +function resolvePath(path: ConditionPath, context: ExecutionContext): FlowVariableValue | undefined { + if (path === 'server.connected') return context.serverConnected; + if (path === 'target') return context.target; + if (path === 'screen.current') return context.screenId; + if (path === 'network.online') return context.networkOnline; + if (path === 'account.signedIn') return context.accountSignedIn; + if (path === 'playback.state') return context.playbackState; + if (path === 'download.state') return context.downloadState; + return context.variables[path.slice('variable.'.length)]; +} + +function evaluateCondition(condition: InteractionCondition, context: ExecutionContext): boolean { + if (condition.kind === 'all') return condition.conditions.every((entry) => evaluateCondition(entry, context)); + if (condition.kind === 'any') return condition.conditions.some((entry) => evaluateCondition(entry, context)); + if (condition.kind === 'not') return !evaluateCondition(condition.condition, context); + const actual = resolvePath(condition.path, context); + if (condition.kind === 'exists') return actual !== undefined; + return compareCondition(condition.kind, actual, condition.value); +} + +function variableAccepts(variable: FlowVariable, value: FlowVariableValue): boolean { + if (variable.type === 'boolean') return typeof value === 'boolean'; + if (variable.type === 'number') return typeof value === 'number'; + if (variable.type === 'string') return typeof value === 'string'; + return typeof value === 'string' && (variable.values ?? []).includes(value); +} + +interface MutableContext extends ExecutionContext { + route?: string; + toast?: string; + error?: string; +} + +function runAction( + action: InteractionAction, + context: MutableContext, + dispatcher: FlowDispatcher, + declared: ReadonlyMap, +): FlowDispatchOutcome { + switch (action.type) { + case 'navigate': { + const outcome = dispatcher.navigate(action.screenId); + if (outcome.ok) context.route = action.screenId; + return outcome; + } + case 'back': + return dispatcher.back(); + case 'openScreen': { + const outcome = dispatcher.openScreen(action.screenId); + if (outcome.ok) context.route = action.screenId; + return outcome; + } + case 'toast': { + const outcome = dispatcher.toast(action.message); + if (outcome.ok) context.toast = action.message; + return outcome; + } + case 'dialog': + return dispatcher.dialog(action.title, action.message); + case 'playback.playPause': + return dispatcher.playbackPlayPause(); + case 'playback.stop': + return dispatcher.playbackStop(); + case 'playback.nextEpisode': + return dispatcher.playbackNextEpisode(); + case 'queue.add': + return dispatcher.queueAdd(); + case 'queue.replace': + return dispatcher.queueReplace(); + case 'server.connect': + return dispatcher.serverConnect(); + case 'server.reconnect': + return dispatcher.serverReconnect(); + case 'server.retry': + return dispatcher.serverRetry(); + case 'source.retry': + return dispatcher.sourceRetry(); + case 'source.next': + return dispatcher.sourceNext(); + case 'variable.set': { + const variable = declared.get(action.variableId); + if (!variable) return { ok: false, error: `unknown variable ${action.variableId}` }; + if (!variableAccepts(variable, action.value)) return { ok: false, error: `variable ${action.variableId} expects ${variable.type}` }; + context.variables[action.variableId] = action.value; + return { ok: true }; + } + case 'link.open': + return dispatcher.linkOpen(action.url); + case 'telegram.open': + return dispatcher.telegramOpen(action.target); + } +} + +// Terminal actions end the branch after they run; toast, dialog and telegram.open are +// fire-and-forget and let the walk continue along the default edge. +const TERMINAL_ACTIONS = new Set([ + 'navigate', + 'back', + 'openScreen', + 'playback.playPause', + 'playback.stop', + 'playback.nextEpisode', + 'queue.add', + 'queue.replace', + 'server.connect', + 'server.reconnect', + 'server.retry', + 'source.retry', + 'source.next', + 'link.open', +]); + +interface RunState { + steps: number; +} + +interface RunOutcome { + status: ExecutionStatus; +} + +function traceError(trace: ExecutionTraceEntry[], ruleId: string, message: string): void { + trace.push({ ruleId, error: message }); +} + +function nextByBranch(edges: FlowEdge[], from: string, branch: FlowEdgeBranch): FlowEdge | undefined { + return edges.find((edge) => edge.from === from && edge.branch === branch); +} + +function runGraph( + rule: InteractionRule, + graph: FlowGraph, + context: MutableContext, + dispatcher: FlowDispatcher, + declared: ReadonlyMap, + trace: ExecutionTraceEntry[], + state: RunState, +): RunOutcome { + const steps = new Map((rule.steps ?? []).map((entry) => [entry.id, entry])); + const outgoing = new Map(); + for (const edge of graph.edges) { + outgoing.set(edge.from, [...(outgoing.get(edge.from) ?? []), edge]); + } + const trigger = graph.nodes.find((node) => node.kind === 'trigger') + ?? graph.nodes.find((node) => !graph.edges.some((edge) => edge.to === node.id)); + let current: FlowNodeView | undefined; + if (trigger) { + const entryEdge = nextByBranch(outgoing.get(trigger.id) ?? [], trigger.id, 'default'); + current = entryEdge ? graph.nodes.find((node) => node.id === entryEdge.to) : undefined; + } + while (current) { + if (state.steps >= FLOW_STEP_LIMIT) { + traceError(trace, rule.id, `step limit of ${FLOW_STEP_LIMIT} reached`); + context.error = `step limit of ${FLOW_STEP_LIMIT} reached`; + return { status: 'step-limit' }; + } + state.steps += 1; + const step = current.stepId ? steps.get(current.stepId) : undefined; + if (current.kind === 'condition') { + const matched = step?.condition ? evaluateCondition(step.condition, context) : false; + trace.push({ ruleId: rule.id, nodeId: current.id, stepId: current.stepId, kind: 'condition', condition: matched }); + const edge = nextByBranch(outgoing.get(current.id) ?? [], current.id, matched ? 'true' : 'false'); + current = edge ? graph.nodes.find((node) => node.id === edge.to) : undefined; + continue; + } + if (current.kind === 'action' || current.kind === 'result') { + const entry: ExecutionTraceEntry = { ruleId: rule.id, nodeId: current.id, stepId: current.stepId, kind: current.kind }; + trace.push(entry); + if (step?.action) { + entry.action = step.action.type; + if (step.action.type === 'navigate' || step.action.type === 'openScreen') entry.target = step.action.screenId; + if (step.action.type === 'toast') entry.message = step.action.message; + let outcome: FlowDispatchOutcome; + try { + outcome = runAction(step.action, context, dispatcher, declared); + } catch (error) { + outcome = { ok: false, error: error instanceof Error ? error.message : String(error) }; + } + if (!outcome.ok) { + entry.error = outcome.error ?? 'action failed'; + context.error = entry.error; + const edge = nextByBranch(outgoing.get(current.id) ?? [], current.id, 'error'); + if (!edge) return { status: 'error' }; + current = graph.nodes.find((node) => node.id === edge.to); + continue; + } + } + if (current.kind === 'result' || (step?.action && TERMINAL_ACTIONS.has(step.action.type))) return { status: 'completed' }; + const edge = nextByBranch(outgoing.get(current.id) ?? [], current.id, 'default'); + current = edge ? graph.nodes.find((node) => node.id === edge.to) : undefined; + continue; + } + // Unknown kinds cannot pass validation; stop the walk instead of guessing. + return { status: 'completed' }; + } + return { status: 'completed' }; +} + +function runSteps( + rule: InteractionRule, + context: MutableContext, + dispatcher: FlowDispatcher, + declared: ReadonlyMap, + trace: ExecutionTraceEntry[], + state: RunState, +): RunOutcome { + for (const step of rule.steps ?? []) { + if (state.steps >= FLOW_STEP_LIMIT) { + traceError(trace, rule.id, `step limit of ${FLOW_STEP_LIMIT} reached`); + context.error = `step limit of ${FLOW_STEP_LIMIT} reached`; + return { status: 'step-limit' }; + } + state.steps += 1; + if (step.condition) { + const matched = evaluateCondition(step.condition, context); + trace.push({ ruleId: rule.id, stepId: step.id, kind: 'condition', condition: matched }); + if (!matched) return { status: 'completed' }; + } + if (step.action) { + const entry: ExecutionTraceEntry = { ruleId: rule.id, stepId: step.id, kind: 'action', action: step.action.type }; + trace.push(entry); + let outcome: FlowDispatchOutcome; + try { + outcome = runAction(step.action, context, dispatcher, declared); + } catch (error) { + outcome = { ok: false, error: error instanceof Error ? error.message : String(error) }; + } + if (!outcome.ok) { + entry.error = outcome.error ?? 'action failed'; + context.error = entry.error; + return { status: 'error' }; + } + if (TERMINAL_ACTIONS.has(step.action.type)) return { status: 'completed' }; + } + } + // The rule action is the terminal step of an ordered chain without a graph. + if (state.steps >= FLOW_STEP_LIMIT) { + traceError(trace, rule.id, `step limit of ${FLOW_STEP_LIMIT} reached`); + context.error = `step limit of ${FLOW_STEP_LIMIT} reached`; + return { status: 'step-limit' }; + } + state.steps += 1; + const terminal: ExecutionTraceEntry = { ruleId: rule.id, kind: 'action', action: rule.action.type }; + if (rule.action.type === 'navigate' || rule.action.type === 'openScreen') terminal.target = rule.action.screenId; + if (rule.action.type === 'toast') terminal.message = rule.action.message; + trace.push(terminal); + let terminalOutcome: FlowDispatchOutcome; + try { + terminalOutcome = runAction(rule.action, context, dispatcher, declared); + } catch (error) { + terminalOutcome = { ok: false, error: error instanceof Error ? error.message : String(error) }; + } + if (!terminalOutcome.ok) { + terminal.error = terminalOutcome.error ?? 'action failed'; + context.error = terminal.error; + return { status: 'error' }; + } + return { status: 'completed' }; +} + +function triggerMatches(rule: InteractionRule, event: FlowEvent, context: ExecutionContext): boolean { + const active = currentSession(); + if (rule.cooldownMs && active.lastRunAt[rule.id] !== undefined && Date.now() - active.lastRunAt[rule.id] < rule.cooldownMs) { + return false; + } + if (rule.screenId !== '*' && rule.screenId !== event.screenId) return false; + if (rule.elementId !== event.elementId || rule.event !== event.type) return false; + if (rule.condition && !evaluateCondition(rule.condition, context)) return false; + active.lastRunAt[rule.id] = Date.now(); + return true; +} + +function buildResult( + status: ExecutionStatus, + matched: ExecutionResult['matched'], + trace: ExecutionTraceEntry[], + state: RunState, + context: MutableContext, +): ExecutionResult { + const result: ExecutionResult = { + status, + matched, + trace, + steps: state.steps, + variables: { ...context.variables }, + }; + if (context.route !== undefined) result.route = context.route; + if (context.toast !== undefined) result.toast = context.toast; + if (context.error !== undefined) result.error = context.error; + return result; +} + +export function executeGraphRule( + rule: InteractionRule, + event: FlowEvent, + dispatcher: FlowDispatcher, + variables: FlowVariable[] = [], +): ExecutionResult { + const context = createContext(event); + const state: RunState = { steps: 0 }; + const trace: ExecutionTraceEntry[] = []; + if (!triggerMatches(rule, event, context)) { + return buildResult('not-matched', null, trace, state, context); + } + const declared = new Map(variables.map((variable) => [variable.id, variable])); + const outcome = rule.graph + ? runGraph(rule, rule.graph, context, dispatcher, declared, trace, state) + : runSteps(rule, context, dispatcher, declared, trace, state); + commitContext(context); + return buildResult(outcome.status, 'graph', trace, state, context); +} + +function isGraphRule(rule: InteractionRule): boolean { + return Boolean(rule.graph || (rule.steps && rule.steps.length)); +} + +function legacyDispatcher(dispatcher: FlowDispatcher): InteractionDispatcher { + return { + navigate: (screenId) => { + dispatcher.navigate(screenId); + }, + back: () => { + dispatcher.back(); + }, + toast: (message) => { + dispatcher.toast(message); + }, + }; +} + +function legacyResult(traces: ReturnType, variables: Record): ExecutionResult { + const trace: ExecutionTraceEntry[] = traces.map((entry) => { + const mapped: ExecutionTraceEntry = { ruleId: entry.ruleId, kind: 'action', action: entry.action }; + if (entry.target !== undefined) mapped.target = entry.target; + if (entry.message !== undefined) mapped.message = entry.message; + return mapped; + }); + const result: ExecutionResult = { + status: 'completed', + matched: 'legacy', + trace, + steps: trace.length, + variables: { ...variables }, + }; + const last = traces[traces.length - 1]; + if (last?.action === 'navigate' && last.target) result.route = last.target; + if (last?.action === 'toast' && last.message) result.toast = last.message; + return result; +} + +// One entry point for an event: legacy rules keep their original order and error semantics +// through the existing dispatchInteraction, and graph rules run only when no legacy rule matched. +export function executeFlow(flow: FlowDocument, event: FlowEvent, dispatcher: FlowDispatcher): ExecutionResult { + const active = currentSession(); + ensureDocumentVariables(flow, active); + if (event.type === 'click') { + const legacyContext: InteractionContext = { + target: active.env.target, + screenId: event.screenId, + serverConnected: active.env.serverConnected, + }; + const legacyEvent = { type: 'click' as const, screenId: event.screenId, elementId: event.elementId }; + const traces = dispatchInteraction(flow, legacyEvent, legacyContext, legacyDispatcher(dispatcher)); + if (traces.length) return legacyResult(traces, active.variables); + } + for (const rule of flow.rules) { + if (!isGraphRule(rule)) continue; + const result = executeGraphRule(rule, event, dispatcher, flow.variables ?? []); + if (result.matched === 'graph') return result; + } + return { + status: 'not-matched', + matched: null, + trace: [], + steps: 0, + variables: { ...active.variables }, + }; +} diff --git a/design/interaction.ts b/design/interaction.ts index 613ea8a3..22049409 100644 --- a/design/interaction.ts +++ b/design/interaction.ts @@ -27,18 +27,41 @@ export interface InteractionTrace { condition?: InteractionCondition; } +// Shared operator semantics for legacy and graph condition evaluation. +export function compareCondition(kind: string, actual: unknown, value: unknown): boolean { + if (kind === 'eq') return actual === value; + if (kind === 'neq') return actual !== value; + if (kind === 'gt' || kind === 'gte' || kind === 'lt' || kind === 'lte') { + if (typeof actual !== 'number' || typeof value !== 'number') return false; + if (kind === 'gt') return actual > value; + if (kind === 'gte') return actual >= value; + if (kind === 'lt') return actual < value; + return actual <= value; + } + if (kind === 'contains') return typeof actual === 'string' && typeof value === 'string' && actual.includes(value); + return false; +} + function conditionMatches(condition: InteractionCondition | undefined, context: InteractionContext): boolean { if (!condition) return true; - if (condition.kind === 'eq') { - const actual = condition.path === 'server.connected' ? context.serverConnected : condition.path === 'target' ? context.target : context.screenId; - return actual === condition.value; - } if (condition.kind === 'all') return condition.conditions.every((entry) => conditionMatches(entry, context)); if (condition.kind === 'any') return condition.conditions.some((entry) => conditionMatches(entry, context)); - return !conditionMatches(condition.condition, context); + if (condition.kind === 'not') return !conditionMatches(condition.condition, context); + const actual = condition.path === 'server.connected' + ? context.serverConnected + : condition.path === 'target' + ? context.target + : condition.path === 'screen.current' + ? context.screenId + : undefined; + if (condition.kind === 'exists') return actual !== undefined; + return compareCondition(condition.kind, actual, condition.value); } function ruleMatches(rule: InteractionRule, event: InteractionEvent, context: InteractionContext): boolean { + // Rules with steps or a graph belong to the flow graph runtime and never match in the legacy pass. + if (rule.graph || (rule.steps && rule.steps.length)) return false; + if (rule.action.type !== 'navigate' && rule.action.type !== 'back' && rule.action.type !== 'toast') return false; if (rule.screenId !== '*' && rule.screenId !== event.screenId) return false; if (rule.elementId !== event.elementId || rule.event !== event.type) return false; return conditionMatches(rule.condition, context); @@ -52,16 +75,18 @@ export function dispatchInteraction( ): InteractionTrace[] { const rule = flow.rules.find((entry) => ruleMatches(entry, event, context)); if (!rule) return []; - const trace: InteractionTrace = { ruleId: rule.id, event: 'click', action: rule.action.type }; + const action = rule.action; + if (action.type !== 'navigate' && action.type !== 'back' && action.type !== 'toast') return []; + const trace: InteractionTrace = { ruleId: rule.id, event: 'click', action: action.type }; if (rule.condition) trace.condition = rule.condition; - if (rule.action.type === 'navigate') { - trace.target = rule.action.screenId; - dispatcher.navigate(rule.action.screenId); - } else if (rule.action.type === 'back') { + if (action.type === 'navigate') { + trace.target = action.screenId; + dispatcher.navigate(action.screenId); + } else if (action.type === 'back') { dispatcher.back(); } else { - trace.message = rule.action.message; - dispatcher.toast(rule.action.message); + trace.message = action.message; + dispatcher.toast(action.message); } return [trace]; } diff --git a/design/schema.ts b/design/schema.ts index 6f1aff82..8c093b29 100644 --- a/design/schema.ts +++ b/design/schema.ts @@ -120,25 +120,140 @@ export interface ThemeDocument { stylePresets?: StylePreset[]; } -export type ConditionPath = 'server.connected' | 'target' | 'screen.current'; +export type ConditionPath = + | 'server.connected' + | 'target' + | 'screen.current' + | 'network.online' + | 'account.signedIn' + | 'playback.state' + | 'download.state' + | `variable.${string}`; + +export type ConditionValue = string | number | boolean; + export type InteractionCondition = - | { kind: 'eq'; path: ConditionPath; value: string | boolean } + | { kind: 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains'; path: ConditionPath; value: ConditionValue } + | { kind: 'exists'; path: ConditionPath } | { kind: 'all'; conditions: InteractionCondition[] } | { kind: 'any'; conditions: InteractionCondition[] } | { kind: 'not'; condition: InteractionCondition }; +export const FLOW_EVENT_TYPES = [ + 'click', + 'screen.enter', + 'screen.leave', + 'server.connected', + 'server.disconnected', + 'network.online', + 'network.offline', + 'account.signed-in', + 'account.signed-out', + 'playback.started', + 'playback.paused', + 'playback.stopped', + 'playback.finished', + 'download.started', + 'download.completed', + 'download.failed', +] as const; +export type FlowEventType = (typeof FLOW_EVENT_TYPES)[number]; + +export const FLOW_NODE_KINDS = ['trigger', 'condition', 'action', 'result'] as const; +export type FlowNodeKind = (typeof FLOW_NODE_KINDS)[number]; +export const FLOW_EDGE_BRANCHES = ['default', 'true', 'false', 'error'] as const; +export type FlowEdgeBranch = (typeof FLOW_EDGE_BRANCHES)[number]; + +// Graph nodes are views over the typed rule parts: a node references its step by id +// instead of carrying behavior, and positions never affect execution. +export interface FlowNodeView { + id: string; + kind: FlowNodeKind; + ruleId?: string; + stepId?: string; + x: number; + y: number; + label?: string; +} + +export interface FlowEdge { + id: string; + from: string; + to: string; + branch: FlowEdgeBranch; +} + +export interface FlowGraph { + nodes: FlowNodeView[]; + edges: FlowEdge[]; +} + +export const FLOW_VARIABLE_TYPES = ['boolean', 'string', 'number', 'enum'] as const; +export type FlowVariableType = (typeof FLOW_VARIABLE_TYPES)[number]; +export type FlowVariableValue = string | number | boolean; + +export interface FlowVariable { + id: string; + name: string; + type: FlowVariableType; + initial: FlowVariableValue; + values?: string[]; +} + +export type FlowPlaybackState = 'idle' | 'playing' | 'paused' | 'stopped'; +export type FlowDownloadState = 'idle' | 'active' | 'completed' | 'failed'; + +// Documented read-only environment values available to conditions during one execution. +export interface FlowEnvironment { + target: DesignTarget; + screenId: string; + serverConnected: boolean; + networkOnline: boolean; + accountSignedIn: boolean; + playbackState: FlowPlaybackState; + downloadState: FlowDownloadState; +} + +// Documented project links only: HTTPS targets the OMP project already publishes. +export const FLOW_LINK_HOSTS = ['t.me'] as const; +export const FLOW_TELEGRAM_TARGETS = ['channel', 'billing-bot'] as const; + export type InteractionAction = | { type: 'navigate'; screenId: string } | { type: 'back' } - | { type: 'toast'; message: string }; + | { type: 'toast'; message: string } + | { type: 'openScreen'; screenId: string } + | { type: 'dialog'; title: string; message: string } + | { type: 'playback.playPause' } + | { type: 'playback.stop' } + | { type: 'playback.nextEpisode' } + | { type: 'queue.add' } + | { type: 'queue.replace' } + | { type: 'server.connect' } + | { type: 'server.reconnect' } + | { type: 'server.retry' } + | { type: 'source.retry' } + | { type: 'source.next' } + | { type: 'variable.set'; variableId: string; value: FlowVariableValue } + | { type: 'link.open'; url: string } + | { type: 'telegram.open'; target: string }; + +export interface InteractionStep { + id: string; + condition?: InteractionCondition; + action?: InteractionAction; +} export interface InteractionRule { id: string; screenId: string; elementId: string; - event: 'click'; + event: FlowEventType; condition?: InteractionCondition; action: InteractionAction; + steps?: InteractionStep[]; + graph?: FlowGraph; + cooldownMs?: number; } export interface FlowDocument { @@ -146,4 +261,5 @@ export interface FlowDocument { id: string; baseCommit: string; rules: InteractionRule[]; + variables?: FlowVariable[]; } diff --git a/design/validation.ts b/design/validation.ts index cbd19adb..709a4534 100644 --- a/design/validation.ts +++ b/design/validation.ts @@ -2,6 +2,12 @@ import { DESIGN_FORMATS, DESIGN_STATES, DESIGN_TARGETS, + FLOW_EDGE_BRANCHES, + FLOW_EVENT_TYPES, + FLOW_LINK_HOSTS, + FLOW_NODE_KINDS, + FLOW_TELEGRAM_TARGETS, + FLOW_VARIABLE_TYPES, LAYOUT_ALIGNMENTS, LAYOUT_ANCHORS, LAYOUT_DIRECTIONS, @@ -11,10 +17,16 @@ import { type DesignState, type DesignTarget, type ConditionPath, + type ConditionValue, type FlowDocument, + type FlowGraph, + type FlowNodeView, + type FlowVariable, + type FlowVariableValue, type InteractionAction, type InteractionCondition, type InteractionRule, + type InteractionStep, type LayoutContainer, type LayoutClone, type LayoutDocument, @@ -33,7 +45,8 @@ const SIZE = /^(?:0|[1-9]\d{0,2})(?:\.\d{1,2})?px$/; const VERSION = /^\d+\.\d+\.\d+(?:-beta\.\d+)?$/; const COLORS = new Set(['--omp-color-background', '--omp-color-panel', '--omp-color-text', '--omp-color-accent', '--omp-color-muted', '--omp-color-danger']); const SIZES = new Set(['--omp-size-space', '--omp-size-font', '--omp-size-sidebar', '--omp-size-radius']); -const CONDITION_PATHS = new Set(['server.connected', 'target', 'screen.current']); +const CONDITION_PATHS = new Set(['server.connected', 'target', 'screen.current', 'network.online', 'account.signedIn', 'playback.state', 'download.state']); +const FLOW_COOLDOWN_LIMIT = 3600000; const SCOPES = new Set(['target', 'component', 'screen', 'element', 'format', 'state']); export const LAYOUT_GEOMETRY_LIMIT = 10000; export const LAYOUT_ZINDEX_LIMIT = 999; @@ -328,59 +341,234 @@ export function parseThemeDocument(value: unknown): ThemeDocument { return result; } -function condition(value: unknown, path: string, depth = 0): InteractionCondition { +function isScalar(value: unknown): value is ConditionValue { + return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'; +} + +function conditionPath(value: unknown, path: string, variables: ReadonlySet | undefined): ConditionPath { + const result = text(value, path, 80); + if (CONDITION_PATHS.has(result)) return result as ConditionPath; + if (result.startsWith('variable.')) { + const variableId = result.slice('variable.'.length); + if (!ID.test(variableId)) fail(path, 'invalid variable condition path'); + if (variables && !variables.has(variableId)) fail(path, 'unknown variable'); + return result as ConditionPath; + } + fail(path, 'unknown condition path'); +} + +function condition(value: unknown, path: string, variables?: ReadonlySet, depth = 0): InteractionCondition { if (depth > 8) fail(path, 'condition nesting is limited to 8'); const source = plain(value, path, ['kind', 'path', 'value', 'conditions', 'condition']); const kind = text(source.kind, `${path}.kind`, 10); - if (kind === 'eq') { - const conditionPath = text(source.path, `${path}.path`, 40); - if (!CONDITION_PATHS.has(conditionPath)) fail(`${path}.path`, 'unknown condition path'); - if (typeof source.value !== 'string' && typeof source.value !== 'boolean') fail(`${path}.value`, 'string or boolean required'); - return { kind, path: conditionPath as ConditionPath, value: source.value }; + if (kind === 'eq' || kind === 'neq') { + if (!isScalar(source.value)) fail(`${path}.value`, 'string, number or boolean required'); + return { kind, path: conditionPath(source.path, `${path}.path`, variables), value: source.value }; + } + if (kind === 'gt' || kind === 'gte' || kind === 'lt' || kind === 'lte') { + if (typeof source.value !== 'number') fail(`${path}.value`, 'number required'); + return { kind, path: conditionPath(source.path, `${path}.path`, variables), value: source.value }; + } + if (kind === 'contains') { + if (typeof source.value !== 'string') fail(`${path}.value`, 'string required'); + return { kind, path: conditionPath(source.path, `${path}.path`, variables), value: source.value }; + } + if (kind === 'exists') { + if (source.value !== undefined) fail(`${path}.value`, 'value is not allowed'); + return { kind, path: conditionPath(source.path, `${path}.path`, variables) }; } if (kind === 'all' || kind === 'any') { if (!Array.isArray(source.conditions) || !source.conditions.length) fail(`${path}.conditions`, 'non-empty array required'); - return { kind, conditions: source.conditions.map((entry, index) => condition(entry, `${path}.conditions[${index}]`, depth + 1)) }; + return { kind, conditions: source.conditions.map((entry, index) => condition(entry, `${path}.conditions[${index}]`, variables, depth + 1)) }; } - if (kind === 'not') return { kind, condition: condition(source.condition, `${path}.condition`, depth + 1) }; + if (kind === 'not') return { kind, condition: condition(source.condition, `${path}.condition`, variables, depth + 1) }; return fail(`${path}.kind`, 'unknown condition kind'); } -function action(value: unknown, path: string): InteractionAction { - const source = plain(value, path, ['type', 'screenId', 'message']); +function variableValueMatches(variable: FlowVariable, value: FlowVariableValue): boolean { + if (variable.type === 'boolean') return typeof value === 'boolean'; + if (variable.type === 'number') return typeof value === 'number'; + if (variable.type === 'string') return typeof value === 'string'; + return typeof value === 'string' && (variable.values ?? []).includes(value); +} + +function action(value: unknown, path: string, variables?: ReadonlyMap): InteractionAction { + const source = plain(value, path, ['type', 'screenId', 'message', 'title', 'variableId', 'value', 'url', 'target']); const type = text(source.type, `${path}.type`, 20); if (type === 'navigate') return { type, screenId: id(source.screenId, `${path}.screenId`) }; if (type === 'back') return { type }; if (type === 'toast') return { type, message: text(source.message, `${path}.message`, 200) }; + if (type === 'openScreen') return { type, screenId: id(source.screenId, `${path}.screenId`) }; + if (type === 'dialog') { + return { type, title: text(source.title, `${path}.title`), message: text(source.message, `${path}.message`, 500) }; + } + if (type === 'playback.playPause' || type === 'playback.stop' || type === 'playback.nextEpisode') return { type }; + if (type === 'queue.add' || type === 'queue.replace') return { type }; + if (type === 'server.connect' || type === 'server.reconnect' || type === 'server.retry') return { type }; + if (type === 'source.retry' || type === 'source.next') return { type }; + if (type === 'variable.set') { + const variableId = id(source.variableId, `${path}.variableId`); + if (!isScalar(source.value)) fail(`${path}.value`, 'string, number or boolean required'); + const variable = variables?.get(variableId); + if (variables && !variable) fail(`${path}.variableId`, 'unknown variable'); + if (variable && !variableValueMatches(variable, source.value)) fail(`${path}.value`, `value does not match variable type ${variable.type}`); + return { type, variableId, value: source.value }; + } + if (type === 'link.open') { + const url = text(source.url, `${path}.url`, 500); + let parsed: URL; + try { + parsed = new URL(url); + } catch { + fail(`${path}.url`, 'valid URL required'); + } + if (parsed.protocol !== 'https:') fail(`${path}.url`, 'https URL required'); + if (parsed.username || parsed.password) fail(`${path}.url`, 'credentials are not allowed'); + if (!(FLOW_LINK_HOSTS as readonly string[]).includes(parsed.host)) fail(`${path}.url`, 'link host is not allowed'); + return { type, url }; + } + if (type === 'telegram.open') return { type, target: oneOf(source.target, [...FLOW_TELEGRAM_TARGETS], `${path}.target`) }; return fail(`${path}.type`, 'unknown action type'); } -function rule(value: unknown, path: string): InteractionRule { - const source = plain(value, path, ['id', 'screenId', 'elementId', 'event', 'condition', 'action']); +function flowVariable(value: unknown, path: string): FlowVariable { + const source = plain(value, path, ['id', 'name', 'type', 'initial', 'values']); + const type = oneOf(source.type, [...FLOW_VARIABLE_TYPES], `${path}.type`); + if (!isScalar(source.initial)) fail(`${path}.initial`, 'string, number or boolean required'); + const result: FlowVariable = { + id: id(source.id, `${path}.id`), + name: text(source.name, `${path}.name`), + type, + initial: source.initial, + }; + if (type === 'boolean' && typeof source.initial !== 'boolean') fail(`${path}.initial`, 'boolean required'); + if (type === 'number' && typeof source.initial !== 'number') fail(`${path}.initial`, 'number required'); + if (type === 'string' && typeof source.initial !== 'string') fail(`${path}.initial`, 'string required'); + if (type === 'enum') { + if (!Array.isArray(source.values) || !source.values.length) fail(`${path}.values`, 'non-empty array required'); + const values = source.values.map((entry, index) => text(entry, `${path}.values[${index}]`, 64)); + if (new Set(values).size !== values.length) fail(`${path}.values`, 'duplicate value'); + if (typeof source.initial !== 'string' || !values.includes(source.initial)) fail(`${path}.initial`, 'initial value must be one of values'); + result.values = values; + } else if (source.values !== undefined) { + fail(`${path}.values`, 'values are only allowed for enum variables'); + } + return result; +} + +function step(value: unknown, path: string, variables: ReadonlyMap): InteractionStep { + const source = plain(value, path, ['id', 'condition', 'action']); + const result: InteractionStep = { id: id(source.id, `${path}.id`) }; + if (source.condition === undefined && source.action === undefined) fail(path, 'condition or action required'); + if (source.condition !== undefined) result.condition = condition(source.condition, `${path}.condition`, new Set(variables.keys())); + if (source.action !== undefined) result.action = action(source.action, `${path}.action`, variables); + return result; +} + +function flowGraph(value: unknown, path: string, ruleId: string, steps: ReadonlyMap): FlowGraph { + const source = plain(value, path, ['nodes', 'edges']); + if (!Array.isArray(source.nodes)) fail(`${path}.nodes`, 'array required'); + if (!Array.isArray(source.edges)) fail(`${path}.edges`, 'array required'); + const nodes: FlowNodeView[] = []; + for (const [index, entry] of source.nodes.entries()) { + const nodePath = `${path}.nodes[${index}]`; + const nodeSource = plain(entry, nodePath, ['id', 'kind', 'ruleId', 'stepId', 'x', 'y', 'label']); + const node: FlowNodeView = { + id: id(nodeSource.id, `${nodePath}.id`), + kind: oneOf(nodeSource.kind, [...FLOW_NODE_KINDS], `${nodePath}.kind`), + x: layoutGeometry(nodeSource.x, `${nodePath}.x`), + y: layoutGeometry(nodeSource.y, `${nodePath}.y`), + }; + if (nodeSource.ruleId !== undefined) { + const nodeRuleId = id(nodeSource.ruleId, `${nodePath}.ruleId`); + if (nodeRuleId !== ruleId) fail(`${nodePath}.ruleId`, 'node belongs to a different rule'); + node.ruleId = nodeRuleId; + } + if (nodeSource.label !== undefined) node.label = layoutLabel(nodeSource.label, `${nodePath}.label`); + const stepId = nodeSource.stepId === undefined ? undefined : id(nodeSource.stepId, `${nodePath}.stepId`); + if (stepId !== undefined) { + const linked = steps.get(stepId); + if (!linked) fail(`${nodePath}.stepId`, 'unknown step'); + if (node.kind === 'condition' && !linked.condition) fail(`${nodePath}.stepId`, 'condition node requires a step with a condition'); + if ((node.kind === 'action' || node.kind === 'result') && !linked.action) fail(`${nodePath}.stepId`, `${node.kind} node requires a step with an action`); + node.stepId = stepId; + } + nodes.push(node); + } + if (new Set(nodes.map((entry) => entry.id)).size !== nodes.length) fail(`${path}.nodes`, 'duplicate node id'); + const nodeIds = new Set(nodes.map((entry) => entry.id)); + const edges: FlowGraph['edges'] = []; + for (const [index, entry] of source.edges.entries()) { + const edgePath = `${path}.edges[${index}]`; + const edgeSource = plain(entry, edgePath, ['id', 'from', 'to', 'branch']); + const from = id(edgeSource.from, `${edgePath}.from`); + const to = id(edgeSource.to, `${edgePath}.to`); + if (!nodeIds.has(from) || !nodeIds.has(to)) fail(edgePath, 'edge references an unknown node'); + if (from === to) fail(edgePath, 'self-loop edge'); + edges.push({ + id: id(edgeSource.id, `${edgePath}.id`), + from, + to, + branch: oneOf(edgeSource.branch, [...FLOW_EDGE_BRANCHES], `${edgePath}.branch`), + }); + } + if (new Set(edges.map((entry) => entry.id)).size !== edges.length) fail(`${path}.edges`, 'duplicate edge id'); + return { nodes, edges }; +} + +function rule(value: unknown, path: string, variables: ReadonlyMap): InteractionRule { + const source = plain(value, path, ['id', 'screenId', 'elementId', 'event', 'condition', 'action', 'steps', 'graph', 'cooldownMs']); const screenId = text(source.screenId, `${path}.screenId`, 64); if (screenId !== '*' && !ID.test(screenId)) fail(`${path}.screenId`, 'invalid screen id or wildcard'); - if (source.event !== 'click') fail(`${path}.event`, 'only click is supported'); const result: InteractionRule = { id: id(source.id, `${path}.id`), screenId, elementId: id(source.elementId, `${path}.elementId`), - event: 'click', - action: action(source.action, `${path}.action`), + event: oneOf(source.event, [...FLOW_EVENT_TYPES], `${path}.event`), + action: action(source.action, `${path}.action`, variables), }; - if (source.condition !== undefined) result.condition = condition(source.condition, `${path}.condition`); + const variableIds = new Set(variables.keys()); + if (source.condition !== undefined) result.condition = condition(source.condition, `${path}.condition`, variableIds); + if (source.steps !== undefined) { + if (!Array.isArray(source.steps)) fail(`${path}.steps`, 'array required'); + const steps = source.steps.map((entry, index) => step(entry, `${path}.steps[${index}]`, variables)); + if (new Set(steps.map((entry) => entry.id)).size !== steps.length) fail(`${path}.steps`, 'duplicate step id'); + result.steps = steps; + } + if (source.graph !== undefined) { + const steps = new Map((result.steps ?? []).map((entry) => [entry.id, entry])); + result.graph = flowGraph(source.graph, `${path}.graph`, result.id, steps); + } + if (source.cooldownMs !== undefined) { + if (typeof source.cooldownMs !== 'number' || !Number.isInteger(source.cooldownMs) || source.cooldownMs < 0 || source.cooldownMs > FLOW_COOLDOWN_LIMIT) { + fail(`${path}.cooldownMs`, `integer from 0 to ${FLOW_COOLDOWN_LIMIT} required`); + } + result.cooldownMs = source.cooldownMs; + } return result; } export function parseFlowDocument(value: unknown): FlowDocument { - const source = plain(value, 'flow', ['schemaVersion', 'id', 'baseCommit', 'rules']); + const source = plain(value, 'flow', ['schemaVersion', 'id', 'baseCommit', 'rules', 'variables']); if (source.schemaVersion !== 1) fail('flow.schemaVersion', 'only schema version 1 is supported'); if (!Array.isArray(source.rules)) fail('flow.rules', 'array required'); - const rules = source.rules.map((entry, index) => rule(entry, `flow.rules[${index}]`)); + const variables = new Map(); + if (source.variables !== undefined) { + if (!Array.isArray(source.variables)) fail('flow.variables', 'array required'); + for (const [index, entry] of source.variables.entries()) { + const variable = flowVariable(entry, `flow.variables[${index}]`); + if (variables.has(variable.id)) fail('flow.variables', 'duplicate variable id'); + variables.set(variable.id, variable); + } + } + const rules = source.rules.map((entry, index) => rule(entry, `flow.rules[${index}]`, variables)); if (new Set(rules.map((entry) => entry.id)).size !== rules.length) fail('flow.rules', 'duplicate rule id'); - return { + const result: FlowDocument = { schemaVersion: 1, id: id(source.id, 'flow.id'), baseCommit: commit(source.baseCommit, 'flow.baseCommit'), rules, }; + if (source.variables !== undefined) result.variables = [...variables.values()]; + return result; } diff --git a/editor/src/components/FlowMap.tsx b/editor/src/components/FlowMap.tsx index a4d4033b..6c2886e8 100644 --- a/editor/src/components/FlowMap.tsx +++ b/editor/src/components/FlowMap.tsx @@ -4,7 +4,10 @@ function conditionLabel(condition: InteractionCondition | undefined): string { if (!condition) return 'always'; if (condition.kind === 'eq') return `${condition.path} = ${String(condition.value)}`; if (condition.kind === 'not') return `not ${conditionLabel(condition.condition)}`; - return `${condition.kind}(${condition.conditions.map(conditionLabel).join(', ')})`; + if (condition.kind === 'all' || condition.kind === 'any') { + return `${condition.kind}(${condition.conditions.map(conditionLabel).join(', ')})`; + } + return `${condition.kind} ${condition.path}`; } export function FlowMap({ flow }: { flow: FlowDocument }) { diff --git a/editor/src/components/LogicEditor.tsx b/editor/src/components/LogicEditor.tsx index 02ebc089..3585fc57 100644 --- a/editor/src/components/LogicEditor.tsx +++ b/editor/src/components/LogicEditor.tsx @@ -13,11 +13,12 @@ interface RuleForm { } function formOf(rule: InteractionRule): RuleForm { + const actionType = rule.action.type; return { condition: rule.condition?.kind === 'eq' && rule.condition.path === 'server.connected' ? rule.condition.value === true ? 'server-true' : 'server-false' : rule.condition ? 'custom' : 'none', - action: rule.action.type, + action: actionType === 'navigate' || actionType === 'back' || actionType === 'toast' ? actionType : 'toast', destination: rule.action.type === 'navigate' ? rule.action.screenId : 'library', message: rule.action.type === 'toast' ? rule.action.message : '', }; diff --git a/tests/design/flow-graph.test.ts b/tests/design/flow-graph.test.ts new file mode 100644 index 00000000..93a5b0cd --- /dev/null +++ b/tests/design/flow-graph.test.ts @@ -0,0 +1,362 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + beginFlowSession, + executeFlow, + executeGraphRule, + resetFlowSession, + type FlowDispatcher, +} from '../../design/flow-graph'; +import { createDefaultFlow } from '../../design/defaults'; +import type { + FlowDocument, + FlowEdge, + FlowEdgeBranch, + FlowEnvironment, + FlowGraph, + FlowNodeKind, + FlowNodeView, + FlowVariable, + InteractionRule, + InteractionStep, +} from '../../design/schema'; + +const BASE = '0000000000000000000000000000000000000000'; + +const ENV: FlowEnvironment = { + target: 'desktop-windows', + screenId: 'settings', + serverConnected: true, + networkOnline: true, + accountSignedIn: false, + playbackState: 'idle', + downloadState: 'idle', +}; + +function node(id: string, kind: FlowNodeKind, x: number, stepId?: string): FlowNodeView { + return stepId ? { id, kind, stepId, x, y: 0 } : { id, kind, x, y: 0 }; +} + +function edge(id: string, from: string, to: string, branch: FlowEdgeBranch = 'default'): FlowEdge { + return { id, from, to, branch }; +} + +function graph(nodes: FlowNodeView[], edges: FlowEdge[]): FlowGraph { + return { nodes, edges }; +} + +function flowDoc(rules: InteractionRule[], variables: FlowVariable[] = []): FlowDocument { + return { schemaVersion: 1, id: 'test-flow', baseCommit: BASE, rules, ...(variables.length ? { variables } : {}) }; +} + +function graphRule(id: string, elementId: string, g: FlowGraph, steps: InteractionStep[] = [], extra: Partial = {}): InteractionRule { + return { + id, + screenId: '*', + elementId, + event: 'click', + action: { type: 'toast', message: 'unused legacy action' }, + graph: g, + steps, + ...extra, + }; +} + +function stepsRule(id: string, elementId: string, steps: InteractionStep[], action: InteractionRule['action']): InteractionRule { + return { id, screenId: '*', elementId, event: 'click', steps, action }; +} + +function makeDispatcher(calls: string[], failures: Record = {}, throwers: string[] = []): FlowDispatcher { + const record = (name: string) => (...args: string[]) => { + calls.push(args.length ? `${name}(${args.join(',')})` : name); + if (throwers.includes(name)) throw new Error(`${name} exploded`); + return failures[name] ? { ok: false as const, error: failures[name] } : { ok: true as const }; + }; + return { + navigate: record('navigate'), + back: record('back'), + openScreen: record('openScreen'), + toast: record('toast'), + dialog: record('dialog'), + playbackPlayPause: record('playback.playPause'), + playbackStop: record('playback.stop'), + playbackNextEpisode: record('playback.nextEpisode'), + queueAdd: record('queue.add'), + queueReplace: record('queue.replace'), + serverConnect: record('server.connect'), + serverReconnect: record('server.reconnect'), + serverRetry: record('server.retry'), + sourceRetry: record('source.retry'), + sourceNext: record('source.next'), + linkOpen: record('link.open'), + telegramOpen: record('telegram.open'), + }; +} + +const click = (elementId: string, screenId = 'settings') => ({ type: 'click' as const, screenId, elementId }); + +beforeEach(() => { + beginFlowSession(ENV); +}); + +afterEach(() => { + resetFlowSession(); +}); + +describe('flow graph interpreter', () => { + it('legacy rules keep precedence over graph rules', () => { + const flow = flowDoc([ + graphRule('graph', 'settings-continue', graph( + [node('t', 'trigger', 0), node('a', 'action', 1, 's1')], + [edge('e1', 't', 'a')], + ), [{ id: 's1', action: { type: 'toast', message: 'graph ran' } }]), + { id: 'legacy', screenId: '*', elementId: 'settings-continue', event: 'click', action: { type: 'navigate', screenId: 'library' } }, + ]); + const calls: string[] = []; + const result = executeFlow(flow, click('settings-continue'), makeDispatcher(calls)); + expect(calls).toEqual(['navigate(library)']); + expect(result.matched).toBe('legacy'); + expect(result.status).toBe('completed'); + }); + + it('runs a graph rule only when no legacy rule matches', () => { + const flow = flowDoc([ + graphRule('graph', 'settings-continue', graph( + [node('t', 'trigger', 0), node('a', 'action', 1, 's1')], + [edge('e1', 't', 'a')], + ), [{ id: 's1', action: { type: 'toast', message: 'graph ran' } }]), + ]); + const calls: string[] = []; + const result = executeFlow(flow, click('settings-continue'), makeDispatcher(calls)); + expect(calls).toEqual(['toast(graph ran)']); + expect(result.matched).toBe('graph'); + expect(result.status).toBe('completed'); + }); + + it('follows true and false branches of a condition node', () => { + const flow = flowDoc([ + graphRule('branch', 'settings-continue', graph( + [node('t', 'trigger', 0), node('c', 'condition', 1, 'check'), node('yes', 'action', 2, 'on-yes'), node('no', 'action', 3, 'on-no')], + [edge('e1', 't', 'c'), edge('e2', 'c', 'yes', 'true'), edge('e3', 'c', 'no', 'false')], + ), [ + { id: 'check', condition: { kind: 'eq', path: 'server.connected', value: true } }, + { id: 'on-yes', action: { type: 'toast', message: 'yes' } }, + { id: 'on-no', action: { type: 'toast', message: 'no' } }, + ]), + ]); + const calls: string[] = []; + executeFlow(flow, click('settings-continue'), makeDispatcher(calls)); + expect(calls).toEqual(['toast(yes)']); + beginFlowSession({ ...ENV, serverConnected: false }); + executeFlow(flow, click('settings-continue'), makeDispatcher(calls)); + expect(calls).toEqual(['toast(yes)', 'toast(no)']); + }); + + it('routes dispatcher errors to the error branch and stops without one', () => { + const withErrorBranch = flowDoc([ + graphRule('recover', 'settings-continue', graph( + [node('t', 'trigger', 0), node('a', 'action', 1, 'go'), node('e', 'action', 2, 'fix')], + [edge('e1', 't', 'a'), edge('e2', 'a', 'e', 'error')], + ), [ + { id: 'go', action: { type: 'navigate', screenId: 'library' } }, + { id: 'fix', action: { type: 'toast', message: 'recovered' } }, + ]), + ]); + const calls: string[] = []; + const recovered = executeFlow(withErrorBranch, click('settings-continue'), makeDispatcher(calls, { navigate: 'storage full' })); + expect(calls).toEqual(['navigate(library)', 'toast(recovered)']); + expect(recovered.status).toBe('completed'); + + const withoutErrorBranch = flowDoc([ + graphRule('fail', 'settings-continue', graph( + [node('t', 'trigger', 0), node('a', 'action', 1, 'go')], + [edge('e1', 't', 'a')], + ), [{ id: 'go', action: { type: 'navigate', screenId: 'library' } }]), + ]); + calls.length = 0; + const failed = executeFlow(withoutErrorBranch, click('settings-continue'), makeDispatcher(calls, { navigate: 'storage full' })); + expect(failed.status).toBe('error'); + expect(failed.error).toBe('storage full'); + expect(calls).toEqual(['navigate(library)']); + }); + + it('treats a synchronous dispatcher throw as an action error', () => { + const flow = flowDoc([ + graphRule('throw', 'settings-continue', graph( + [node('t', 'trigger', 0), node('a', 'action', 1, 'go')], + [edge('e1', 't', 'a')], + ), [{ id: 'go', action: { type: 'navigate', screenId: 'library' } }]), + ]); + const calls: string[] = []; + const result = executeFlow(flow, click('settings-continue'), makeDispatcher(calls, {}, ['navigate'])); + expect(result.status).toBe('error'); + expect(result.error).toContain('exploded'); + }); + + it('keeps typed variables in the session until reset', () => { + const variables: FlowVariable[] = [ + { id: 'counter', name: 'Counter', type: 'number', initial: 0 }, + { id: 'mode', name: 'Mode', type: 'enum', initial: 'a', values: ['a', 'b'] }, + ]; + const flow = flowDoc([ + stepsRule('set-counter', 'inc', [{ id: 's1', action: { type: 'variable.set', variableId: 'counter', value: 5 } }], { type: 'navigate', screenId: 'library' }), + stepsRule('check-counter', 'check', [{ id: 's1', condition: { kind: 'eq', path: 'variable.counter', value: 5 }, action: { type: 'toast', message: 'five' } }], { type: 'back' }), + ], variables); + const calls: string[] = []; + const first = executeFlow(flow, click('inc'), makeDispatcher(calls)); + expect(first.variables).toEqual({ counter: 5, mode: 'a' }); + executeFlow(flow, click('check'), makeDispatcher(calls)); + expect(calls).toEqual(['navigate(library)', 'toast(five)', 'back']); + resetFlowSession(); + const afterReset = executeFlow(flow, click('check'), makeDispatcher(calls)); + expect(afterReset.variables).toEqual({ counter: 0, mode: 'a' }); + expect(calls).toEqual(['navigate(library)', 'toast(five)', 'back']); + }); + + it('stops after 32 steps', () => { + const nodes = [node('t', 'trigger', 0)]; + const edges: FlowEdge[] = []; + let previous = 't'; + for (let index = 1; index <= 40; index += 1) { + const id = `a${index}`; + nodes.push(node(id, 'action', index, 'tick')); + edges.push(edge(`e${index}`, previous, id)); + previous = id; + } + const flow = flowDoc([ + graphRule('long', 'settings-continue', graph(nodes, edges), [{ id: 'tick', action: { type: 'toast', message: 'tick' } }]), + ]); + const calls: string[] = []; + const result = executeFlow(flow, click('settings-continue'), makeDispatcher(calls)); + expect(result.status).toBe('step-limit'); + expect(result.steps).toBe(32); + expect(calls).toHaveLength(32); + }); + + it('collects one trace per event', () => { + const flow = flowDoc([ + graphRule('trace', 'settings-continue', graph( + [node('t', 'trigger', 0), node('c', 'condition', 1, 'check'), node('a1', 'action', 2, 's1'), node('a2', 'action', 3, 's2')], + [edge('e1', 't', 'c'), edge('e2', 'c', 'a1', 'true'), edge('e3', 'a1', 'a2')], + ), [ + { id: 'check', condition: { kind: 'eq', path: 'server.connected', value: true } }, + { id: 's1', action: { type: 'toast', message: 'one' } }, + { id: 's2', action: { type: 'toast', message: 'two' } }, + ]), + ]); + const calls: string[] = []; + const first = executeFlow(flow, click('settings-continue'), makeDispatcher(calls)); + expect(first.trace.map((entry) => entry.kind)).toEqual(['condition', 'action', 'action']); + const snapshot = first.trace; + executeFlow(flow, click('settings-continue'), makeDispatcher(calls)); + expect(snapshot).toHaveLength(3); + expect(first.trace).not.toBe(executeFlow(flow, click('settings-continue'), makeDispatcher(calls)).trace); + }); + + it('completes a terminal action without a result node', () => { + const flow = flowDoc([ + graphRule('terminal', 'settings-continue', graph( + [node('t', 'trigger', 0), node('a', 'action', 1, 'go'), node('b', 'action', 2, 'never')], + [edge('e1', 't', 'a'), edge('e2', 'a', 'b')], + ), [ + { id: 'go', action: { type: 'navigate', screenId: 'library' } }, + { id: 'never', action: { type: 'toast', message: 'never' } }, + ]), + ]); + const calls: string[] = []; + const result = executeFlow(flow, click('settings-continue'), makeDispatcher(calls)); + expect(result.status).toBe('completed'); + expect(result.error).toBeUndefined(); + expect(calls).toEqual(['navigate(library)']); + expect(result.route).toBe('library'); + }); + + it('ends without action when the matching branch is missing', () => { + const flow = flowDoc([ + graphRule('dangling', 'settings-continue', graph( + [node('t', 'trigger', 0), node('c', 'condition', 1, 'check'), node('a', 'action', 2, 's1')], + [edge('e1', 't', 'c'), edge('e2', 'c', 'a', 'true')], + ), [ + { id: 'check', condition: { kind: 'eq', path: 'server.connected', value: true } }, + { id: 's1', action: { type: 'toast', message: 'unreachable' } }, + ]), + ]); + const calls: string[] = []; + beginFlowSession({ ...ENV, serverConnected: false }); + const result = executeFlow(flow, click('settings-continue'), makeDispatcher(calls)); + expect(result.status).toBe('completed'); + expect(calls).toEqual([]); + }); + + it('runs ordered steps without a graph', () => { + const flow = flowDoc([ + stepsRule('chain', 'settings-continue', [ + { id: 's1', action: { type: 'toast', message: 'one' } }, + { id: 's2', action: { type: 'toast', message: 'two' } }, + ], { type: 'navigate', screenId: 'library' }), + ]); + const calls: string[] = []; + const result = executeFlow(flow, click('settings-continue'), makeDispatcher(calls)); + expect(calls).toEqual(['toast(one)', 'toast(two)', 'navigate(library)']); + expect(result.status).toBe('completed'); + }); + + it('stops ordered steps when a condition fails', () => { + const flow = flowDoc([ + stepsRule('gated', 'settings-continue', [ + { id: 's1', condition: { kind: 'eq', path: 'server.connected', value: true }, action: { type: 'toast', message: 'gated' } }, + { id: 's2', action: { type: 'toast', message: 'after' } }, + ], { type: 'navigate', screenId: 'library' }), + ]); + const calls: string[] = []; + beginFlowSession({ ...ENV, serverConnected: false }); + executeFlow(flow, click('settings-continue'), makeDispatcher(calls)); + expect(calls).toEqual([]); + }); + + it('reports not-matched when no rule fits', () => { + const calls: string[] = []; + const result = executeFlow(flowDoc([]), click('settings-continue'), makeDispatcher(calls)); + expect(result.status).toBe('not-matched'); + expect(result.matched).toBeNull(); + expect(result.trace).toEqual([]); + expect(result.steps).toBe(0); + }); + + it('executes legacy documents through the legacy path', () => { + const calls: string[] = []; + beginFlowSession({ ...ENV, serverConnected: false }); + const result = executeFlow(createDefaultFlow(BASE), click('settings-continue'), makeDispatcher(calls)); + expect(calls).toEqual(['navigate(connect)']); + expect(result.matched).toBe('legacy'); + expect(result.route).toBe('connect'); + }); + + it('treats a result node as the terminal collector', () => { + const flow = flowDoc([ + graphRule('collect', 'settings-continue', graph( + [node('t', 'trigger', 0), node('a', 'action', 1, 's1'), node('r', 'result', 2, 'fin')], + [edge('e1', 't', 'a'), edge('e2', 'a', 'r')], + ), [ + { id: 's1', action: { type: 'toast', message: 'done' } }, + { id: 'fin', action: { type: 'navigate', screenId: 'library' } }, + ]), + ]); + const calls: string[] = []; + const result = executeFlow(flow, click('settings-continue'), makeDispatcher(calls)); + expect(calls).toEqual(['toast(done)', 'navigate(library)']); + expect(result.status).toBe('completed'); + expect(result.route).toBe('library'); + expect(result.trace[result.trace.length - 1]).toMatchObject({ kind: 'result' }); + }); + + it('executeGraphRule skips a rule whose trigger does not match', () => { + const rule = graphRule('scoped', 'settings-continue', graph( + [node('t', 'trigger', 0), node('a', 'action', 1, 's1')], + [edge('e1', 't', 'a')], + ), [{ id: 's1', action: { type: 'toast', message: 'hi' } }], { screenId: 'settings' }); + const calls: string[] = []; + const result = executeGraphRule(rule, click('settings-continue', 'library'), makeDispatcher(calls), []); + expect(result.status).toBe('not-matched'); + expect(calls).toEqual([]); + }); +}); diff --git a/tests/design/schema.test.ts b/tests/design/schema.test.ts index 8f25733f..9b16781e 100644 --- a/tests/design/schema.test.ts +++ b/tests/design/schema.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { createDefaultFlow, createDefaultTheme } from '../../design/defaults'; -import type { LayoutContainer, LayoutPlacement } from '../../design/schema'; +import type { FlowDocument, FlowVariable, InteractionCondition, InteractionRule, LayoutContainer, LayoutPlacement } from '../../design/schema'; import { DesignValidationError, parseFlowDocument, parseThemeDocument } from '../../design/validation'; const BASE = '0000000000000000000000000000000000000000'; @@ -202,3 +202,199 @@ describe('layout schema validation', () => { expect(() => parseThemeDocument(theme)).toThrow(DesignValidationError); }); }); + +describe('flow graph schema', () => { + const graphFlow = (): FlowDocument => ({ + schemaVersion: 1, + id: 'graph-flow', + baseCommit: BASE, + variables: [ + { id: 'server-name', name: 'Server', type: 'string', initial: 'omp' }, + { id: 'retries', name: 'Retries', type: 'number', initial: 0 }, + { id: 'active', name: 'Active', type: 'boolean', initial: false }, + { id: 'mode', name: 'Mode', type: 'enum', initial: 'a', values: ['a', 'b'] }, + ], + rules: [ + { + id: 'enter-library', + screenId: 'library', + elementId: 'library-root', + event: 'screen.enter', + condition: { kind: 'gte', path: 'variable.retries', value: 0 }, + steps: [ + { id: 'check', condition: { kind: 'exists', path: 'network.online' } }, + { id: 'mark', action: { type: 'variable.set', variableId: 'active', value: true } }, + { id: 'notify', action: { type: 'dialog', title: 'Hi', message: 'There' } }, + ], + action: { type: 'navigate', screenId: 'library' }, + graph: { + nodes: [ + { id: 't', kind: 'trigger', ruleId: 'enter-library', x: 0, y: 0 }, + { id: 'c', kind: 'condition', stepId: 'check', x: 160, y: 0, label: 'Online' }, + { id: 'r', kind: 'result', stepId: 'mark', x: 320, y: 0 }, + ], + edges: [ + { id: 'e1', from: 't', to: 'c', branch: 'default' }, + { id: 'e2', from: 'c', to: 'r', branch: 'true' }, + ], + }, + cooldownMs: 500, + }, + ], + }); + + it('accepts graph rules, typed variables and new events', () => { + expect(parseFlowDocument(graphFlow())).toEqual(graphFlow()); + }); + + it('keeps legacy documents without graph unchanged', () => { + expect(parseFlowDocument(createDefaultFlow(BASE))).toEqual(createDefaultFlow(BASE)); + }); + + it('keeps legacy rule order in a mixed document', () => { + const flow = createDefaultFlow(BASE); + const graphDoc = graphFlow(); + flow.variables = graphDoc.variables; + flow.rules.push(graphDoc.rules[0]); + flow.rules.unshift({ id: 'first', screenId: '*', elementId: 'top', event: 'click', action: { type: 'back' } }); + const parsed = parseFlowDocument(flow); + expect(parsed.rules.map((rule) => rule.id)).toEqual(['first', ...createDefaultFlow(BASE).rules.map((rule) => rule.id), 'enter-library']); + }); + + it('rejects variables whose initial value does not match the type', () => { + const reject = (variable: FlowVariable) => { + const flow = graphFlow(); + flow.variables = [variable]; + expect(() => parseFlowDocument(flow), JSON.stringify(variable)).toThrow(DesignValidationError); + }; + reject({ id: 'v', name: 'V', type: 'number', initial: '0' }); + reject({ id: 'v', name: 'V', type: 'boolean', initial: 1 }); + reject({ id: 'v', name: 'V', type: 'string', initial: true }); + reject({ id: 'v', name: 'V', type: 'enum', initial: 'c', values: ['a', 'b'] }); + reject({ id: 'v', name: 'V', type: 'enum', initial: 'a' }); + reject({ id: 'v', name: 'V', type: 'enum', initial: 'a', values: [] }); + reject({ id: 'v', name: 'V', type: 'number', initial: 0, values: ['a'] } as never); + }); + + it('rejects duplicate variable ids', () => { + const flow = graphFlow(); + flow.variables = [ + { id: 'same', name: 'One', type: 'boolean', initial: false }, + { id: 'same', name: 'Two', type: 'boolean', initial: true }, + ]; + expect(() => parseFlowDocument(flow)).toThrow(DesignValidationError); + }); + + it('rejects variable.set and condition paths against unknown variables', () => { + const unknownSet = graphFlow(); + (unknownSet.rules[0].steps![1].action as { variableId: string }).variableId = 'missing'; + expect(() => parseFlowDocument(unknownSet)).toThrow(DesignValidationError); + + const wrongType = graphFlow(); + (wrongType.rules[0].steps![1].action as { value: unknown }).value = 'not-a-boolean'; + expect(() => parseFlowDocument(wrongType)).toThrow(DesignValidationError); + + const unknownPath = graphFlow(); + unknownPath.rules[0].condition = { kind: 'eq', path: 'variable.missing', value: 1 }; + expect(() => parseFlowDocument(unknownPath)).toThrow(DesignValidationError); + }); + + it('rejects executable actions, arbitrary paths and network access', () => { + const mutate = (patch: (rule: InteractionRule) => void) => { + const flow = graphFlow(); + patch(flow.rules[0]); + return flow; + }; + expect(() => parseFlowDocument(mutate((rule) => { + rule.steps = [{ id: 'x', action: { type: 'eval', code: 'alert(1)' } as never }]; + }))).toThrow(DesignValidationError); + expect(() => parseFlowDocument(mutate((rule) => { + rule.steps = [{ id: 'x', action: { type: 'fetch', url: 'https://evil.example.com' } as never }]; + }))).toThrow(DesignValidationError); + expect(() => parseFlowDocument(mutate((rule) => { + rule.steps = [{ id: 'x', action: { type: 'variable.set', variableId: 'active', value: { injected: true } } as never }]; + }))).toThrow(DesignValidationError); + expect(() => parseFlowDocument(mutate((rule) => { + rule.condition = { kind: 'eq', path: 'window.location' as never, value: true }; + }))).toThrow(DesignValidationError); + expect(() => parseFlowDocument(mutate((rule) => { + rule.steps = [{ id: 'x', action: { type: 'link.open', url: 'http://t.me/omp' } }]; + }))).toThrow(DesignValidationError); + expect(() => parseFlowDocument(mutate((rule) => { + rule.steps = [{ id: 'x', action: { type: 'link.open', url: 'https://evil.example.com/x' } }]; + }))).toThrow(DesignValidationError); + expect(() => parseFlowDocument(mutate((rule) => { + rule.steps = [{ id: 'x', action: { type: 'telegram.open', target: 'random-chat' } }]; + }))).toThrow(DesignValidationError); + const allowed = mutate((rule) => { + rule.steps = [ + ...(rule.steps ?? []), + { id: 'x', action: { type: 'link.open', url: 'https://t.me/ompplyaer' } }, + { id: 'y', action: { type: 'telegram.open', target: 'channel' } }, + ]; + }); + expect(() => parseFlowDocument(allowed)).not.toThrow(); + }); + + it('rejects condition values that do not match the operator', () => { + const mutate = (condition: InteractionCondition) => { + const flow = graphFlow(); + flow.rules[0].condition = condition; + return flow; + }; + expect(() => parseFlowDocument(mutate({ kind: 'gt', path: 'variable.retries', value: '10' }))).toThrow(DesignValidationError); + expect(() => parseFlowDocument(mutate({ kind: 'contains', path: 'server-name' as never, value: 5 }))).toThrow(DesignValidationError); + expect(() => parseFlowDocument(mutate({ kind: 'eq', path: 'server.connected' } as never))).toThrow(DesignValidationError); + expect(() => parseFlowDocument(mutate({ kind: 'exists', path: 'network.online', value: true } as never))).toThrow(DesignValidationError); + }); + + it('rejects malformed graphs and steps', () => { + const mutate = (patch: (rule: InteractionRule) => void) => { + const flow = graphFlow(); + patch(flow.rules[0]); + expect(() => parseFlowDocument(flow)).toThrow(DesignValidationError); + }; + mutate((rule) => { + rule.graph = { nodes: [{ id: 't', kind: 'trigger', x: 0, y: 0 }], edges: [{ id: 'e', from: 't', to: 't', branch: 'default' }] }; + }); + mutate((rule) => { + rule.graph = { nodes: [{ id: 't', kind: 'trigger', x: 0, y: 0 }], edges: [{ id: 'e', from: 't', to: 'missing', branch: 'default' }] }; + }); + mutate((rule) => { + rule.graph = { + nodes: [{ id: 't', kind: 'trigger', x: 0, y: 0 }, { id: 't', kind: 'action', x: 1, y: 0 }], + edges: [], + }; + }); + mutate((rule) => { + rule.graph = { nodes: [{ id: 't', kind: 'delay' as never, x: 0, y: 0 }], edges: [] }; + }); + mutate((rule) => { + rule.graph = { + nodes: [{ id: 't', kind: 'trigger', x: 0, y: 0 }, { id: 'a', kind: 'action', x: 1, y: 0 }], + edges: [{ id: 'e', from: 't', to: 'a', branch: 'maybe' as never }], + }; + }); + mutate((rule) => { + rule.graph = { nodes: [{ id: 't', kind: 'trigger', x: -1, y: 0 }], edges: [] }; + }); + mutate((rule) => { + rule.graph = { nodes: [{ id: 't', kind: 'trigger', ruleId: 'other-rule', x: 0, y: 0 }], edges: [] }; + }); + mutate((rule) => { + rule.steps = [{ id: 'same', action: { type: 'back' } }, { id: 'same', action: { type: 'back' } }]; + }); + mutate((rule) => { + rule.steps = [{ id: 'empty' }]; + }); + mutate((rule) => { + delete (rule as { action?: InteractionRule['action'] }).action; + }); + mutate((rule) => { + rule.cooldownMs = -1; + }); + mutate((rule) => { + rule.cooldownMs = 1.5; + }); + }); +}); From 8f8609279aeff7b85c0efdb5fc2a7757f0272d2b Mon Sep 17 00:00:00 2001 From: ANDY BUM <47109571+spacesarmat@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:15:02 +0300 Subject: [PATCH 04/26] feat: add bounded timer triggers and cooldown regression tests --- design/flow-graph.ts | 3 ++- design/schema.ts | 7 +++++++ design/validation.ts | 25 ++++++++++++++++++++--- tests/design/flow-graph.test.ts | 35 +++++++++++++++++++++++++++++++++ tests/design/schema.test.ts | 35 +++++++++++++++++++++++++++++++++ 5 files changed, 101 insertions(+), 4 deletions(-) diff --git a/design/flow-graph.ts b/design/flow-graph.ts index dda75111..6d16ed74 100644 --- a/design/flow-graph.ts +++ b/design/flow-graph.ts @@ -395,7 +395,8 @@ function triggerMatches(rule: InteractionRule, event: FlowEvent, context: Execut return false; } if (rule.screenId !== '*' && rule.screenId !== event.screenId) return false; - if (rule.elementId !== event.elementId || rule.event !== event.type) return false; + if (rule.event !== event.type) return false; + if (rule.elementId !== '*' && rule.elementId !== event.elementId) return false; if (rule.condition && !evaluateCondition(rule.condition, context)) return false; active.lastRunAt[rule.id] = Date.now(); return true; diff --git a/design/schema.ts b/design/schema.ts index 8c093b29..cc67be2d 100644 --- a/design/schema.ts +++ b/design/schema.ts @@ -156,9 +156,15 @@ export const FLOW_EVENT_TYPES = [ 'download.started', 'download.completed', 'download.failed', + 'timer', ] as const; export type FlowEventType = (typeof FLOW_EVENT_TYPES)[number]; +// Timer triggers are representable now, but nothing starts them in the background: +// a bounded interval is validated here, and only an explicit timer event runs the rule. +export const FLOW_TIMER_INTERVAL_MIN = 1000; +export const FLOW_TIMER_INTERVAL_MAX = 3600000; + export const FLOW_NODE_KINDS = ['trigger', 'condition', 'action', 'result'] as const; export type FlowNodeKind = (typeof FLOW_NODE_KINDS)[number]; export const FLOW_EDGE_BRANCHES = ['default', 'true', 'false', 'error'] as const; @@ -254,6 +260,7 @@ export interface InteractionRule { steps?: InteractionStep[]; graph?: FlowGraph; cooldownMs?: number; + intervalMs?: number; } export interface FlowDocument { diff --git a/design/validation.ts b/design/validation.ts index 709a4534..9324000f 100644 --- a/design/validation.ts +++ b/design/validation.ts @@ -7,6 +7,8 @@ import { FLOW_LINK_HOSTS, FLOW_NODE_KINDS, FLOW_TELEGRAM_TARGETS, + FLOW_TIMER_INTERVAL_MAX, + FLOW_TIMER_INTERVAL_MIN, FLOW_VARIABLE_TYPES, LAYOUT_ALIGNMENTS, LAYOUT_ANCHORS, @@ -517,14 +519,21 @@ function flowGraph(value: unknown, path: string, ruleId: string, steps: Readonly } function rule(value: unknown, path: string, variables: ReadonlyMap): InteractionRule { - const source = plain(value, path, ['id', 'screenId', 'elementId', 'event', 'condition', 'action', 'steps', 'graph', 'cooldownMs']); + const source = plain(value, path, ['id', 'screenId', 'elementId', 'event', 'condition', 'action', 'steps', 'graph', 'cooldownMs', 'intervalMs']); const screenId = text(source.screenId, `${path}.screenId`, 64); if (screenId !== '*' && !ID.test(screenId)) fail(`${path}.screenId`, 'invalid screen id or wildcard'); + const event = oneOf(source.event, [...FLOW_EVENT_TYPES], `${path}.event`); + const elementId = text(source.elementId, `${path}.elementId`, 64); + if (event === 'timer') { + if (elementId !== '*' && !ID.test(elementId)) fail(`${path}.elementId`, 'invalid element id or wildcard'); + } else { + id(source.elementId, `${path}.elementId`); + } const result: InteractionRule = { id: id(source.id, `${path}.id`), screenId, - elementId: id(source.elementId, `${path}.elementId`), - event: oneOf(source.event, [...FLOW_EVENT_TYPES], `${path}.event`), + elementId, + event, action: action(source.action, `${path}.action`, variables), }; const variableIds = new Set(variables.keys()); @@ -545,6 +554,16 @@ function rule(value: unknown, path: string, variables: ReadonlyMap FLOW_TIMER_INTERVAL_MAX) { + fail(`${path}.intervalMs`, `integer from ${FLOW_TIMER_INTERVAL_MIN} to ${FLOW_TIMER_INTERVAL_MAX} required`); + } + result.intervalMs = source.intervalMs; + } else if (source.intervalMs !== undefined) { + fail(`${path}.intervalMs`, 'intervalMs is only allowed for timer rules'); + } return result; } diff --git a/tests/design/flow-graph.test.ts b/tests/design/flow-graph.test.ts index 93a5b0cd..5c919d0d 100644 --- a/tests/design/flow-graph.test.ts +++ b/tests/design/flow-graph.test.ts @@ -359,4 +359,39 @@ describe('flow graph interpreter', () => { expect(result.status).toBe('not-matched'); expect(calls).toEqual([]); }); + + it('runs timer triggers only from explicit timer events without background execution', () => { + const flow = flowDoc([ + graphRule('heartbeat', '*', graph( + [node('t', 'trigger', 0), node('a', 'action', 1, 's1')], + [edge('e1', 't', 'a')], + ), [{ id: 's1', action: { type: 'toast', message: 'tick' } }], { event: 'timer', intervalMs: 60000 }), + ]); + const calls: string[] = []; + const timer = executeFlow(flow, { type: 'timer', screenId: 'settings', elementId: 'heartbeat' }, makeDispatcher(calls)); + expect(timer.matched).toBe('graph'); + expect(calls).toEqual(['toast(tick)']); + executeFlow(flow, click('heartbeat'), makeDispatcher(calls)); + expect(calls).toEqual(['toast(tick)']); + }); + + it('applies cooldownMs between repeated triggers', () => { + const flow = flowDoc([ + graphRule('cool', 'settings-continue', graph( + [node('t', 'trigger', 0), node('a', 'action', 1, 's1')], + [edge('e1', 't', 'a')], + ), [{ id: 's1', action: { type: 'toast', message: 'once' } }], { cooldownMs: 50000 }), + ]); + const calls: string[] = []; + const first = executeFlow(flow, click('settings-continue'), makeDispatcher(calls)); + expect(first.matched).toBe('graph'); + const second = executeFlow(flow, click('settings-continue'), makeDispatcher(calls)); + expect(second.status).toBe('not-matched'); + expect(calls).toHaveLength(1); + resetFlowSession(); + beginFlowSession(ENV); + const third = executeFlow(flow, click('settings-continue'), makeDispatcher(calls)); + expect(third.matched).toBe('graph'); + expect(calls).toHaveLength(2); + }); }); diff --git a/tests/design/schema.test.ts b/tests/design/schema.test.ts index 9b16781e..df1cc325 100644 --- a/tests/design/schema.test.ts +++ b/tests/design/schema.test.ts @@ -397,4 +397,39 @@ describe('flow graph schema', () => { rule.cooldownMs = 1.5; }); }); + + it('represents timer triggers with a bounded interval and no background execution', () => { + const flow = graphFlow(); + flow.rules = [{ + id: 'heartbeat', + screenId: 'library', + elementId: '*', + event: 'timer', + intervalMs: 60000, + action: { type: 'toast', message: 'tick' }, + }]; + expect(parseFlowDocument(flow)).toEqual(flow); + + const reject = (patch: (rule: InteractionRule) => void) => { + const invalid = graphFlow(); + invalid.rules = [flow.rules[0]]; + patch(invalid.rules[0]); + expect(() => parseFlowDocument(invalid)).toThrow(DesignValidationError); + }; + reject((rule) => { + delete (rule as { intervalMs?: number }).intervalMs; + }); + reject((rule) => { + rule.intervalMs = 500; + }); + reject((rule) => { + rule.intervalMs = 3600001; + }); + reject((rule) => { + rule.intervalMs = 1500.5; + }); + reject((rule) => { + rule.event = 'click'; + }); + }); }); From 8fde8d217d886d4cfd6b5cb45e6d49abf8db5d2c Mon Sep 17 00:00:00 2001 From: ANDY BUM <47109571+spacesarmat@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:38:30 +0300 Subject: [PATCH 05/26] feat: validate and rebase flow graphs --- design/flow-graph.ts | 2 +- design/flow-validation-graph.ts | 211 +++++++++++++++++++ design/flow-validation.ts | 22 +- editor/design.mjs | 64 +++--- tests/design/flow-validation-graph.test.ts | 223 +++++++++++++++++++++ tests/editor/design.test.ts | 130 +++++++++++- 6 files changed, 619 insertions(+), 33 deletions(-) create mode 100644 design/flow-validation-graph.ts create mode 100644 tests/design/flow-validation-graph.test.ts diff --git a/design/flow-graph.ts b/design/flow-graph.ts index 6d16ed74..7ac42ae4 100644 --- a/design/flow-graph.ts +++ b/design/flow-graph.ts @@ -225,7 +225,7 @@ function runAction( // Terminal actions end the branch after they run; toast, dialog and telegram.open are // fire-and-forget and let the walk continue along the default edge. -const TERMINAL_ACTIONS = new Set([ +export const TERMINAL_ACTIONS = new Set([ 'navigate', 'back', 'openScreen', diff --git a/design/flow-validation-graph.ts b/design/flow-validation-graph.ts new file mode 100644 index 00000000..0c06fdcc --- /dev/null +++ b/design/flow-validation-graph.ts @@ -0,0 +1,211 @@ +import { TERMINAL_ACTIONS } from './flow-graph.ts'; +import type { + FlowDocument, + FlowEdge, + FlowGraph, + FlowNodeView, + FlowVariable, + FlowVariableValue, + InteractionAction, + InteractionCondition, + InteractionRule, + InteractionStep, +} from './schema.ts'; +import type { FlowIssue } from './flow-validation.ts'; + +// The legacy pass only executes navigate, back and toast rules without steps or a graph; +// a rule with any other action and no steps or graph never runs in either runtime. +const LEGACY_ACTION_TYPES = new Set(['navigate', 'back', 'toast']); + +function variableAccepts(variable: FlowVariable, value: FlowVariableValue): boolean { + if (variable.type === 'boolean') return typeof value === 'boolean'; + if (variable.type === 'number') return typeof value === 'number'; + if (variable.type === 'string') return typeof value === 'string'; + return typeof value === 'string' && (variable.values ?? []).includes(value); +} + +interface RuleScope { + rule: InteractionRule; + variables: ReadonlyMap; + routes: ReadonlySet; + issues: FlowIssue[]; + nodeByStep: Map; +} + +function checkCondition(condition: InteractionCondition, scope: RuleScope, nodeId?: string): void { + if (condition.kind === 'all' || condition.kind === 'any') { + for (const entry of condition.conditions) checkCondition(entry, scope, nodeId); + return; + } + if (condition.kind === 'not') { + checkCondition(condition.condition, scope, nodeId); + return; + } + if (condition.kind === 'exists') return; + const issue = (message: string) => + scope.issues.push({ code: 'WRONG_VALUE_TYPE', ruleId: scope.rule.id, ...(nodeId ? { nodeId } : {}), message }); + if (condition.kind === 'gt' || condition.kind === 'gte' || condition.kind === 'lt' || condition.kind === 'lte') { + if (typeof condition.value !== 'number') issue(`condition ${condition.kind} expects a number value`); + return; + } + if (condition.kind === 'contains') { + if (typeof condition.value !== 'string') issue('condition contains expects a string value'); + return; + } + if (condition.path.startsWith('variable.')) { + const variable = scope.variables.get(condition.path.slice('variable.'.length)); + if (variable && !variableAccepts(variable, condition.value)) { + issue(`condition on variable ${variable.id} expects a ${variable.type} value`); + } + } +} + +function checkAction(action: InteractionAction, scope: RuleScope, nodeId?: string): void { + const issue = (code: FlowIssue['code'], message: string) => + scope.issues.push({ code, ruleId: scope.rule.id, ...(nodeId ? { nodeId } : {}), message }); + if ((action.type === 'navigate' || action.type === 'openScreen') && !scope.routes.has(action.screenId)) { + issue('UNKNOWN_ROUTE', `unknown route ${action.screenId}`); + } + if (action.type === 'toast' && !action.message.trim()) issue('EMPTY_MESSAGE', 'toast message is empty'); + if (action.type === 'dialog' && (!action.title.trim() || !action.message.trim())) { + issue('EMPTY_MESSAGE', 'dialog title and message are required'); + } + if (action.type === 'variable.set') { + const variable = scope.variables.get(action.variableId); + if (variable && !variableAccepts(variable, action.value)) { + issue('WRONG_VALUE_TYPE', `variable ${action.variableId} expects a ${variable.type} value`); + } + } +} + +function isTerminal(node: FlowNodeView, steps: ReadonlyMap): boolean { + if (node.kind === 'result') return true; + if (!node.stepId) return false; + const action = steps.get(node.stepId)?.action; + return Boolean(action && TERMINAL_ACTIONS.has(action.type)); +} + +function validateGraph(scope: RuleScope, graph: FlowGraph): void { + const { rule, issues } = scope; + const nodes = new Map(graph.nodes.map((entry) => [entry.id, entry])); + const known = (id: string) => nodes.has(id); + + const outgoing = new Map(); + for (const edge of graph.edges) { + if (!known(edge.from) || !known(edge.to)) { + issues.push({ + code: 'MISSING_NODE', + ruleId: rule.id, + edgeId: edge.id, + message: `edge ${edge.id} references an unknown node`, + }); + } + if (edge.from === edge.to) { + issues.push({ code: 'SELF_LOOP', ruleId: rule.id, edgeId: edge.id, message: `edge ${edge.id} loops onto its own node` }); + } + outgoing.set(edge.from, [...(outgoing.get(edge.from) ?? []), edge]); + } + for (const [from, branches] of outgoing) { + const byBranch = new Map(); + for (const edge of branches) byBranch.set(edge.branch, [...(byBranch.get(edge.branch) ?? []), edge]); + for (const [branch, edges] of byBranch) { + if (edges.length > 1) { + issues.push({ + code: 'AMBIGUOUS_BRANCH', + ruleId: rule.id, + nodeId: from, + edgeId: edges[1].id, + message: `node ${from} has ${edges.length} outgoing ${branch} edges`, + }); + } + } + } + + // Cycle detection and reachability walk only over edges between existing nodes. + const adjacency = new Map(); + for (const edge of graph.edges) { + if (!known(edge.from) || !known(edge.to)) continue; + adjacency.set(edge.from, [...(adjacency.get(edge.from) ?? []), edge.to]); + } + const state = new Map(); + const visit = (id: string): void => { + state.set(id, 'visiting'); + for (const next of adjacency.get(id) ?? []) { + const status = state.get(next); + if (status === 'visiting') { + issues.push({ code: 'CYCLE', ruleId: rule.id, nodeId: next, message: `cycle reaches node ${next}` }); + } else if (status !== 'done') { + visit(next); + } + } + state.set(id, 'done'); + }; + for (const entry of graph.nodes) { + if (!state.has(entry.id)) visit(entry.id); + } + + const triggers = graph.nodes.filter((entry) => entry.kind === 'trigger'); + if (!triggers.length) return; + const reachable = new Set(); + const queue = triggers.map((entry) => entry.id); + for (const id of queue) reachable.add(id); + while (queue.length) { + const id = queue.shift()!; + for (const next of adjacency.get(id) ?? []) { + if (reachable.has(next)) continue; + reachable.add(next); + queue.push(next); + } + } + for (const entry of graph.nodes) { + if (!reachable.has(entry.id)) { + issues.push({ + code: 'UNREACHABLE_NODE', + ruleId: rule.id, + nodeId: entry.id, + message: `node ${entry.id} is not reachable from a trigger`, + }); + } + } + const steps = new Map((rule.steps ?? []).map((entry) => [entry.id, entry])); + const hasTerminalPath = [...reachable].some((id) => { + const entry = nodes.get(id); + return Boolean(entry && isTerminal(entry, steps)); + }); + if (!hasTerminalPath) { + issues.push({ + code: 'NO_TERMINAL_PATH', + ruleId: rule.id, + nodeId: triggers[0].id, + message: `trigger ${triggers[0].id} has no path to a result or terminal action`, + }); + } +} + +export function validateFlowGraph(flow: FlowDocument, routeIds: string[]): FlowIssue[] { + const issues: FlowIssue[] = []; + const variables = new Map((flow.variables ?? []).map((entry) => [entry.id, entry])); + const routes = new Set(routeIds); + for (const rule of flow.rules) { + const nodeByStep = new Map( + (rule.graph?.nodes ?? []).flatMap((entry) => (entry.stepId ? [[entry.stepId, entry.id] as const] : [])), + ); + const scope: RuleScope = { rule, variables, routes, issues, nodeByStep }; + if (!rule.graph && !(rule.steps?.length) && !LEGACY_ACTION_TYPES.has(rule.action.type)) { + issues.push({ + code: 'DEAD_RULE', + ruleId: rule.id, + message: `rule ${rule.id} has a ${rule.action.type} action but no steps or graph, so no runtime executes it`, + }); + } + if (rule.condition) checkCondition(rule.condition, scope); + checkAction(rule.action, scope); + for (const step of rule.steps ?? []) { + const nodeId = nodeByStep.get(step.id); + if (step.condition) checkCondition(step.condition, scope, nodeId); + if (step.action) checkAction(step.action, scope, nodeId); + } + if (rule.graph) validateGraph(scope, rule.graph); + } + return issues; +} diff --git a/design/flow-validation.ts b/design/flow-validation.ts index 61768481..32a5c78d 100644 --- a/design/flow-validation.ts +++ b/design/flow-validation.ts @@ -1,8 +1,26 @@ import type { FlowDocument, InteractionRule } from './schema.ts'; +export { validateFlowGraph } from './flow-validation-graph.ts'; + +export type FlowIssueCode = + | 'SHADOWED_RULE' + | 'UNKNOWN_ROUTE' + | 'DUPLICATE_RULE' + | 'AMBIGUOUS_BRANCH' + | 'SELF_LOOP' + | 'MISSING_NODE' + | 'CYCLE' + | 'WRONG_VALUE_TYPE' + | 'UNREACHABLE_NODE' + | 'NO_TERMINAL_PATH' + | 'DEAD_RULE' + | 'EMPTY_MESSAGE'; + export interface FlowIssue { - code: 'SHADOWED_RULE' | 'UNKNOWN_ROUTE' | 'DUPLICATE_RULE'; - ruleId: string; + code: FlowIssueCode; + ruleId?: string; + nodeId?: string; + edgeId?: string; message: string; } diff --git a/editor/design.mjs b/editor/design.mjs index c53efdad..2759942d 100644 --- a/editor/design.mjs +++ b/editor/design.mjs @@ -98,6 +98,20 @@ function competingParentIssues(layout) { return []; } +function mergeKey(path) { + if (path === 'theme.releaseIntents') return 'target'; + if (path === 'theme.overrides' + || path === 'flow.rules' + || path === 'flow.variables' + || path === 'theme.stylePresets' + || path === 'theme.layout.placements' + || path === 'theme.layout.containers' + || path === 'theme.layout.clones') return 'id'; + // Records inside one flow rule (ordered steps, graph nodes and edges) carry unique ids. + if (/\.steps$/.test(path) || /\.graph\.nodes$/.test(path) || /\.graph\.edges$/.test(path)) return 'id'; + return null; +} + function mergeValue(base, local, incoming, path, issues) { if (equal(local, base)) return structuredClone(incoming); if (equal(incoming, base) || equal(local, incoming)) return structuredClone(local); if (object(base) && object(local) && object(incoming)) { @@ -109,35 +123,29 @@ function mergeValue(base, local, incoming, path, issues) { if (equal(local, bas } return result; } - if (Array.isArray(base) && Array.isArray(local) && Array.isArray(incoming)) { - // Layout records and style presets carry unique record ids, so independent edits to - // different nodes, containers, clones or presets merge entry by entry instead of - // conflicting as whole arrays. - const key = path === 'theme.releaseIntents' ? 'target' - : path === 'theme.overrides' - || path === 'flow.rules' - || path === 'theme.stylePresets' - || path === 'theme.layout.placements' - || path === 'theme.layout.containers' - || path === 'theme.layout.clones' - ? 'id' - : null; - if (key) { - const merged = new Map(); - const allIds = new Set([...base, ...local, ...incoming].map((entry) => entry[key])); - for (const id of allIds) { - const value = mergeValue( - base.find((entry) => entry[key] === id), - local.find((entry) => entry[key] === id), - incoming.find((entry) => entry[key] === id), - `${path}[${id}]`, - issues, - ); - if (value !== undefined) merged.set(id, value); - } - const ids = mergeOrder(base, local, incoming, key, path, issues, new Set(merged.keys())); - return ids.map((id) => merged.get(id)); + const key = mergeKey(path); + if (key && (Array.isArray(base) || Array.isArray(local) || Array.isArray(incoming))) { + // Layout records, style presets, flow rules, variables, steps, graph nodes and edges carry + // unique record ids, so independent edits to different records merge entry by entry instead + // of conflicting as whole arrays. A missing collection means "no records yet": additions on + // both sides still merge by id, while an edit on one side rejects against a deletion. + const baseEntries = Array.isArray(base) ? base : []; + const localEntries = Array.isArray(local) ? local : []; + const incomingEntries = Array.isArray(incoming) ? incoming : []; + const merged = new Map(); + const allIds = new Set([...baseEntries, ...localEntries, ...incomingEntries].map((entry) => entry[key])); + for (const id of allIds) { + const value = mergeValue( + baseEntries.find((entry) => entry[key] === id), + localEntries.find((entry) => entry[key] === id), + incomingEntries.find((entry) => entry[key] === id), + `${path}[${id}]`, + issues, + ); + if (value !== undefined) merged.set(id, value); } + const ids = mergeOrder(baseEntries, localEntries, incomingEntries, key, path, issues, new Set(merged.keys())); + return ids.map((id) => merged.get(id)); } issues.push({ path }); return structuredClone(local); diff --git a/tests/design/flow-validation-graph.test.ts b/tests/design/flow-validation-graph.test.ts new file mode 100644 index 00000000..fcf42689 --- /dev/null +++ b/tests/design/flow-validation-graph.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from 'vitest'; +import { validateFlowGraph } from '../../design/flow-validation-graph'; +import type { + FlowDocument, + FlowEdge, + FlowEdgeBranch, + FlowGraph, + FlowNodeKind, + FlowNodeView, + FlowVariable, + InteractionCondition, + InteractionRule, +} from '../../design/schema'; + +const BASE = '0'.repeat(40); +const ROUTES = ['library', 'settings']; + +function node(id: string, kind: FlowNodeKind, x = 0, y = 0, stepId?: string): FlowNodeView { + return { id, kind, x, y, ...(stepId ? { stepId } : {}) }; +} + +function edge(id: string, from: string, to: string, branch: FlowEdgeBranch = 'default'): FlowEdge { + return { id, from, to, branch }; +} + +function flowWith(rules: InteractionRule[], variables?: FlowVariable[]): FlowDocument { + return { schemaVersion: 1, id: 'flow-1', baseCommit: BASE, rules, ...(variables ? { variables } : {}) }; +} + +// A connected graph: trigger -> condition -> (true: variable.set -> navigate, false: result toast). +function validGraph(): { rule: InteractionRule; graph: FlowGraph } { + const graph: FlowGraph = { + nodes: [ + node('t-1', 'trigger'), + node('c-1', 'condition', 120, 0, 's-check'), + node('s-1', 'action', 240, 0, 's-set'), + node('a-1', 'action', 360, 0, 's-go'), + node('r-1', 'result', 240, 120, 's-stop'), + ], + edges: [ + edge('e-tc', 't-1', 'c-1'), + edge('e-cts', 'c-1', 's-1', 'true'), + edge('e-ctr', 'c-1', 'r-1', 'false'), + edge('e-sa', 's-1', 'a-1'), + ], + }; + const rule: InteractionRule = { + id: 'rule-1', + screenId: 'settings', + elementId: 'continue-button', + event: 'click', + action: { type: 'toast', message: 'ignored in graph mode' }, + steps: [ + { id: 's-check', condition: { kind: 'eq', path: 'server.connected', value: true } }, + { id: 's-set', action: { type: 'variable.set', variableId: 'v-flag', value: true } }, + { id: 's-go', action: { type: 'navigate', screenId: 'library' } }, + { id: 's-stop', action: { type: 'toast', message: 'Stopped' } }, + ], + graph, + }; + return { rule, graph }; +} + +describe('validateFlowGraph', () => { + it('accepts a connected graph with typed conditions and full route coverage', () => { + const { rule } = validGraph(); + const flow = flowWith([rule], [{ id: 'v-flag', name: 'Flag', type: 'boolean', initial: false }]); + expect(validateFlowGraph(flow, ROUTES)).toEqual([]); + }); + + it('rejects ambiguous branch destination', () => { + const { rule, graph } = validGraph(); + graph.edges.push(edge('e-ta', 't-1', 'a-1')); + const flow = flowWith([rule], [{ id: 'v-flag', name: 'Flag', type: 'boolean', initial: false }]); + expect(validateFlowGraph(flow, ROUTES)).toContainEqual( + expect.objectContaining({ code: 'AMBIGUOUS_BRANCH', ruleId: 'rule-1', nodeId: 't-1' }), + ); + }); + + it('rejects self-loop edges', () => { + const { rule, graph } = validGraph(); + graph.edges.push(edge('e-loop', 'c-1', 'c-1', 'true')); + const flow = flowWith([rule], [{ id: 'v-flag', name: 'Flag', type: 'boolean', initial: false }]); + expect(validateFlowGraph(flow, ROUTES)).toContainEqual( + expect.objectContaining({ code: 'SELF_LOOP', ruleId: 'rule-1', edgeId: 'e-loop' }), + ); + }); + + it('rejects cycle in the graph', () => { + const { rule, graph } = validGraph(); + graph.edges.push(edge('e-back', 'a-1', 'c-1', 'error')); + const flow = flowWith([rule], [{ id: 'v-flag', name: 'Flag', type: 'boolean', initial: false }]); + expect(validateFlowGraph(flow, ROUTES)).toContainEqual( + expect.objectContaining({ code: 'CYCLE', ruleId: 'rule-1' }), + ); + }); + + it('rejects edges referencing a missing node', () => { + const { rule, graph } = validGraph(); + graph.edges.push(edge('e-ghost', 'a-1', 'ghost')); + const flow = flowWith([rule], [{ id: 'v-flag', name: 'Flag', type: 'boolean', initial: false }]); + expect(validateFlowGraph(flow, ROUTES)).toContainEqual( + expect.objectContaining({ code: 'MISSING_NODE', ruleId: 'rule-1', edgeId: 'e-ghost' }), + ); + }); + + it('rejects wrong value types in conditions and variable.set', () => { + const { rule } = validGraph(); + rule.steps = [ + { id: 's-check', condition: { kind: 'gt', path: 'server.connected', value: 'many' } }, + { id: 's-set', action: { type: 'variable.set', variableId: 'v-count', value: 'ten' } }, + { id: 's-go', action: { type: 'navigate', screenId: 'library' } }, + { id: 's-stop', action: { type: 'toast', message: 'Stopped' } }, + ]; + const flow = flowWith([rule], [{ id: 'v-count', name: 'Count', type: 'number', initial: 0 }]); + const issues = validateFlowGraph(flow, ROUTES); + expect(issues).toContainEqual(expect.objectContaining({ code: 'WRONG_VALUE_TYPE', ruleId: 'rule-1', nodeId: 'c-1' })); + expect(issues).toContainEqual(expect.objectContaining({ code: 'WRONG_VALUE_TYPE', ruleId: 'rule-1', nodeId: 's-1' })); + }); + + it('rejects an unreachable result node', () => { + const { rule, graph } = validGraph(); + graph.nodes.push(node('r-2', 'result', 480, 120, 's-stop')); + const flow = flowWith([rule], [{ id: 'v-flag', name: 'Flag', type: 'boolean', initial: false }]); + expect(validateFlowGraph(flow, ROUTES)).toContainEqual( + expect.objectContaining({ code: 'UNREACHABLE_NODE', ruleId: 'rule-1', nodeId: 'r-2' }), + ); + }); + + it('rejects a trigger without a path to a result or terminal action', () => { + const graph: FlowGraph = { + nodes: [node('t-1', 'trigger'), node('a-1', 'action', 120, 0, 's-note')], + edges: [edge('e-ta', 't-1', 'a-1')], + }; + const rule: InteractionRule = { + id: 'rule-1', + screenId: 'settings', + elementId: 'continue-button', + event: 'click', + action: { type: 'toast', message: 'ignored in graph mode' }, + steps: [{ id: 's-note', action: { type: 'toast', message: 'Note' } }], + graph, + }; + expect(validateFlowGraph(flowWith([rule]), ROUTES)).toContainEqual( + expect.objectContaining({ code: 'NO_TERMINAL_PATH', ruleId: 'rule-1', nodeId: 't-1' }), + ); + }); + + it('rejects a new-action rule without steps or a graph as dead', () => { + const rule: InteractionRule = { + id: 'rule-1', + screenId: 'settings', + elementId: 'continue-button', + event: 'click', + action: { type: 'dialog', title: 'Title', message: 'Message' }, + }; + const flow = flowWith([rule]); + expect(validateFlowGraph(flow, ROUTES)).toContainEqual( + expect.objectContaining({ code: 'DEAD_RULE', ruleId: 'rule-1' }), + ); + }); + + it('accepts legacy-style rules without steps or a graph', () => { + const rule: InteractionRule = { + id: 'rule-1', + screenId: 'settings', + elementId: 'continue-button', + event: 'click', + action: { type: 'navigate', screenId: 'library' }, + }; + expect(validateFlowGraph(flowWith([rule]), ROUTES)).toEqual([]); + }); + + it('rejects unknown routes in graph step actions', () => { + const { rule, graph } = validGraph(); + graph.edges = graph.edges.filter((entry) => entry.id !== 'e-sa'); + rule.steps = (rule.steps ?? []).map((step) => + step.id === 's-go' ? { ...step, action: { type: 'navigate', screenId: 'nowhere' } } : step, + ); + const flow = flowWith([rule], [{ id: 'v-flag', name: 'Flag', type: 'boolean', initial: false }]); + expect(validateFlowGraph(flow, ROUTES)).toContainEqual( + expect.objectContaining({ code: 'UNKNOWN_ROUTE', ruleId: 'rule-1', nodeId: 'a-1' }), + ); + }); + + it('rejects empty toast and dialog messages', () => { + const graph: FlowGraph = { + nodes: [node('t-1', 'trigger'), node('a-1', 'action', 120, 0, 's-note'), node('a-2', 'action', 240, 0, 's-ask')], + edges: [edge('e-t1', 't-1', 'a-1'), edge('e-12', 'a-1', 'a-2')], + }; + const rule: InteractionRule = { + id: 'rule-1', + screenId: 'settings', + elementId: 'continue-button', + event: 'click', + action: { type: 'toast', message: 'ignored in graph mode' }, + steps: [ + { id: 's-note', action: { type: 'toast', message: ' ' } }, + { id: 's-ask', action: { type: 'dialog', title: '', message: 'Message' } }, + ], + graph, + }; + const issues = validateFlowGraph(flowWith([rule]), ROUTES); + expect(issues).toContainEqual(expect.objectContaining({ code: 'EMPTY_MESSAGE', nodeId: 'a-1' })); + expect(issues).toContainEqual(expect.objectContaining({ code: 'EMPTY_MESSAGE', nodeId: 'a-2' })); + }); + + it('rejects variable conditions with mismatched value types', () => { + const condition: InteractionCondition = { kind: 'eq', path: 'variable.v-count', value: true }; + const rule: InteractionRule = { + id: 'rule-1', + screenId: 'settings', + elementId: 'continue-button', + event: 'click', + condition, + action: { type: 'navigate', screenId: 'library' }, + }; + const flow = flowWith([rule], [{ id: 'v-count', name: 'Count', type: 'number', initial: 0 }]); + expect(validateFlowGraph(flow, ROUTES)).toContainEqual( + expect.objectContaining({ code: 'WRONG_VALUE_TYPE', ruleId: 'rule-1' }), + ); + }); +}); diff --git a/tests/editor/design.test.ts b/tests/editor/design.test.ts index d0ae8b8b..0a645b37 100644 --- a/tests/editor/design.test.ts +++ b/tests/editor/design.test.ts @@ -32,8 +32,7 @@ async function makeRepo(): Promise { return root; } -async function writeDesign(root: string, draft: ReturnType): Promise { - const baseCommit = await head(root); +async function writeDesign(root: string, draft: ReturnType): Promise { const baseCommit = await head(root); draft.baseCommit = baseCommit; draft.theme.baseCommit = baseCommit; draft.flow.baseCommit = baseCommit; @@ -47,6 +46,24 @@ async function writeDesign(root: string, draft: ReturnType { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); @@ -332,6 +349,115 @@ describe('committed editor designs', () => { expect(result.draft.flow.rules[1].action).toEqual({ type: 'toast', message: 'Local edit' }); }); + it('rebase merges independent flow graph node, edge and variable additions by id', async () => { + const root = await makeRepo(); + const initial = createDefaultDraft('draft-1', await head(root)); + initial.flow.rules.push(graphDemoRule()); + await writeDesign(root, initial); + const base = await commit(root, 'base design'); + + // Local adds its own condition node with steps and edges plus a variable. + const local = createDefaultDraft('draft-1', base); + local.flow.rules.push(graphDemoRule()); + const localRule = local.flow.rules[local.flow.rules.length - 1]; + localRule.steps!.push({ id: 's-local', condition: { kind: 'eq', path: 'server.connected', value: true } }); + localRule.graph!.nodes.push({ id: 'c-local', kind: 'condition', stepId: 's-local', x: 120, y: 0 }); + localRule.graph!.edges.push( + { id: 'e-tlocal', from: 't-main', to: 'c-local', branch: 'default' }, + { id: 'e-localdone', from: 'c-local', to: 'a-done', branch: 'true' }, + ); + local.flow.variables = [{ id: 'v-local', name: 'Local flag', type: 'boolean', initial: false }]; + + // Incoming independently adds another condition node with steps and edges plus a variable. + const incoming = createDefaultDraft('draft-1', base); + incoming.flow.rules.push(graphDemoRule()); + const incomingRule = incoming.flow.rules[incoming.flow.rules.length - 1]; + incomingRule.steps!.push({ id: 's-incoming', condition: { kind: 'eq', path: 'network.online', value: true } }); + incomingRule.graph!.nodes.push({ id: 'c-incoming', kind: 'condition', stepId: 's-incoming', x: 120, y: 120 }); + incomingRule.graph!.edges.push( + { id: 'e-tincoming', from: 't-main', to: 'c-incoming', branch: 'default' }, + { id: 'e-incomingdone', from: 'c-incoming', to: 'a-done', branch: 'true' }, + ); + incoming.flow.variables = [{ id: 'v-incoming', name: 'Incoming flag', type: 'boolean', initial: true }]; + await writeDesign(root, incoming); + await commit(root, 'incoming design'); + + const result = await rebaseDraft({ root, draft: local }); + const rule = result.draft.flow.rules.find((entry) => entry.id === 'graph-demo')!; + expect(rule.graph!.nodes.map((entry) => entry.id).sort()).toEqual(['a-done', 'c-incoming', 'c-local', 't-main']); + expect(rule.graph!.edges.map((entry) => entry.id).sort()).toEqual(['e-incomingdone', 'e-localdone', 'e-start', 'e-tincoming', 'e-tlocal']); + expect(rule.steps!.map((entry) => entry.id).sort()).toEqual(['s-done', 's-incoming', 's-local']); + expect((result.draft.flow.variables ?? []).map((entry) => entry.id).sort()).toEqual(['v-incoming', 'v-local']); + }); + + it('rebase rejects competing edits of the same flow graph node', async () => { + const root = await makeRepo(); + const initial = createDefaultDraft('draft-1', await head(root)); + initial.flow.rules.push(graphDemoRule()); + await writeDesign(root, initial); + const base = await commit(root, 'base design'); + + const local = createDefaultDraft('draft-1', base); + local.flow.rules.push(graphDemoRule()); + local.flow.rules[local.flow.rules.length - 1].graph!.nodes.find((entry) => entry.id === 'a-done')!.x = 480; + + const incoming = createDefaultDraft('draft-1', base); + incoming.flow.rules.push(graphDemoRule()); + incoming.flow.rules[incoming.flow.rules.length - 1].graph!.nodes.find((entry) => entry.id === 'a-done')!.x = 960; + await writeDesign(root, incoming); + await commit(root, 'incoming design'); + + await expect(rebaseDraft({ root, draft: local })).rejects.toMatchObject({ + code: 'REBASE_CONFLICT', + issues: [{ path: 'flow.rules[graph-demo].graph.nodes[a-done].x' }], + }); + }); + + it('rebase rejects competing edits of the same flow graph edge', async () => { + const root = await makeRepo(); + const initial = createDefaultDraft('draft-1', await head(root)); + initial.flow.rules.push(graphDemoRule()); + await writeDesign(root, initial); + const base = await commit(root, 'base design'); + + const local = createDefaultDraft('draft-1', base); + local.flow.rules.push(graphDemoRule()); + local.flow.rules[local.flow.rules.length - 1].graph!.edges.find((entry) => entry.id === 'e-start')!.branch = 'error'; + + const incoming = createDefaultDraft('draft-1', base); + incoming.flow.rules.push(graphDemoRule()); + incoming.flow.rules[incoming.flow.rules.length - 1].graph!.edges.find((entry) => entry.id === 'e-start')!.branch = 'true'; + await writeDesign(root, incoming); + await commit(root, 'incoming design'); + + await expect(rebaseDraft({ root, draft: local })).rejects.toMatchObject({ + code: 'REBASE_CONFLICT', + issues: [{ path: 'flow.rules[graph-demo].graph.edges[e-start].branch' }], + }); + }); + + it('rebase rejects competing edits of the same flow variable', async () => { + const root = await makeRepo(); + const initial = createDefaultDraft('draft-1', await head(root)); + initial.flow.rules.push(graphDemoRule()); + initial.flow.variables = [{ id: 'v-count', name: 'Count', type: 'number', initial: 1 }]; + await writeDesign(root, initial); + const base = await commit(root, 'base design'); + + const local = createDefaultDraft('draft-1', base); + local.flow.variables = [{ id: 'v-count', name: 'Count', type: 'number', initial: 2 }]; + + const incoming = createDefaultDraft('draft-1', base); + incoming.flow.variables = [{ id: 'v-count', name: 'Count', type: 'number', initial: 3 }]; + await writeDesign(root, incoming); + await commit(root, 'incoming design'); + + await expect(rebaseDraft({ root, draft: local })).rejects.toMatchObject({ + code: 'REBASE_CONFLICT', + issues: [{ path: 'flow.variables[v-count].initial' }], + }); + }); + it('stops instead of inventing a base when its commit is unavailable', async () => { const root = await makeRepo(); const draft = createDefaultDraft('draft-1', '0000000000000000000000000000000000000000'); From e0293a113d02f25ada09048c09add286b4c2a608 Mon Sep 17 00:00:00 2001 From: ANDY BUM <47109571+spacesarmat@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:53:33 +0300 Subject: [PATCH 06/26] fix: close flow validation gaps from independent review --- design/flow-validation-graph.ts | 42 ++++++++--- design/flow-validation.ts | 5 +- editor/design.d.mts | 8 ++ editor/design.mjs | 15 +++- tests/design/flow-validation-graph.test.ts | 88 ++++++++++++++++++++++ tests/editor/design.test.ts | 25 +++++- 6 files changed, 165 insertions(+), 18 deletions(-) diff --git a/design/flow-validation-graph.ts b/design/flow-validation-graph.ts index 0c06fdcc..a8af008f 100644 --- a/design/flow-validation-graph.ts +++ b/design/flow-validation-graph.ts @@ -41,22 +41,29 @@ function checkCondition(condition: InteractionCondition, scope: RuleScope, nodeI checkCondition(condition.condition, scope, nodeId); return; } + const issue = (code: FlowIssue['code'], message: string) => + scope.issues.push({ code, ruleId: scope.rule.id, ...(nodeId ? { nodeId } : {}), message }); + // Only documented read-only context paths resolve without a declaration; variable. + // paths must reference a declared variable in every leaf, including inside nested groups. + let variable: FlowVariable | undefined; + if (condition.path.startsWith('variable.')) { + variable = scope.variables.get(condition.path.slice('variable.'.length)); + if (!variable) { + issue('UNKNOWN_VARIABLE', `condition references undeclared variable ${condition.path.slice('variable.'.length)}`); + return; + } + } if (condition.kind === 'exists') return; - const issue = (message: string) => - scope.issues.push({ code: 'WRONG_VALUE_TYPE', ruleId: scope.rule.id, ...(nodeId ? { nodeId } : {}), message }); if (condition.kind === 'gt' || condition.kind === 'gte' || condition.kind === 'lt' || condition.kind === 'lte') { - if (typeof condition.value !== 'number') issue(`condition ${condition.kind} expects a number value`); + if (typeof condition.value !== 'number') issue('WRONG_VALUE_TYPE', `condition ${condition.kind} expects a number value`); return; } if (condition.kind === 'contains') { - if (typeof condition.value !== 'string') issue('condition contains expects a string value'); + if (typeof condition.value !== 'string') issue('WRONG_VALUE_TYPE', 'condition contains expects a string value'); return; } - if (condition.path.startsWith('variable.')) { - const variable = scope.variables.get(condition.path.slice('variable.'.length)); - if (variable && !variableAccepts(variable, condition.value)) { - issue(`condition on variable ${variable.id} expects a ${variable.type} value`); - } + if (variable && (condition.kind === 'eq' || condition.kind === 'neq') && !variableAccepts(variable, condition.value)) { + issue('WRONG_VALUE_TYPE', `condition on variable ${variable.id} expects a ${variable.type} value`); } } @@ -72,7 +79,9 @@ function checkAction(action: InteractionAction, scope: RuleScope, nodeId?: strin } if (action.type === 'variable.set') { const variable = scope.variables.get(action.variableId); - if (variable && !variableAccepts(variable, action.value)) { + if (!variable) { + issue('UNKNOWN_VARIABLE', `variable.set references undeclared variable ${action.variableId}`); + } else if (!variableAccepts(variable, action.value)) { issue('WRONG_VALUE_TYPE', `variable ${action.variableId} expects a ${variable.type} value`); } } @@ -145,7 +154,18 @@ function validateGraph(scope: RuleScope, graph: FlowGraph): void { } const triggers = graph.nodes.filter((entry) => entry.kind === 'trigger'); - if (!triggers.length) return; + if (!triggers.length) { + issues.push({ code: 'NO_TRIGGER', ruleId: rule.id, message: `rule ${rule.id} graph has no trigger node` }); + return; + } + if (triggers.length > 1) { + issues.push({ + code: 'MULTIPLE_TRIGGERS', + ruleId: rule.id, + nodeId: triggers[1].id, + message: `rule ${rule.id} graph has ${triggers.length} trigger nodes`, + }); + } const reachable = new Set(); const queue = triggers.map((entry) => entry.id); for (const id of queue) reachable.add(id); diff --git a/design/flow-validation.ts b/design/flow-validation.ts index 32a5c78d..5116cfe3 100644 --- a/design/flow-validation.ts +++ b/design/flow-validation.ts @@ -14,7 +14,10 @@ export type FlowIssueCode = | 'UNREACHABLE_NODE' | 'NO_TERMINAL_PATH' | 'DEAD_RULE' - | 'EMPTY_MESSAGE'; + | 'EMPTY_MESSAGE' + | 'NO_TRIGGER' + | 'MULTIPLE_TRIGGERS' + | 'UNKNOWN_VARIABLE'; export interface FlowIssue { code: FlowIssueCode; diff --git a/editor/design.d.mts b/editor/design.d.mts index 3b04cf7c..d308f7a8 100644 --- a/editor/design.d.mts +++ b/editor/design.d.mts @@ -20,3 +20,11 @@ export class DesignError extends Error { export function getCommittedDesign(root: string): Promise; export function rebaseDraft(options: { root: string; draft: EditorDraft }): Promise; +// Exported for merge regression tests; production callers go through rebaseDraft. +export function mergeValue( + base: unknown, + local: unknown, + incoming: unknown, + path: string, + issues: Array<{ path: string }>, +): unknown; diff --git a/editor/design.mjs b/editor/design.mjs index 2759942d..9c9ee294 100644 --- a/editor/design.mjs +++ b/editor/design.mjs @@ -112,7 +112,8 @@ function mergeKey(path) { return null; } -function mergeValue(base, local, incoming, path, issues) { if (equal(local, base)) return structuredClone(incoming); +// Exported for merge regression tests; rebaseDraft is the production entry point. +export function mergeValue(base, local, incoming, path, issues) { if (equal(local, base)) return structuredClone(incoming); if (equal(incoming, base) || equal(local, incoming)) return structuredClone(local); if (object(base) && object(local) && object(incoming)) { const result = {}; @@ -127,8 +128,16 @@ function mergeValue(base, local, incoming, path, issues) { if (equal(local, bas if (key && (Array.isArray(base) || Array.isArray(local) || Array.isArray(incoming))) { // Layout records, style presets, flow rules, variables, steps, graph nodes and edges carry // unique record ids, so independent edits to different records merge entry by entry instead - // of conflicting as whole arrays. A missing collection means "no records yet": additions on - // both sides still merge by id, while an edit on one side rejects against a deletion. + // of conflicting as whole arrays. An absent (undefined) collection means "no records yet" or + // a deletion: additions on both sides still merge by id, and an edit on one side rejects + // against a deletion. A defined non-array value cannot come from a parsed document, so it + // rejects instead of being normalized to an empty collection. + for (const side of [base, local, incoming]) { + if (side !== undefined && !Array.isArray(side)) { + issues.push({ path }); + return structuredClone(local); + } + } const baseEntries = Array.isArray(base) ? base : []; const localEntries = Array.isArray(local) ? local : []; const incomingEntries = Array.isArray(incoming) ? incoming : []; diff --git a/tests/design/flow-validation-graph.test.ts b/tests/design/flow-validation-graph.test.ts index fcf42689..5c8e98ec 100644 --- a/tests/design/flow-validation-graph.test.ts +++ b/tests/design/flow-validation-graph.test.ts @@ -220,4 +220,92 @@ describe('validateFlowGraph', () => { expect.objectContaining({ code: 'WRONG_VALUE_TYPE', ruleId: 'rule-1' }), ); }); + + it('rejects a graph without a trigger node', () => { + const graph: FlowGraph = { + nodes: [node('a-1', 'action', 120, 0, 's-go')], + edges: [], + }; + const rule: InteractionRule = { + id: 'rule-1', + screenId: 'settings', + elementId: 'continue-button', + event: 'click', + action: { type: 'toast', message: 'ignored in graph mode' }, + steps: [{ id: 's-go', action: { type: 'navigate', screenId: 'library' } }], + graph, + }; + expect(validateFlowGraph(flowWith([rule]), ROUTES)).toContainEqual( + expect.objectContaining({ code: 'NO_TRIGGER', ruleId: 'rule-1' }), + ); + }); + + it('rejects multiple trigger nodes in one graph', () => { + const graph: FlowGraph = { + nodes: [node('t-1', 'trigger'), node('t-2', 'trigger', 0, 120), node('a-1', 'action', 240, 0, 's-go')], + edges: [edge('e-ta', 't-1', 'a-1')], + }; + const rule: InteractionRule = { + id: 'rule-1', + screenId: 'settings', + elementId: 'continue-button', + event: 'click', + action: { type: 'toast', message: 'ignored in graph mode' }, + steps: [{ id: 's-go', action: { type: 'navigate', screenId: 'library' } }], + graph, + }; + expect(validateFlowGraph(flowWith([rule]), ROUTES)).toContainEqual( + expect.objectContaining({ code: 'MULTIPLE_TRIGGERS', ruleId: 'rule-1', nodeId: 't-2' }), + ); + }); + + it('rejects conditions and variable.set referencing undeclared variables', () => { + const condition: InteractionCondition = { + kind: 'all', + conditions: [ + { + kind: 'any', + conditions: [ + { kind: 'not', condition: { kind: 'eq', path: 'variable.v-missing', value: true } }, + { kind: 'exists', path: 'variable.v-other' }, + ], + }, + { kind: 'eq', path: 'server.connected', value: true }, + ], + }; + const rule: InteractionRule = { + id: 'rule-1', + screenId: 'settings', + elementId: 'continue-button', + event: 'click', + condition, + action: { type: 'variable.set', variableId: 'v-absent', value: 1 }, + }; + const issues = validateFlowGraph(flowWith([rule]), ROUTES); + const unknown = issues.filter((entry) => entry.code === 'UNKNOWN_VARIABLE'); + expect(unknown).toHaveLength(3); + expect(unknown.every((entry) => entry.ruleId === 'rule-1')).toBe(true); + }); + + it('accepts documented context paths and declared variables in conditions', () => { + const condition: InteractionCondition = { + kind: 'all', + conditions: [ + { kind: 'eq', path: 'server.connected', value: true }, + { kind: 'exists', path: 'network.online' }, + { kind: 'neq', path: 'screen.current', value: 'library' }, + { kind: 'exists', path: 'variable.v-flag' }, + ], + }; + const rule: InteractionRule = { + id: 'rule-1', + screenId: 'settings', + elementId: 'continue-button', + event: 'click', + condition, + action: { type: 'navigate', screenId: 'library' }, + }; + const flow = flowWith([rule], [{ id: 'v-flag', name: 'Flag', type: 'boolean', initial: false }]); + expect(validateFlowGraph(flow, ROUTES)).toEqual([]); + }); }); diff --git a/tests/editor/design.test.ts b/tests/editor/design.test.ts index 0a645b37..fc88b29f 100644 --- a/tests/editor/design.test.ts +++ b/tests/editor/design.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; import { afterEach, describe, expect, it } from 'vitest'; -import { getCommittedDesign, rebaseDraft } from '../../editor/design.mjs'; +import { getCommittedDesign, mergeValue, rebaseDraft } from '../../editor/design.mjs'; import { createDefaultDraft } from '../../editor/shared/draft'; import type { LayoutClone, LayoutContainer, LayoutPlacement } from '../../design/schema'; @@ -458,8 +458,27 @@ describe('committed editor designs', () => { }); }); - it('stops instead of inventing a base when its commit is unavailable', async () => { - const root = await makeRepo(); + it('rejects a non-array value on a keyed merge collection instead of normalizing it', () => { + const issues: Array<{ path: string }> = []; + const localVariable = { id: 'v-local', name: 'Local', type: 'boolean' as const, initial: false }; + // A defined non-array value cannot come from a parsed document; merging it as an empty + // collection would silently drop one side of the rebase. + const merged = mergeValue([], [localVariable], { unexpected: true }, 'flow.variables', issues); + expect(issues).toEqual([{ path: 'flow.variables' }]); + expect(merged).toEqual([localVariable]); + }); + + it('keeps whole-collection deletion semantics on keyed merge paths', () => { + const variable = { id: 'v-local', name: 'Local', type: 'boolean' as const, initial: false }; + // Deletion on one side and no change on the other: deletion wins without a conflict. + expect(mergeValue([variable], undefined, [variable], 'flow.variables', [])).toBeUndefined(); + // Deletion on one side and an edit on the other: competing change, rejected. + const issues: Array<{ path: string }> = []; + mergeValue([variable], undefined, [{ ...variable, initial: true }], 'flow.variables', issues); + expect(issues).toEqual([{ path: 'flow.variables[v-local]' }]); + }); + + it('stops instead of inventing a base when its commit is unavailable', async () => { const root = await makeRepo(); const draft = createDefaultDraft('draft-1', '0000000000000000000000000000000000000000'); const before = JSON.stringify(draft); await expect(rebaseDraft({ root, draft })).rejects.toMatchObject({ code: 'BASE_HISTORY_UNAVAILABLE' }); From ae84af15c6309d3d90ef79f3fd54bf39eff73a2f Mon Sep 17 00:00:00 2001 From: ANDY BUM <47109571+spacesarmat@users.noreply.github.com> Date: Thu, 12 Nov 2026 20:19:08 +0300 Subject: [PATCH 07/26] feat: edit logic flows on node canvas --- editor/src/App.tsx | 114 +++++-- editor/src/components/FlowCanvas.tsx | 325 ++++++++++++++++++++ editor/src/components/FlowNodeInspector.tsx | 249 +++++++++++++++ editor/src/editor.css | 188 +++++++++++ editor/src/flow/nodeOperations.ts | 229 ++++++++++++++ tests/editor/flow-canvas.test.tsx | 287 +++++++++++++++++ 6 files changed, 1361 insertions(+), 31 deletions(-) create mode 100644 editor/src/components/FlowCanvas.tsx create mode 100644 editor/src/components/FlowNodeInspector.tsx create mode 100644 editor/src/flow/nodeOperations.ts create mode 100644 tests/editor/flow-canvas.test.tsx diff --git a/editor/src/App.tsx b/editor/src/App.tsx index bca40fd7..cb4f0595 100644 --- a/editor/src/App.tsx +++ b/editor/src/App.tsx @@ -25,6 +25,8 @@ import { LayoutInspector } from './components/LayoutInspector'; import { MultiSelectInspector } from './components/MultiSelectInspector'; import { LogicEditor } from './components/LogicEditor'; import { FlowMap } from './components/FlowMap'; +import { FlowCanvas, type FlowNodeSelection } from './components/FlowCanvas'; +import { FlowNodeInspector } from './components/FlowNodeInspector'; import { SimulationPanel } from './components/SimulationPanel'; import { ExportPanel } from './components/ExportPanel'; import { EditorIcon } from './components/EditorIcon'; @@ -85,6 +87,7 @@ function EditorWorkspace({ controller, client }: { controller: DraftController; const [geometrySnapshot, setGeometrySnapshot] = useState(null); const [geometryConfirmed, setGeometryConfirmed] = useState(false); const [mode, setMode] = useState<'design' | 'logic' | 'simulate'>('design'); + const [flowSelection, setFlowSelection] = useState(null); const [serverConnected, setServerConnected] = useState(false); const [previewMode, setPreviewMode] = useState<'before' | 'after'>('after'); const [trace, setTrace] = useState([]); @@ -151,6 +154,34 @@ function EditorWorkspace({ controller, client }: { controller: DraftController; ?? layoutNodes.find((entry) => entry.elementId === elementId); handleSelectNode(node ? { ids: [node.nodeId], anchorId: node.nodeId } : { ids: [], anchorId: null }); }; + // Canvas selection reports the node so the inspector can show its typed fields; the linked + // element or destination screen stays highlighted for the Design mode switch. + const handleFlowNodeSelected = (selection: FlowNodeSelection) => { + setFlowSelection(selection); + const rule = controller.draft.flow.rules.find((entry) => entry.id === selection.ruleId); + const node = rule?.graph?.nodes.find((entry) => entry.id === selection.nodeId); + if (!rule || !node) return; + let target = rule.screenId; + if (node.kind === 'trigger' && rule.elementId !== '*') target = rule.elementId; + if (node.kind === 'action' || node.kind === 'result') { + const step = rule.steps?.find((entry) => entry.id === node.stepId); + if (step?.action && (step.action.type === 'navigate' || step.action.type === 'openScreen')) target = step.action.screenId; + } + if (isDesignElementAvailable(target, draft.screenId, draft.target)) { + setSelected(target); + } else if (isEditorRoute(draft.target, target)) { + setView({ screenId: target as DesktopRouteId }); + } + }; + const handleFlowReveal = (selection: FlowNodeSelection) => { + handleFlowNodeSelected(selection); + setMode('design'); + }; + useEffect(() => { + if (!flowSelection) return; + const rule = draft.flow.rules.find((entry) => entry.id === flowSelection.ruleId); + if (!rule?.graph?.nodes.some((entry) => entry.id === flowSelection.nodeId)) setFlowSelection(null); + }, [draft.flow, flowSelection]); const handleMoveNode = (args: { nodeId: string; parentId: string; index: number; placement?: LayoutPlacement; siblings?: string[] }) => { const placement = getLayoutPlacement(draft.theme, layoutContext, args.nodeId); if (placement?.locked || placement?.hidden) return; @@ -654,36 +685,46 @@ function EditorWorkspace({ controller, client }: { controller: DraftController; />
{routeLabel}{viewport.width} × {viewport.height}{!previewEditable && Базовая тема - редактирование выключено}
- setView({ screenId })} - onTrace={setTrace} - onNodesChange={handleNodesChange} - onGeometryChange={setGeometrySnapshot} - onSelectNode={handleSelectNode} - onMoveNode={handleMoveNode} - onPlaceNode={handlePlaceNode} - onPlaceNodes={handlePlaceNodes} - /> + {mode === 'logic' ? ( + setMode('simulate')} + /> + ) : ( + setView({ screenId })} + onTrace={setTrace} + onNodesChange={handleNodesChange} + onGeometryChange={setGeometrySnapshot} + onSelectNode={handleSelectNode} + onMoveNode={handleMoveNode} + onPlaceNode={handlePlaceNode} + onPlaceNodes={handlePlaceNodes} + /> + )}