Worker prewarm, viewport-first stable batches, and adaptive reveal pacing (default on, clamp 6–14ms). On drag settle force OVG rebuild and never drop the last legal path when route/stretch fail.
124 lines
4.4 KiB
JavaScript
124 lines
4.4 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import {
|
|
adaptiveBudgetMs,
|
|
orderedRouteResults,
|
|
processRouteSlice,
|
|
progressiveItemLimit,
|
|
shouldProgressivelyRoute,
|
|
} from "../web/progressive.js";
|
|
import { orderRouteCandidates } from "../web/drag-policy.js";
|
|
|
|
const entry = (id) => ({ link: { id } });
|
|
|
|
test("progressive routing keeps result order while revealing slices", () => {
|
|
const entries = [entry(1), entry(2), entry(3), entry(4)];
|
|
const batch = {
|
|
entries,
|
|
jobs: entries.map((e) => ({ entry: e })),
|
|
index: 0,
|
|
resultsById: new Map(),
|
|
};
|
|
const routeOne = (job) => ({ entry: job.entry, cached: { pts: [] } });
|
|
const first = processRouteSlice(batch, routeOne, { maxItems: 2, budgetMs: Infinity, now: () => 0 });
|
|
assert.deepEqual(first, { processed: 2, done: false, remaining: 2 });
|
|
assert.deepEqual(orderedRouteResults(entries, batch.resultsById).map((r) => r.entry.link.id), [1, 2]);
|
|
const second = processRouteSlice(batch, routeOne, { maxItems: 2, budgetMs: Infinity, now: () => 0 });
|
|
assert.equal(second.done, true);
|
|
assert.deepEqual(orderedRouteResults(entries, batch.resultsById).map((r) => r.entry.link.id), [1, 2, 3, 4]);
|
|
});
|
|
|
|
test("progressive policy leaves small and drag-settle passes synchronous", () => {
|
|
assert.equal(shouldProgressivelyRoute(false, false, 20), true);
|
|
assert.equal(shouldProgressivelyRoute(true, false, 20), false);
|
|
assert.equal(shouldProgressivelyRoute(false, true, 20), false);
|
|
assert.equal(shouldProgressivelyRoute(false, false, 15), false);
|
|
assert.equal(shouldProgressivelyRoute(false, false, 16), true);
|
|
assert.equal(progressiveItemLimit(573, 10), 58);
|
|
});
|
|
|
|
test("a route slice yields after its time budget", () => {
|
|
const entries = [entry(1), entry(2), entry(3), entry(4)];
|
|
const batch = {
|
|
entries,
|
|
jobs: entries.map((e) => ({ entry: e })),
|
|
index: 0,
|
|
resultsById: new Map(),
|
|
};
|
|
let time = 0;
|
|
const slice = processRouteSlice(
|
|
batch,
|
|
(job) => {
|
|
time += 7;
|
|
return { entry: job.entry, cached: { pts: [] } };
|
|
},
|
|
{ maxItems: 10, budgetMs: 12, now: () => time },
|
|
);
|
|
assert.deepEqual(slice, { processed: 2, done: false, remaining: 2 });
|
|
});
|
|
|
|
test("adaptiveBudgetMs clamps to [6, 14] and falls back without measures", () => {
|
|
// light draw → headroom hits the 14ms cap
|
|
assert.equal(
|
|
adaptiveBudgetMs({ frameIntervalMs: 16.6, drawMsP95: 0.5 }),
|
|
14,
|
|
);
|
|
// heavy draw → floor at 6ms (never starves to 0)
|
|
assert.equal(
|
|
adaptiveBudgetMs({ frameIntervalMs: 16.6, drawMsP95: 20 }),
|
|
6,
|
|
);
|
|
// mid value passes through
|
|
assert.ok(
|
|
Math.abs(adaptiveBudgetMs({ frameIntervalMs: 16.6, drawMsP95: 4 }) - 10.6) < 1e-9,
|
|
);
|
|
// invalid / missing measures → fixed fallback 12
|
|
assert.equal(adaptiveBudgetMs({ frameIntervalMs: NaN, drawMsP95: 2 }), 12);
|
|
assert.equal(adaptiveBudgetMs({ frameIntervalMs: 16.6, drawMsP95: NaN }), 12);
|
|
assert.equal(
|
|
adaptiveBudgetMs({ frameIntervalMs: 0, drawMsP95: 1, fallbackMs: 12 }),
|
|
12,
|
|
);
|
|
// never exceeds maxMs even on 30fps headroom
|
|
assert.equal(
|
|
adaptiveBudgetMs({ frameIntervalMs: 33, drawMsP95: 2, maxMs: 14 }),
|
|
14,
|
|
);
|
|
});
|
|
|
|
test("viewport job reorder does not change orderedRouteResults id sequence", () => {
|
|
const entries = [entry(1), entry(2), entry(3), entry(4)];
|
|
const viewport = { x: 0, y: 0, x2: 100, y2: 100 };
|
|
// Jobs intentionally listed off-screen first; after viewport order, on-screen
|
|
// jobs are processed first, but results still emit in entry order.
|
|
const jobsRaw = [
|
|
{ entry: entries[2], bounds: { x: 500, y: 0, x2: 520, y2: 20 } },
|
|
{ entry: entries[0], bounds: { x: 10, y: 10, x2: 30, y2: 30 } },
|
|
{ entry: entries[3], bounds: { x: 200, y: 0, x2: 220, y2: 20 } },
|
|
{ entry: entries[1], bounds: { x: 40, y: 40, x2: 60, y2: 60 } },
|
|
];
|
|
const orderedJobs = orderRouteCandidates(jobsRaw, viewport);
|
|
// Visible first (ids 1,2), then near off-screen (4), then far (3).
|
|
assert.deepEqual(
|
|
orderedJobs.map((j) => j.entry.link.id),
|
|
[1, 2, 4, 3],
|
|
);
|
|
const batch = {
|
|
entries,
|
|
jobs: orderedJobs,
|
|
index: 0,
|
|
resultsById: new Map(),
|
|
};
|
|
processRouteSlice(batch, (job) => ({ entry: job.entry, cached: { pts: [] } }), {
|
|
maxItems: 99,
|
|
budgetMs: Infinity,
|
|
now: () => 0,
|
|
});
|
|
// Final emit order follows entries, not job processing order.
|
|
assert.deepEqual(
|
|
orderedRouteResults(entries, batch.resultsById).map((r) => r.entry.link.id),
|
|
[1, 2, 3, 4],
|
|
);
|
|
});
|