249 lines
8.6 KiB
TypeScript
249 lines
8.6 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import {
|
|
NODE_BYTES,
|
|
Queue,
|
|
VIEW_HIGH,
|
|
VIEW_LOW,
|
|
before,
|
|
bytes,
|
|
clean,
|
|
compressTask,
|
|
grow,
|
|
key,
|
|
merge,
|
|
mergeTask,
|
|
tooLong,
|
|
viewBytes,
|
|
type Line,
|
|
type View,
|
|
} from "../src/optchat-core.js";
|
|
|
|
interface State {
|
|
keep: number;
|
|
state: number;
|
|
older: State | null;
|
|
}
|
|
|
|
// Taelin's rollback push without `life`, which stays 0 under push alone.
|
|
function push(next: number, states: State | null): State {
|
|
if (states === null) return { keep: 0, state: next, older: null };
|
|
if (states.keep === 0) return { ...states, keep: 1 };
|
|
return { keep: 0, state: next, older: push(states.state, states.older) };
|
|
}
|
|
|
|
// Reads the newest-first list as view lines, oldest first.
|
|
function lines(states: State | null, t: number) {
|
|
const starts: number[] = [];
|
|
for (let s = states; s; s = s.older) starts.push(s.state);
|
|
starts.reverse();
|
|
return starts.map((first, k): Line => {
|
|
const size = (starts[k + 1] ?? t + 1) - first;
|
|
const l = Math.log2(size);
|
|
expect(Number.isInteger(l) && first % size === 0).toBe(true);
|
|
return [l, first / size];
|
|
});
|
|
}
|
|
|
|
test("due ordering reproduces Taelin's push at its list length for t=0..2000", () => {
|
|
// Every parent counts as built. A target one byte under the current size merges exactly the
|
|
// most due pair, so the budget is push's list length in lines.
|
|
const text = () => "";
|
|
let states: State | null = null;
|
|
let view: Line[] = [];
|
|
for (let t = 0; t <= 2000; t++) {
|
|
states = push(t, states);
|
|
const expected = lines(states, t);
|
|
view.push([0, t]);
|
|
while (view.length > expected.length) {
|
|
const next = merge(view, t + 1, viewBytes(view, text) - 1, text);
|
|
expect(next.length).toBe(view.length - 1);
|
|
view = next;
|
|
}
|
|
expect(view).toEqual(expected);
|
|
}
|
|
});
|
|
|
|
test("merges and compaction context never use unbuilt lines", () => {
|
|
const built = new Set(["1:0"]);
|
|
const text = (line: Line) => (line[0] === 0 || built.has(key(line)) ? "" : undefined);
|
|
const view: Line[] = [
|
|
[0, 0],
|
|
[0, 1],
|
|
[0, 2],
|
|
[0, 3],
|
|
];
|
|
expect(merge(view, 4, 0, text)).toEqual([
|
|
[1, 0],
|
|
[0, 2],
|
|
[0, 3],
|
|
]);
|
|
const context = (line: Line) => (key(line) === "0:5" ? undefined : "t");
|
|
const zoomed: Line[] = [
|
|
[2, 0],
|
|
[0, 4],
|
|
[0, 5],
|
|
[0, 6],
|
|
];
|
|
expect(before(zoomed, 4, context)).toEqual([[2, 0]]);
|
|
expect(before(zoomed, 7, context)).toEqual([
|
|
[2, 0],
|
|
[0, 4],
|
|
]);
|
|
});
|
|
|
|
test("view is a sawtooth: grows past 128 KB, one batch drops it to 64 KB, and a short batch continues", () => {
|
|
{
|
|
const line = "y".repeat(500);
|
|
const text = () => line;
|
|
const view: View = { lines: [], batching: false };
|
|
let peak = 0;
|
|
let batches = 0;
|
|
let total = 0;
|
|
for (let i = 0; i < 2000; i++) {
|
|
const size = viewBytes(view.lines, text);
|
|
expect(total).toBe(size);
|
|
peak = Math.max(peak, size);
|
|
const grown = grow(view, i, text, total, true);
|
|
total = grown.total;
|
|
if (grown.merged) {
|
|
batches++;
|
|
expect(size).toBeLessThanOrEqual(VIEW_HIGH);
|
|
expect(viewBytes(view.lines, text)).toBeLessThanOrEqual(VIEW_LOW);
|
|
} else expect(view.lines.at(-1)).toEqual([0, i]);
|
|
}
|
|
expect(peak).toBeGreaterThan(VIEW_LOW);
|
|
expect(peak).toBeLessThanOrEqual(VIEW_HIGH + 600);
|
|
expect(batches).toBeGreaterThan(1);
|
|
// Lines stay contiguous and cover every message.
|
|
let next = 0;
|
|
for (const [l, i] of view.lines) {
|
|
expect(i * 2 ** l).toBe(next);
|
|
next += 2 ** l;
|
|
}
|
|
expect(next).toBe(2000);
|
|
}
|
|
{
|
|
let parents = false;
|
|
const text = (line: Line) => (line[0] === 0 || parents ? "z".repeat(500) : undefined);
|
|
const view: View = { lines: [], batching: false };
|
|
let total = 0;
|
|
for (let i = 0; i < 260; i++) {
|
|
const grown = grow(view, i, text, total, true);
|
|
expect(grown.merged).toBe(false);
|
|
total = grown.total;
|
|
}
|
|
expect(view.batching).toBe(true);
|
|
parents = true;
|
|
// Nothing was built since the last try: the batch waits.
|
|
const waiting = grow(view, 260, text, total, false);
|
|
expect(waiting).toEqual({
|
|
merged: false,
|
|
tried: false,
|
|
total: viewBytes(view.lines, text),
|
|
});
|
|
expect(grow(view, 261, text, waiting.total, true).merged).toBe(true);
|
|
expect(viewBytes(view.lines, text)).toBeLessThanOrEqual(VIEW_LOW);
|
|
expect(view.batching).toBe(false);
|
|
}
|
|
});
|
|
|
|
test("tasks are verbatim and Too long cuts at 512 bytes", () => {
|
|
const ruler = "-".repeat(512);
|
|
expect(compressTask(7, "user", "hi")).toBe(
|
|
`Compaction: compress message 7 into one line of at most 512 bytes\n(about 70 words), the length of this ruler:\n${ruler}\n<input>\nuser: hi\n</input>`,
|
|
);
|
|
expect(mergeTask([3, 5], "A", "B")).toBe(
|
|
`Compaction: merge lines 40+4 and 44+4, adjacent, into one line of at most\n512 bytes (about 70 words), the length of this ruler:\n${ruler}\n<chat> may hold their messages, 40 to 47, in more detail: take details\nof them from there too.\n<input>\nA\nB\n</input>`,
|
|
);
|
|
const reply = "é".repeat(300);
|
|
const message = tooLong(reply);
|
|
expect(message).toStartWith("Too long: your line is 600 bytes, over the 512-byte limit.");
|
|
expect(message).toEndWith(`\n${"é".repeat(256)}| ← LIMIT`);
|
|
expect(bytes("é".repeat(256))).toBe(NODE_BYTES);
|
|
expect(clean(" 40+8| the line \n")).toBe("the line");
|
|
});
|
|
|
|
test("queue keeps at most 8 builds running, starts messages within 8 of the first unbuilt, and merges built pairs", async () => {
|
|
const built = new Set<string>();
|
|
const pending = new Map<string, (ok: boolean) => void>();
|
|
let peak = 0;
|
|
const queue = new Queue(
|
|
(line) => built.has(key(line)),
|
|
(line) =>
|
|
new Promise<boolean>((resolve) => {
|
|
pending.set(key(line), (ok) => {
|
|
if (ok) built.add(key(line));
|
|
resolve(ok);
|
|
});
|
|
peak = Math.max(peak, pending.size);
|
|
}),
|
|
);
|
|
const finish = async (name: string, ok = true) => {
|
|
const resolve = pending.get(name)!;
|
|
pending.delete(name);
|
|
resolve(ok);
|
|
await Bun.sleep(0);
|
|
};
|
|
for (let i = 0; i < 12; i++) queue.add(i);
|
|
expect([...pending.keys()].sort()).toEqual(
|
|
["0:0", "0:1", "0:2", "0:3", "0:4", "0:5", "0:6", "0:7"].sort(),
|
|
);
|
|
await finish("0:1");
|
|
// Message 8 starts: 7 earlier messages remain unbuilt.
|
|
expect(pending.has("0:8")).toBe(true);
|
|
await finish("0:0");
|
|
// 0 and 1 are built: their merge competes with message 9 for the free slot; messages come first.
|
|
expect(pending.has("0:9")).toBe(true);
|
|
expect(pending.has("1:0")).toBe(false);
|
|
await finish("0:2", false);
|
|
// The failure keeps its slot as unbuilt; no leaf may start, so the merge runs.
|
|
expect(pending.has("1:0")).toBe(true);
|
|
expect(pending.has("0:10")).toBe(false);
|
|
expect(peak).toBeLessThanOrEqual(8);
|
|
const settled = queue.settle(3, 1_000);
|
|
// settle requeues the failure at once; it starts in the next free slot.
|
|
await finish("1:0");
|
|
expect(pending.has("0:2")).toBe(true);
|
|
await finish("0:2");
|
|
expect(await settled).toBe(true);
|
|
});
|
|
|
|
test("queue resumes unbuilt messages and ready merges from disk", async () => {
|
|
const built = new Set(["0:0", "0:1", "0:2"]);
|
|
const started: string[] = [];
|
|
const queue = new Queue(
|
|
(line) => built.has(key(line)),
|
|
async (line) => {
|
|
started.push(key(line));
|
|
built.add(key(line));
|
|
return true;
|
|
},
|
|
);
|
|
queue.resume(4, [
|
|
[0, 0],
|
|
[0, 1],
|
|
[0, 2],
|
|
]);
|
|
await Bun.sleep(0);
|
|
expect(started).toEqual(["0:3", "1:0", "1:1", "2:0"]);
|
|
});
|
|
|
|
test("a merge is queued once when both halves finish in the same tick", async () => {
|
|
// Regression: short leaves build synchronously, so both finishes saw the parent unbuilt.
|
|
const tree = new Set<string>();
|
|
const starts: string[] = [];
|
|
const queue = new Queue(
|
|
(line) => tree.has(key(line)),
|
|
async (line) => {
|
|
starts.push(key(line));
|
|
if (line[0] > 0) await Bun.sleep(5);
|
|
tree.add(key(line));
|
|
return true;
|
|
},
|
|
);
|
|
queue.add(0);
|
|
queue.add(1);
|
|
await Bun.sleep(30);
|
|
expect(starts).toEqual(["0:0", "0:1", "1:0"]);
|
|
});
|