Initial Commit
This commit is contained in:
42
packages/foundation/flow/undo/tests/dep-graph.test.ts
Normal file
42
packages/foundation/flow/undo/tests/dep-graph.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(resolve(import.meta.dirname, "../package.json"), "utf-8")
|
||||
) as Record<string, unknown>;
|
||||
|
||||
describe("dep-graph", () => {
|
||||
it("has sideEffects: false", { timeout: 5000 }, () => {
|
||||
expect(pkg["sideEffects"]).toBe(false);
|
||||
});
|
||||
|
||||
it("has no disallowed @suaveplan/* runtime deps (only error, types, events, diff)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const deps = Object.keys(
|
||||
(pkg["dependencies"] as Record<string, string>) ?? {}
|
||||
);
|
||||
const suaveDeps = deps.filter((d) => d.startsWith("@suaveplan/"));
|
||||
const allowed = new Set([
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/diff",
|
||||
]);
|
||||
for (const dep of suaveDeps) {
|
||||
expect(allowed.has(dep), `unexpected dep: ${dep}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("workspace deps use workspace:^ range", { timeout: 5000 }, () => {
|
||||
const deps = (pkg["dependencies"] as Record<string, string>) ?? {};
|
||||
for (const [name, version] of Object.entries(deps)) {
|
||||
if (name.startsWith("@suaveplan/")) {
|
||||
expect(version, `${name} should use workspace:^`).toBe(
|
||||
"workspace:^"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
108
packages/foundation/flow/undo/tests/otel.test.ts
Normal file
108
packages/foundation/flow/undo/tests/otel.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createAsyncCommand } from "../src/async/async-command.js";
|
||||
import { AsyncUndoStack } from "../src/async/async-stack.js";
|
||||
import { BranchingHistory } from "../src/branch/branch.js";
|
||||
import { createCommand } from "../src/command/command.js";
|
||||
import { compressHistory } from "../src/compression/history-compressor.js";
|
||||
import { SelectiveUndoStack } from "../src/selective/selective-undo.js";
|
||||
import { UndoStack } from "../src/stack/stack.js";
|
||||
|
||||
// OTel meters are no-ops by default (no exporter registered) — we just verify
|
||||
// that operations complete without throwing and that the meter API is exercised.
|
||||
// The default @opentelemetry/api ships a NoopMeterProvider.
|
||||
|
||||
interface Counter {
|
||||
value: number;
|
||||
}
|
||||
|
||||
const mkCmd = (n = 1) =>
|
||||
createCommand<Counter>(
|
||||
`Inc${n}`,
|
||||
(s) => ({ value: s.value + n }),
|
||||
(s) => ({ value: s.value - n })
|
||||
);
|
||||
|
||||
describe("OTel instrumentation (no-op SDK)", () => {
|
||||
it("UndoStack push increments counter without throwing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const s = new UndoStack<Counter>({ value: 0 });
|
||||
expect(() => {
|
||||
s.push(mkCmd(1));
|
||||
s.push(mkCmd(2));
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("UndoStack undo increments counter without throwing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const s = new UndoStack<Counter>({ value: 0 });
|
||||
s.push(mkCmd(1));
|
||||
expect(() => s.undo()).not.toThrow();
|
||||
});
|
||||
|
||||
it("UndoStack redo increments counter without throwing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const s = new UndoStack<Counter>({ value: 0 });
|
||||
s.push(mkCmd(1));
|
||||
s.undo();
|
||||
expect(() => s.redo()).not.toThrow();
|
||||
});
|
||||
|
||||
it("BranchingHistory push, undo, redo without throwing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const h = new BranchingHistory<Counter>({ value: 0 });
|
||||
expect(() => {
|
||||
h.push(mkCmd(1));
|
||||
h.undo();
|
||||
h.redo();
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("AsyncUndoStack push/undo/redo without throwing", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const s = new AsyncUndoStack<Counter>({ value: 0 });
|
||||
const aCmd = createAsyncCommand<Counter>(
|
||||
"AI",
|
||||
async (st) => ({ value: st.value + 1 }),
|
||||
async (st) => ({ value: st.value - 1 })
|
||||
);
|
||||
await s.push(aCmd);
|
||||
await s.undo();
|
||||
await s.redo();
|
||||
expect(s.current.value).toBe(1);
|
||||
});
|
||||
|
||||
it("compressHistory records compress counter without throwing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const s = new UndoStack<Counter>({ value: 0 });
|
||||
for (let i = 0; i < 20; i++) s.push(mkCmd(1));
|
||||
expect(() => compressHistory(s, { compressionDepth: 5 })).not.toThrow();
|
||||
});
|
||||
|
||||
it("SelectiveUndoStack undoById records undo counter", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const s = new SelectiveUndoStack<Counter>({ value: 0 });
|
||||
const a = mkCmd(1);
|
||||
s.push(a);
|
||||
expect(() => s.undoById(a.id)).not.toThrow();
|
||||
});
|
||||
|
||||
it("BranchingHistory switchBranch without throwing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const h = new BranchingHistory<Counter>({ value: 0 });
|
||||
h.push(mkCmd(1));
|
||||
h.undo();
|
||||
h.push(mkCmd(2)); // diverge → fork
|
||||
const forked = h.branches.find((b) => b.label.startsWith("fork-"));
|
||||
if (forked) {
|
||||
expect(() => h.switchBranch(forked.id)).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, it } from "@suaveplan/testing/runner";
|
||||
import * as fc from "fast-check";
|
||||
import { BranchingHistory } from "../../src/branch/branch.js";
|
||||
import { createCommand } from "../../src/command/command.js";
|
||||
|
||||
interface Counter {
|
||||
value: number;
|
||||
}
|
||||
|
||||
describe("property: BranchingHistory invariants", () => {
|
||||
it("branch isolation: pushing on one branch doesn't change another", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.integer({ min: 1, max: 5 }), {
|
||||
minLength: 2,
|
||||
maxLength: 10,
|
||||
}),
|
||||
fc.array(fc.integer({ min: 1, max: 5 }), {
|
||||
minLength: 1,
|
||||
maxLength: 5,
|
||||
}),
|
||||
(mainDeltas, extraDeltas) => {
|
||||
const h = new BranchingHistory<Counter>({ value: 0 });
|
||||
for (const d of mainDeltas) {
|
||||
h.push(
|
||||
createCommand(
|
||||
"D",
|
||||
(s) => ({ value: s.value + d }),
|
||||
(s) => ({ value: s.value - d })
|
||||
)
|
||||
);
|
||||
}
|
||||
h.undo(); // set up diverge
|
||||
const _stateBeforeFork = h.current.value;
|
||||
h.push(
|
||||
createCommand(
|
||||
"Fork",
|
||||
(s) => ({ value: s.value + 99 }),
|
||||
(s) => ({ value: s.value - 99 })
|
||||
)
|
||||
);
|
||||
const forked = h.branches.find((b) =>
|
||||
b.label.startsWith("fork-")
|
||||
);
|
||||
if (!forked) return true; // nothing to test
|
||||
|
||||
const mainId = h.branches.find(
|
||||
(b) => b.label === "main"
|
||||
)?.id;
|
||||
if (!mainId) return true;
|
||||
|
||||
const mainCurrent = h.current.value; // on main now
|
||||
|
||||
// Switch to forked and push more
|
||||
h.switchBranch(forked.id);
|
||||
for (const d of extraDeltas) {
|
||||
h.push(
|
||||
createCommand(
|
||||
"E",
|
||||
(s) => ({ value: s.value + d }),
|
||||
(s) => ({ value: s.value - d })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Switch back to main — must be unchanged
|
||||
h.switchBranch(mainId);
|
||||
return h.current.value === mainCurrent;
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it("undo/redo cursor: canUndo + canRedo invariant holds", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.integer({ min: 1, max: 5 }), {
|
||||
minLength: 1,
|
||||
maxLength: 20,
|
||||
}),
|
||||
fc.integer({ min: 0, max: 20 }),
|
||||
(deltas, undoSteps) => {
|
||||
const h = new BranchingHistory<Counter>(
|
||||
{ value: 0 },
|
||||
{ branchOnDiverge: false }
|
||||
);
|
||||
for (const d of deltas) {
|
||||
h.push(
|
||||
createCommand(
|
||||
"D",
|
||||
(s) => ({ value: s.value + d }),
|
||||
(s) => ({ value: s.value - d })
|
||||
)
|
||||
);
|
||||
}
|
||||
const steps = Math.min(undoSteps, deltas.length);
|
||||
for (let i = 0; i < steps; i++) h.undo();
|
||||
const branch = h.getBranch(h.activeBranchId)!;
|
||||
return (
|
||||
branch.undoStack.length + branch.redoStack.length ===
|
||||
deltas.length
|
||||
);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it } from "@suaveplan/testing/runner";
|
||||
import * as fc from "fast-check";
|
||||
import {
|
||||
createCommand,
|
||||
executeCommand,
|
||||
undoCommand,
|
||||
} from "../../src/command/command.js";
|
||||
|
||||
interface State {
|
||||
x: number;
|
||||
y: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
describe("property: command round-trip", () => {
|
||||
it("execute then undo restores state for arbitrary closures", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.record({
|
||||
x: fc.integer(),
|
||||
y: fc.integer(),
|
||||
label: fc.string(),
|
||||
}),
|
||||
fc.integer({ min: 1, max: 100 }),
|
||||
(initial, delta) => {
|
||||
const cmd = createCommand<State>(
|
||||
"Transform",
|
||||
(s) => ({ ...s, x: s.x + delta }),
|
||||
(s) => ({ ...s, x: s.x - delta })
|
||||
);
|
||||
const after = executeCommand(initial, cmd);
|
||||
const back = undoCommand(after, cmd);
|
||||
return (
|
||||
back.x === initial.x &&
|
||||
back.y === initial.y &&
|
||||
back.label === initial.label
|
||||
);
|
||||
}
|
||||
),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it } from "@suaveplan/testing/runner";
|
||||
import * as fc from "fast-check";
|
||||
import { createCommand } from "../../src/command/command.js";
|
||||
import { compressHistory } from "../../src/compression/history-compressor.js";
|
||||
import { UndoStack } from "../../src/stack/stack.js";
|
||||
|
||||
interface Counter {
|
||||
value: number;
|
||||
}
|
||||
|
||||
describe("property: compressHistory idempotency", () => {
|
||||
it("re-compressing does not change state or exceed original size", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 0, max: 50 }),
|
||||
fc.integer({ min: 1, max: 20 }),
|
||||
(pushCount, depth) => {
|
||||
const s = new UndoStack<Counter>({ value: 0 });
|
||||
for (let i = 0; i < pushCount; i++) {
|
||||
s.push(
|
||||
createCommand(
|
||||
"Inc",
|
||||
(st) => ({ value: st.value + 1 }),
|
||||
(st) => ({ value: st.value - 1 })
|
||||
)
|
||||
);
|
||||
}
|
||||
const r1 = compressHistory(s, { compressionDepth: depth });
|
||||
const r2 = compressHistory(r1.stack, {
|
||||
compressionDepth: depth,
|
||||
});
|
||||
// State preserved
|
||||
if (r1.stack.current.value !== r2.stack.current.value)
|
||||
return false;
|
||||
// Second compression adds no new squashes (all same-label entries already merged)
|
||||
// Size should not increase
|
||||
return r2.newSize <= r1.newSize;
|
||||
}
|
||||
),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it } from "@suaveplan/testing/runner";
|
||||
import * as fc from "fast-check";
|
||||
import { createCommand, executeCommand } from "../../src/command/command.js";
|
||||
import { CommandMerger } from "../../src/merge/command-merger.js";
|
||||
|
||||
interface Counter {
|
||||
value: number;
|
||||
}
|
||||
|
||||
describe("property: CommandMerger merge associativity", () => {
|
||||
it("merged command executes to same state as executing individually", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.integer({ min: 1, max: 10 }), {
|
||||
minLength: 1,
|
||||
maxLength: 15,
|
||||
}),
|
||||
(deltas) => {
|
||||
const commands = deltas.map((d) =>
|
||||
createCommand<Counter>(
|
||||
"Inc",
|
||||
(s) => ({ value: s.value + d }),
|
||||
(s) => ({ value: s.value - d })
|
||||
)
|
||||
);
|
||||
|
||||
// Execute individually
|
||||
let individual: Counter = { value: 0 };
|
||||
for (const cmd of commands) {
|
||||
individual = executeCommand(individual, cmd);
|
||||
}
|
||||
|
||||
// Merge all then execute
|
||||
const m = new CommandMerger<Counter>({
|
||||
maxCount: commands.length + 1,
|
||||
});
|
||||
for (const cmd of commands) m.add(cmd);
|
||||
const merged = m.flush();
|
||||
const mergedResult = merged
|
||||
? executeCommand({ value: 0 }, merged)
|
||||
: { value: 0 };
|
||||
|
||||
return mergedResult.value === individual.value;
|
||||
}
|
||||
),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it } from "@suaveplan/testing/runner";
|
||||
import * as fc from "fast-check";
|
||||
import { createCommand } from "../../src/command/command.js";
|
||||
import { UndoStack } from "../../src/stack/stack.js";
|
||||
|
||||
interface Counter {
|
||||
value: number;
|
||||
}
|
||||
|
||||
describe("property: UndoStack invariants", () => {
|
||||
it("push N, undo all, redo all: final state = after-push state", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.integer({ min: 1, max: 10 }), {
|
||||
minLength: 1,
|
||||
maxLength: 20,
|
||||
}),
|
||||
(deltas) => {
|
||||
const s = new UndoStack<Counter>({ value: 0 });
|
||||
for (const d of deltas) {
|
||||
s.push(
|
||||
createCommand(
|
||||
"D",
|
||||
(st) => ({ value: st.value + d }),
|
||||
(st) => ({ value: st.value - d })
|
||||
)
|
||||
);
|
||||
}
|
||||
const afterPush = s.current.value;
|
||||
for (let i = 0; i < deltas.length; i++) s.undo();
|
||||
for (let i = 0; i < deltas.length; i++) s.redo();
|
||||
return s.current.value === afterPush;
|
||||
}
|
||||
),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
|
||||
it("undoSize never exceeds maxEntries", { timeout: 30000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 1, max: 20 }),
|
||||
fc.array(fc.integer({ min: 1, max: 5 }), {
|
||||
minLength: 0,
|
||||
maxLength: 50,
|
||||
}),
|
||||
(maxEntries, deltas) => {
|
||||
const s = new UndoStack<Counter>(
|
||||
{ value: 0 },
|
||||
{ maxEntries }
|
||||
);
|
||||
for (const d of deltas) {
|
||||
s.push(
|
||||
createCommand(
|
||||
"D",
|
||||
(st) => ({ value: st.value + d }),
|
||||
(st) => ({ value: st.value - d })
|
||||
)
|
||||
);
|
||||
}
|
||||
return s.undoSize <= maxEntries;
|
||||
}
|
||||
),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
|
||||
it("undoSize + redoSize = total pushed (within maxEntries limit)", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.integer({ min: 1, max: 5 }), {
|
||||
minLength: 0,
|
||||
maxLength: 15,
|
||||
}),
|
||||
fc.integer({ min: 0, max: 15 }),
|
||||
(deltas, undoSteps) => {
|
||||
const s = new UndoStack<Counter>({ value: 0 });
|
||||
for (const d of deltas) {
|
||||
s.push(
|
||||
createCommand(
|
||||
"D",
|
||||
(st) => ({ value: st.value + d }),
|
||||
(st) => ({ value: st.value - d })
|
||||
)
|
||||
);
|
||||
}
|
||||
const steps = Math.min(undoSteps, deltas.length);
|
||||
for (let i = 0; i < steps; i++) s.undo();
|
||||
return s.undoSize + s.redoSize === deltas.length;
|
||||
}
|
||||
),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
});
|
||||
8
packages/foundation/flow/undo/tests/setup/polyfills.ts
Normal file
8
packages/foundation/flow/undo/tests/setup/polyfills.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Ensure `globalThis.crypto` is available in bare-Node vitest runs.
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
Reference in New Issue
Block a user