From 4c5e7d76902e3b1dc5e70dda6cfcf6f3002578bc Mon Sep 17 00:00:00 2001 From: Babayaga Date: Tue, 7 Apr 2026 19:41:32 +0200 Subject: [PATCH] xblox 1/2 --- packages/xblox/.gitignore | 1 + packages/xblox/README.md | 19 +++- packages/xblox/package.json | 1 + packages/xblox/src/cli/run-command.ts | 10 +- packages/xblox/src/engine/execute-block.ts | 81 ++++++++++------ packages/xblox/src/index.ts | 8 ++ packages/xblox/src/schema/blocks-file.ts | 11 ++- packages/xblox/tests/fixtures/for-sum.json | 6 +- .../xblox/tests/fixtures/if-else-zero.json | 6 +- packages/xblox/tests/fixtures/if-else.json | 6 +- .../xblox/tests/fixtures/multi-root-wait.json | 18 ++++ .../xblox/tests/fixtures/switch-case.json | 6 +- .../tests/fixtures/switch-default-only.json | 6 +- .../xblox/tests/fixtures/while-count.json | 6 +- packages/xblox/tests/test-commons.ts | 94 ++++++++++++++++++- packages/xblox/tests/unit/cli-run.test.ts | 2 +- .../xblox/tests/unit/if-else-engine.test.ts | 4 +- packages/xblox/tests/unit/loops-logic.test.ts | 52 +++++----- .../xblox/tests/unit/multi-root-wait.test.ts | 30 ++++++ packages/xblox/tests/unit/perf-report.test.ts | 47 ++++++++++ 20 files changed, 336 insertions(+), 78 deletions(-) create mode 100644 packages/xblox/tests/fixtures/multi-root-wait.json create mode 100644 packages/xblox/tests/unit/multi-root-wait.test.ts create mode 100644 packages/xblox/tests/unit/perf-report.test.ts diff --git a/packages/xblox/.gitignore b/packages/xblox/.gitignore index d1878b1e..458033bc 100644 --- a/packages/xblox/.gitignore +++ b/packages/xblox/.gitignore @@ -2,4 +2,5 @@ node_modules/ dist/ dist-in/ coverage/ +reports/ *.log diff --git a/packages/xblox/README.md b/packages/xblox/README.md index 5da77eb5..b1941f0b 100644 --- a/packages/xblox/README.md +++ b/packages/xblox/README.md @@ -34,7 +34,7 @@ npx pm-xblox run --source=./graph.json --loglevel=info | Option | Default | Description | |--------|---------|-------------| | `--source` | (required) | Path to a blocks JSON file (`version: 1`). | -| `--output` | — | If set, writes `{ "result": … }` as JSON. | +| `--output` | — | If set, writes `{ "result": …, "results": […] }` as JSON. | | `--loglevel` | `info` | `debug` · `info` · `warn` · `error` | ## Blocks file format @@ -45,14 +45,20 @@ Top-level shape (see `src/schema/blocks-file.ts`): { "version": 1, "context": {}, - "root": { "kind": "…", … } + "roots": [{ "kind": "…", … }, …] } ``` - **`context`** — plain object merged into the execution `this` (expressions and scripts use **`this`** like the legacy `apply(ctx, args)` model). -- **`root`** — a discriminated union on **`kind`**: `if`, `runScript`, `for`, `while`, `switch`, `break`, plus `case` / `switchDefault` inside `switch.items`. +- **`roots`** — non-empty array of top-level blocks run **sequentially**, in order (shared `ctx`), like statements in a script. A UI can map one row per root to a collapsible tree of children. -**Legacy note:** older UIs often stored a **flat array** of blocks with `id`, `parentId`, `declaredClass`. This package currently executes the **nested** tree; you can add an adapter layer (flat → tree) for editors without changing the executor. +Block **`kind`** values include: `if`, `runScript`, `for`, `while`, `switch`, `break`, `wait`, plus `case` / `switchDefault` inside `switch.items`. + +- **`wait`** — `{ "kind": "wait", "ms": 5000 }` pauses for the given milliseconds (**async**; the executor is `async`). + +**CLI / API output:** `executeRoots` and `pm-xblox run` return **`results`** (one entry per root) and **`result`** (last root’s return value). + +**Legacy note:** older UIs often stored a **flat array** of blocks with `id`, `parentId`, `declaredClass`. This package executes **nested trees** under each root; you can add an adapter (flat → `roots`) for editors without changing the executor. ## Expressions and scripts @@ -72,10 +78,13 @@ Top-level shape (see `src/schema/blocks-file.ts`): | `npm run test:watch` | Vitest watch mode | | `npm run test:coverage` | Vitest with V8 coverage | | `npm run test:ui` | Vitest browser UI | +| `npm run test:perf` | Runs `tests/unit/perf-report.test.ts` — prints a **`console.table`** of timings. With **`XBLOX_PERF_REPORT=1`**, also writes **`reports/perf-latest.json`** (gitignored). | | `npm run lint` | ESLint on `src/` | | `npm run webpack` | After `build`, emits `dist/main_node.js` from `dist-in/main.js` (default package exports only; **not** `@polymech/xblox/runtime` / vm2). | -Tests live under `tests/`; shared paths use `tests/test-commons.ts`. Fixtures are in `tests/fixtures/`. +Tests live under `tests/`; shared paths and **performance helpers** (`PerfReporter`, `measureMs`, `fixture`) live in `tests/test-commons.ts`. Fixtures are in `tests/fixtures/`. + +**Performance:** import `PerfReporter` in any test, wrap steps with `await perf.measure('label', () => …)`, then call `perf.printSummary()` in `afterAll`. Set **`XBLOX_PERF_REPORT=1`** when running tests to also persist **`reports/perf-latest.json`**. ## Publishing to npm diff --git a/packages/xblox/package.json b/packages/xblox/package.json index ed3fd9c5..e9de2e78 100644 --- a/packages/xblox/package.json +++ b/packages/xblox/package.json @@ -40,6 +40,7 @@ "test:watch": "vitest", "test:coverage": "vitest run --coverage", "test:ui": "vitest --ui", + "test:perf": "vitest run tests/unit/perf-report.test.ts", "prepack": "npm run build", "webpack": "webpack --config webpack.config.js --stats-error-details" }, diff --git a/packages/xblox/src/cli/run-command.ts b/packages/xblox/src/cli/run-command.ts index 8bd64b7d..c6071df3 100644 --- a/packages/xblox/src/cli/run-command.ts +++ b/packages/xblox/src/cli/run-command.ts @@ -1,12 +1,14 @@ import { readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { executeBlock } from '../engine/execute-block.js'; +import { executeRoots } from '../engine/execute-block.js'; import { blocksFileSchema } from '../schema/blocks-file.js'; import { createLogger } from './logger.js'; import { runArgsSchema } from './run-args.js'; -export async function runCommand(raw: Record): Promise<{ result: unknown }> { +export async function runCommand( + raw: Record, +): Promise<{ result: unknown; results: unknown[] }> { const args = runArgsSchema.parse({ source: raw.source, output: raw.output, @@ -23,11 +25,11 @@ export async function runCommand(raw: Record): Promise<{ result const file = blocksFileSchema.parse(json); const ctx = Object.assign(Object.create(null), file.context) as object; - const result = executeBlock(file.root, ctx); + const { result, results } = await executeRoots(file.roots, ctx); log.info('Execution finished.'); - const payload = { result }; + const payload = { result, results }; if (args.output) { const outPath = path.resolve(args.output); diff --git a/packages/xblox/src/engine/execute-block.ts b/packages/xblox/src/engine/execute-block.ts index 5edd2160..f6f21766 100644 --- a/packages/xblox/src/engine/execute-block.ts +++ b/packages/xblox/src/engine/execute-block.ts @@ -10,6 +10,10 @@ export type ExecState = { switchCtl?: { stopped: boolean }; }; +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + function isTruthy(value: unknown): boolean { return Boolean(value); } @@ -24,7 +28,7 @@ function forStep(counter: number | string, modifier: string, ctx: object): unkno return evalExpression(expr, ctx); } -function executeFor(node: Extract, ctx: object, state?: ExecState): unknown { +async function executeFor(node: Extract, ctx: object, state?: ExecState): Promise { let counter = coerceNumber(evalExpression(node.initial, ctx)); if (Number.isNaN(counter)) { counter = 0; @@ -32,7 +36,7 @@ function executeFor(node: Extract, ctx: object, stat let last: unknown; while (forCondition(counter, node.comparator, node.final, ctx)) { const childState: ExecState = { ...state, loopIndex: counter }; - last = runBlockList(node.items, ctx, childState); + last = await runBlockList(node.items, ctx, childState); const nextRaw = forStep(counter, node.modifier, ctx); const next = coerceNumber(nextRaw); if (Number.isNaN(next)) { @@ -46,12 +50,12 @@ function executeFor(node: Extract, ctx: object, stat return last; } -function executeWhile(node: Extract, ctx: object, state?: ExecState): unknown { +async function executeWhile(node: Extract, ctx: object, state?: ExecState): Promise { const limit = node.loopLimit ?? 1500; let iterations = 0; let last: unknown; while (isTruthy(evalExpression(node.condition, ctx)) && iterations < limit) { - last = runBlockList(node.items, ctx, state); + last = await runBlockList(node.items, ctx, state); iterations++; } return last; @@ -65,7 +69,7 @@ function matchCase(ctx: object, variable: string, item: Pick, ctx: object, state?: ExecState): unknown { +async function executeSwitch(node: Extract, ctx: object, state?: ExecState): Promise { const switchCtl = state?.switchCtl ?? { stopped: false }; const childState: ExecState = { ...state, switchCtl }; let anyMatched = false; @@ -74,7 +78,7 @@ function executeSwitch(node: Extract, ctx: object for (const item of node.items) { if (item.kind === 'case') { if (matchCase(ctx, node.variable, item)) { - last = runBlockList(item.consequent, ctx, childState); + last = await runBlockList(item.consequent, ctx, childState); anyMatched = true; break; } @@ -87,7 +91,7 @@ function executeSwitch(node: Extract, ctx: object if (!anyMatched) { for (const item of node.items) { if (item.kind === 'switchDefault') { - last = runBlockList(item.consequent, ctx, childState); + last = await runBlockList(item.consequent, ctx, childState); } } } @@ -95,56 +99,61 @@ function executeSwitch(node: Extract, ctx: object return last; } -export function runBlockList(blocks: BlockNode[], ctx: object, state?: ExecState): unknown { +export async function runBlockList(blocks: BlockNode[], ctx: object, state?: ExecState): Promise { let last: unknown; for (const b of blocks) { - last = executeBlock(b, ctx, state); + last = await executeBlock(b, ctx, state); } return last; } /** - * Execute a tree starting at `root` with `ctx` as `this` in expressions and runScript blocks. + * Execute one block node with `ctx` as `this` in expressions and runScript blocks. */ -export function executeBlock(root: BlockNode, ctx: object, state?: ExecState): unknown { - if (root.kind === 'runScript') { - return runRunScriptBlock(new RunScriptBlock({ method: root.method }), ctx, []); +export async function executeBlock(node: BlockNode, ctx: object, state?: ExecState): Promise { + if (node.kind === 'runScript') { + return runRunScriptBlock(new RunScriptBlock({ method: node.method }), ctx, []); } - if (root.kind === 'break') { + if (node.kind === 'wait') { + await delay(node.ms); + return undefined; + } + + if (node.kind === 'break') { if (state?.switchCtl) { state.switchCtl.stopped = true; } return undefined; } - if (root.kind === 'for') { - return executeFor(root, ctx, state); + if (node.kind === 'for') { + return executeFor(node, ctx, state); } - if (root.kind === 'while') { - return executeWhile(root, ctx, state); + if (node.kind === 'while') { + return executeWhile(node, ctx, state); } - if (root.kind === 'switch') { - return executeSwitch(root, ctx, state); + if (node.kind === 'switch') { + return executeSwitch(node, ctx, state); } - if (root.kind === 'if') { - if (isTruthy(evalExpression(root.condition, ctx))) { - return runBlockList(root.consequent, ctx, state); + if (node.kind === 'if') { + if (isTruthy(evalExpression(node.condition, ctx))) { + return runBlockList(node.consequent, ctx, state); } - if (root.elseIfBlocks) { - for (const branch of root.elseIfBlocks) { + if (node.elseIfBlocks) { + for (const branch of node.elseIfBlocks) { if (isTruthy(evalExpression(branch.condition, ctx))) { return runBlockList(branch.consequent, ctx, state); } } } - if (root.alternate?.length) { - return runBlockList(root.alternate, ctx, state); + if (node.alternate?.length) { + return runBlockList(node.alternate, ctx, state); } return undefined; @@ -152,3 +161,21 @@ export function executeBlock(root: BlockNode, ctx: object, state?: ExecState): u return undefined; } + +export type ExecuteRootsResult = { + /** Return value of each top-level block, in order. */ + results: unknown[]; + /** Last top-level return (same as `results[results.length - 1]`). */ + result: unknown; +}; + +/** Run many top-level blocks in order (shared `ctx`), e.g. UI “run all” / script-like flow. */ +export async function executeRoots(roots: BlockNode[], ctx: object): Promise { + const results: unknown[] = []; + let result: unknown; + for (const block of roots) { + result = await executeBlock(block, ctx); + results.push(result); + } + return { results, result }; +} diff --git a/packages/xblox/src/index.ts b/packages/xblox/src/index.ts index 6ff01286..bd1766b4 100644 --- a/packages/xblox/src/index.ts +++ b/packages/xblox/src/index.ts @@ -3,3 +3,11 @@ export * from './types.js'; export * from './uuid.js'; export * from './model/model-base.js'; export * from './blocks/run-script-block.js'; +export { + executeBlock, + executeRoots, + runBlockList, + type ExecState, + type ExecuteRootsResult, +} from './engine/execute-block.js'; +export { blocksFileSchema, type BlocksFile, type BlockNode } from './schema/blocks-file.js'; diff --git a/packages/xblox/src/schema/blocks-file.ts b/packages/xblox/src/schema/blocks-file.ts index 0bb9edaf..a54fafa0 100644 --- a/packages/xblox/src/schema/blocks-file.ts +++ b/packages/xblox/src/schema/blocks-file.ts @@ -60,6 +60,11 @@ export type BlockNode = /** Legacy `xblox.model.logic.BreakBlock`. */ | { kind: 'break'; + } + /** Async delay between steps (milliseconds). */ + | { + kind: 'wait'; + ms: number; }; export const blockNodeSchema: z.ZodType = z.lazy(() => @@ -119,13 +124,17 @@ export const blockNodeSchema: z.ZodType = z.lazy(() => z.object({ kind: z.literal('break'), }), + z.object({ + kind: z.literal('wait'), + ms: z.number().nonnegative().finite(), + }), ]), ); export const blocksFileSchema = z.object({ version: z.literal(1), context: z.record(z.string(), z.unknown()).optional().default({}), - root: blockNodeSchema, + roots: z.array(blockNodeSchema).min(1), }); export type BlocksFile = z.infer; diff --git a/packages/xblox/tests/fixtures/for-sum.json b/packages/xblox/tests/fixtures/for-sum.json index 7026c218..b3de03af 100644 --- a/packages/xblox/tests/fixtures/for-sum.json +++ b/packages/xblox/tests/fixtures/for-sum.json @@ -1,7 +1,8 @@ { "version": 1, "context": { "x": 0 }, - "root": { + "roots": [ + { "kind": "for", "initial": "0", "final": "3", @@ -13,5 +14,6 @@ "method": "this.x = (this.x || 0) + 1; return this.x;" } ] - } + } + ] } diff --git a/packages/xblox/tests/fixtures/if-else-zero.json b/packages/xblox/tests/fixtures/if-else-zero.json index 9be55689..59f00039 100644 --- a/packages/xblox/tests/fixtures/if-else-zero.json +++ b/packages/xblox/tests/fixtures/if-else-zero.json @@ -1,7 +1,8 @@ { "version": 1, "context": { "n": 0 }, - "root": { + "roots": [ + { "kind": "if", "condition": "this.n > 0", "consequent": [{ "kind": "runScript", "method": "return 'pos';" }], @@ -12,5 +13,6 @@ } ], "alternate": [{ "kind": "runScript", "method": "return 'zero';" }] - } + } + ] } diff --git a/packages/xblox/tests/fixtures/if-else.json b/packages/xblox/tests/fixtures/if-else.json index 3c746b31..2b5605d9 100644 --- a/packages/xblox/tests/fixtures/if-else.json +++ b/packages/xblox/tests/fixtures/if-else.json @@ -1,7 +1,8 @@ { "version": 1, "context": { "n": -3 }, - "root": { + "roots": [ + { "kind": "if", "condition": "this.n > 0", "consequent": [{ "kind": "runScript", "method": "return 'pos';" }], @@ -12,5 +13,6 @@ } ], "alternate": [{ "kind": "runScript", "method": "return 'zero';" }] - } + } + ] } diff --git a/packages/xblox/tests/fixtures/multi-root-wait.json b/packages/xblox/tests/fixtures/multi-root-wait.json new file mode 100644 index 00000000..e0ea5cd8 --- /dev/null +++ b/packages/xblox/tests/fixtures/multi-root-wait.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "context": { "steps": [] }, + "roots": [ + { + "kind": "runScript", + "method": "this.steps = this.steps || []; this.steps.push('a'); return 'a';" + }, + { + "kind": "wait", + "ms": 5000 + }, + { + "kind": "runScript", + "method": "this.steps.push('b'); return 'b';" + } + ] +} diff --git a/packages/xblox/tests/fixtures/switch-case.json b/packages/xblox/tests/fixtures/switch-case.json index 7cd9a3d5..d600c904 100644 --- a/packages/xblox/tests/fixtures/switch-case.json +++ b/packages/xblox/tests/fixtures/switch-case.json @@ -1,7 +1,8 @@ { "version": 1, "context": { "mode": 2 }, - "root": { + "roots": [ + { "kind": "switch", "variable": "mode", "items": [ @@ -22,5 +23,6 @@ "consequent": [{ "kind": "runScript", "method": "return 'def';" }] } ] - } + } + ] } diff --git a/packages/xblox/tests/fixtures/switch-default-only.json b/packages/xblox/tests/fixtures/switch-default-only.json index e69224a6..298925d9 100644 --- a/packages/xblox/tests/fixtures/switch-default-only.json +++ b/packages/xblox/tests/fixtures/switch-default-only.json @@ -1,7 +1,8 @@ { "version": 1, "context": { "mode": 99 }, - "root": { + "roots": [ + { "kind": "switch", "variable": "mode", "items": [ @@ -16,5 +17,6 @@ "consequent": [{ "kind": "runScript", "method": "return 'def';" }] } ] - } + } + ] } diff --git a/packages/xblox/tests/fixtures/while-count.json b/packages/xblox/tests/fixtures/while-count.json index 7c2b66d4..30728a8b 100644 --- a/packages/xblox/tests/fixtures/while-count.json +++ b/packages/xblox/tests/fixtures/while-count.json @@ -1,7 +1,8 @@ { "version": 1, "context": { "n": 0 }, - "root": { + "roots": [ + { "kind": "while", "condition": "this.n < 3", "items": [ @@ -10,5 +11,6 @@ "method": "this.n++; return this.n;" } ] - } + } + ] } diff --git a/packages/xblox/tests/test-commons.ts b/packages/xblox/tests/test-commons.ts index a07f6c84..198c5808 100644 --- a/packages/xblox/tests/test-commons.ts +++ b/packages/xblox/tests/test-commons.ts @@ -1,7 +1,8 @@ /** - * Shared paths and helpers for vitest (import from tests only). + * Shared paths, performance measurement, and optional JSON reports for vitest. */ +import { mkdirSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -12,6 +13,97 @@ export const testsDir = __dirname; export const fixturesDir = path.join(testsDir, 'fixtures'); +/** Default JSON output: `packages/xblox/reports/perf-latest.json` (gitignored). */ +export const defaultPerfReportPath = path.join(testsDir, '..', 'reports', 'perf-latest.json'); + export function fixture(...parts: string[]): string { return path.join(fixturesDir, ...parts); } + +// ——— performance ——— + +export type PerfEntry = { + /** Logical label (e.g. suite step name). */ + name: string; + /** Duration in milliseconds (`performance.now()` delta). */ + ms: number; + /** ISO timestamp when the measurement finished. */ + at: string; +}; + +/** When set to `1`, `PerfReporter.writeJsonFile()` writes to disk (see `npm run test:perf`). */ +export function isPerfReportEnabled(): boolean { + return process.env.XBLOX_PERF_REPORT === '1'; +} + +/** + * Measure sync or async work; returns the function result and duration. + * Does not record to a report — use `PerfReporter.measure` for that. + */ +export async function measureMs(fn: () => T | Promise): Promise<{ result: T; ms: number }> { + const t0 = performance.now(); + const result = await fn(); + const ms = performance.now() - t0; + return { result, ms }; +} + +/** + * Collect timings for a test file or suite. Call `printSummary()` in `afterAll`, + * and optionally `writeJsonFile()` when `XBLOX_PERF_REPORT=1`. + */ +export class PerfReporter { + private readonly entries: PerfEntry[] = []; + + get snapshot(): readonly PerfEntry[] { + return [...this.entries]; + } + + /** Run `fn`, record duration under `name`, return `fn`’s result. */ + async measure(name: string, fn: () => T | Promise): Promise { + const { result, ms } = await measureMs(fn); + this.entries.push({ + name, + ms, + at: new Date().toISOString(), + }); + return result; + } + + /** Log a compact table to stdout (Vitest / CI). */ + printSummary(title = 'xblox perf'): void { + if (this.entries.length === 0) { + return; + } + const rows = this.entries.map((e) => ({ + name: e.name, + ms: Number(e.ms.toFixed(3)), + at: e.at, + })); + console.info(`\n[${title}]`); + console.table(rows); + const total = this.entries.reduce((s, e) => s + e.ms, 0); + console.info(`[${title}] total: ${total.toFixed(3)} ms\n`); + } + + /** + * Write `reports/perf-latest.json` (or `path`). Safe to call from `afterAll`. + * Set env `XBLOX_PERF_REPORT=1` to enable writes; otherwise no-op (avoids churn in CI). + */ + writeJsonFile(filePath: string = defaultPerfReportPath): string | undefined { + if (!isPerfReportEnabled()) { + return undefined; + } + mkdirSync(path.dirname(filePath), { recursive: true }); + const payload = { + generatedAt: new Date().toISOString(), + entries: this.entries.map((e) => ({ + name: e.name, + ms: e.ms, + at: e.at, + })), + totalMs: this.entries.reduce((s, e) => s + e.ms, 0), + }; + writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); + return filePath; + } +} diff --git a/packages/xblox/tests/unit/cli-run.test.ts b/packages/xblox/tests/unit/cli-run.test.ts index d39d5cef..76f06e8c 100644 --- a/packages/xblox/tests/unit/cli-run.test.ts +++ b/packages/xblox/tests/unit/cli-run.test.ts @@ -33,6 +33,6 @@ describe('pm-xblox run (runCommand)', () => { loglevel: 'error', }); const text = await readFile(outPath, 'utf8'); - expect(JSON.parse(text)).toEqual({ result: 'zero' }); + expect(JSON.parse(text)).toEqual({ result: 'zero', results: ['zero'] }); }); }); diff --git a/packages/xblox/tests/unit/if-else-engine.test.ts b/packages/xblox/tests/unit/if-else-engine.test.ts index 9c27cc91..96dc47f8 100644 --- a/packages/xblox/tests/unit/if-else-engine.test.ts +++ b/packages/xblox/tests/unit/if-else-engine.test.ts @@ -11,13 +11,13 @@ describe('if / else-if / alternate engine', () => { const raw = JSON.parse(await readFile(fixture('if-else.json'), 'utf8')); const file = blocksFileSchema.parse(raw); const ctx = Object.assign(Object.create(null), file.context) as object; - expect(executeBlock(file.root, ctx)).toBe('neg'); + expect(await executeBlock(file.roots[0], ctx)).toBe('neg'); }); it('evaluates alternate when no branch matches', async () => { const raw = JSON.parse(await readFile(fixture('if-else-zero.json'), 'utf8')); const file = blocksFileSchema.parse(raw); const ctx = Object.assign(Object.create(null), file.context) as object; - expect(executeBlock(file.root, ctx)).toBe('zero'); + expect(await executeBlock(file.roots[0], ctx)).toBe('zero'); }); }); diff --git a/packages/xblox/tests/unit/loops-logic.test.ts b/packages/xblox/tests/unit/loops-logic.test.ts index ee421045..b89ec7b0 100644 --- a/packages/xblox/tests/unit/loops-logic.test.ts +++ b/packages/xblox/tests/unit/loops-logic.test.ts @@ -10,7 +10,7 @@ describe('ForBlock (legacy xblox.model.loops.ForBlock)', () => { const raw = JSON.parse(await readFile(fixture('for-sum.json'), 'utf8')); const file = blocksFileSchema.parse(raw); const ctx = Object.assign(Object.create(null), file.context) as object; - expect(executeBlock(file.root, ctx)).toBe(3); + expect(await executeBlock(file.roots[0], ctx)).toBe(3); expect((ctx as { x: number }).x).toBe(3); }); }); @@ -20,7 +20,7 @@ describe('WhileBlock (legacy xblox.model.loops.WhileBlock)', () => { const raw = JSON.parse(await readFile(fixture('while-count.json'), 'utf8')); const file = blocksFileSchema.parse(raw); const ctx = Object.assign(Object.create(null), file.context) as object; - expect(executeBlock(file.root, ctx)).toBe(3); + expect(await executeBlock(file.roots[0], ctx)).toBe(3); expect((ctx as { n: number }).n).toBe(3); }); }); @@ -30,14 +30,14 @@ describe('SwitchBlock / CaseBlock / DefaultBlock (legacy logic)', () => { const raw = JSON.parse(await readFile(fixture('switch-case.json'), 'utf8')); const file = blocksFileSchema.parse(raw); const ctx = Object.assign(Object.create(null), file.context) as object; - expect(executeBlock(file.root, ctx)).toBe('two'); + expect(await executeBlock(file.roots[0], ctx)).toBe('two'); }); it('runs switchDefault when no case matches', async () => { const raw = JSON.parse(await readFile(fixture('switch-default-only.json'), 'utf8')); const file = blocksFileSchema.parse(raw); const ctx = Object.assign(Object.create(null), file.context) as object; - expect(executeBlock(file.root, ctx)).toBe('def'); + expect(await executeBlock(file.roots[0], ctx)).toBe('def'); }); }); @@ -46,28 +46,30 @@ describe('BreakBlock', () => { const file = blocksFileSchema.parse({ version: 1, context: { mode: 1 }, - root: { - kind: 'switch', - variable: 'mode', - items: [ - { - kind: 'case', - comparator: '==', - expression: '1', - consequent: [ - { kind: 'runScript', method: "return 'hit';" }, - { kind: 'break' }, - { kind: 'runScript', method: "return 'after';" }, - ], - }, - { - kind: 'switchDefault', - consequent: [{ kind: 'runScript', method: "return 'def';" }], - }, - ], - }, + roots: [ + { + kind: 'switch', + variable: 'mode', + items: [ + { + kind: 'case', + comparator: '==', + expression: '1', + consequent: [ + { kind: 'runScript', method: "return 'hit';" }, + { kind: 'break' }, + { kind: 'runScript', method: "return 'after';" }, + ], + }, + { + kind: 'switchDefault', + consequent: [{ kind: 'runScript', method: "return 'def';" }], + }, + ], + }, + ], }); const ctx = Object.assign(Object.create(null), file.context) as object; - expect(executeBlock(file.root, ctx)).toBe('after'); + expect(await executeBlock(file.roots[0], ctx)).toBe('after'); }); }); diff --git a/packages/xblox/tests/unit/multi-root-wait.test.ts b/packages/xblox/tests/unit/multi-root-wait.test.ts new file mode 100644 index 00000000..f7253949 --- /dev/null +++ b/packages/xblox/tests/unit/multi-root-wait.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { readFile } from 'node:fs/promises'; + +import { executeRoots } from '../../src/engine/execute-block.js'; +import { blocksFileSchema } from '../../src/schema/blocks-file.js'; +import { fixture } from '../test-commons.js'; + +describe('multiple roots + wait', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('runs each top-level block in order; wait(ms) is async (5000ms)', async () => { + const raw = JSON.parse(await readFile(fixture('multi-root-wait.json'), 'utf8')); + const file = blocksFileSchema.parse(raw); + const ctx = Object.assign(Object.create(null), file.context) as { steps: string[] }; + + const run = executeRoots(file.roots, ctx); + await vi.advanceTimersByTimeAsync(5000); + const out = await run; + + expect(out.results).toEqual(['a', undefined, 'b']); + expect(out.result).toBe('b'); + expect(ctx.steps).toEqual(['a', 'b']); + }); +}); diff --git a/packages/xblox/tests/unit/perf-report.test.ts b/packages/xblox/tests/unit/perf-report.test.ts new file mode 100644 index 00000000..ed6b51f6 --- /dev/null +++ b/packages/xblox/tests/unit/perf-report.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, afterAll } from 'vitest'; +import { readFile } from 'node:fs/promises'; + +import { executeRoots } from '../../src/engine/execute-block.js'; +import { blocksFileSchema } from '../../src/schema/blocks-file.js'; +import { fixture, PerfReporter } from '../test-commons.js'; + +const perf = new PerfReporter(); + +describe('performance (test-commons reporter)', () => { + afterAll(() => { + perf.printSummary('xblox'); + perf.writeJsonFile(); + }); + + it('parses and runs representative fixtures under measurement', async () => { + const ifElseFile = await perf.measure('parse if-else.json', async () => { + const raw = JSON.parse(await readFile(fixture('if-else.json'), 'utf8')); + return blocksFileSchema.parse(raw); + }); + + const ctx1 = Object.assign(Object.create(null), ifElseFile.context) as object; + await perf.measure('executeRoots if-else', async () => { + return executeRoots(ifElseFile.roots, ctx1); + }); + + const multiRaw = await perf.measure('parse multi-root-wait.json', async () => { + const raw = JSON.parse(await readFile(fixture('multi-root-wait.json'), 'utf8')); + return blocksFileSchema.parse(raw); + }); + + const ctx2 = Object.assign(Object.create(null), multiRaw.context) as object; + await perf.measure('executeRoots multi-root (sync parts only)', async () => { + return executeRoots( + [ + multiRaw.roots[0], + { kind: 'wait' as const, ms: 0 }, + multiRaw.roots[2], + ], + ctx2, + ); + }); + + expect(ifElseFile.roots).toHaveLength(1); + expect(multiRaw.roots).toHaveLength(3); + }); +});