Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View 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:^"
);
}
}
});
});

View 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();
}
});
});

View File

@@ -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 }
);
});
});

View File

@@ -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 }
);
});
});

View File

@@ -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 }
);
});
});

View File

@@ -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 }
);
});
});

View File

@@ -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 }
);
});
});

View 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;
}