251 lines
8.9 KiB
TypeScript
251 lines
8.9 KiB
TypeScript
|
|
import { describe, expect, it } from 'vitest';
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Benchmark gates for the round-11 SPA items (see benches/ROUND11.md):
|
|||
|
|
*
|
|||
|
|
* [1] `ResourceList.selectedEntries` re-filtered the ENTIRE items array on
|
|||
|
|
* every selection change once the batch toolbar was mounted — and the
|
|||
|
|
* favorites/recent hosts ignored the snippet param and recomputed their
|
|||
|
|
* own `entries.filter(...)` shadow, so each toggle ran TWO full O(N)
|
|||
|
|
* scans (O(N²)-ish across a shift-range gesture). The shipped shape
|
|||
|
|
* derives an id→index Map (rebuilt only when `items` changes) and
|
|||
|
|
* projects the selection in O(k · log k), preserving item order; hosts
|
|||
|
|
* now consume the snippet param.
|
|||
|
|
*
|
|||
|
|
* [2] The Recent page mapper baked `favoriteIds.has(id)` into every entry,
|
|||
|
|
* subscribing the whole O(N) map to the SvelteSet — one star click
|
|||
|
|
* rebuilt all N entries and re-rendered every visible row. The shipped
|
|||
|
|
* shape reads membership in the star widget via ResourceList's new
|
|||
|
|
* `favoriteIds` prop, so the mapper no longer depends on the set.
|
|||
|
|
*
|
|||
|
|
* [3] The admin "time ago" >30-day fallback called `toLocaleDateString()`
|
|||
|
|
* (a fresh Intl.DateTimeFormat per call) instead of the cached
|
|||
|
|
* `dateTimeFormatFor` the rest of the app uses.
|
|||
|
|
*
|
|||
|
|
* All modeled as pure replicas of the derive bodies with instrumentation
|
|||
|
|
* counters (the listDerives.bench.test.ts convention).
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
interface Entry {
|
|||
|
|
id: string;
|
|||
|
|
name: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const buildItems = (n: number): Entry[] =>
|
|||
|
|
Array.from({ length: n }, (_, i) => ({ id: `it-${i}`, name: `Item ${i}` }));
|
|||
|
|
|
|||
|
|
/** BEFORE — component derive + host shadow, each a full O(N) scan. */
|
|||
|
|
function selectedBefore(
|
|||
|
|
items: Entry[],
|
|||
|
|
selected: Set<string>,
|
|||
|
|
counter: { comparisons: number }
|
|||
|
|
): { component: Entry[]; host: Entry[] } {
|
|||
|
|
const component = items.filter((i) => {
|
|||
|
|
counter.comparisons++;
|
|||
|
|
return selected.has(i.id);
|
|||
|
|
});
|
|||
|
|
const host = items.filter((i) => {
|
|||
|
|
counter.comparisons++;
|
|||
|
|
return selected.has(i.id);
|
|||
|
|
});
|
|||
|
|
return { component, host };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** AFTER — id→index Map projection, index rebuilt only on items change. */
|
|||
|
|
function makeAfterProjector(items: Entry[]) {
|
|||
|
|
const indexById = new Map(items.map((i, idx) => [i.id, idx]));
|
|||
|
|
return (selected: Set<string>, counter: { comparisons: number }): Entry[] => {
|
|||
|
|
const picked: { idx: number; item: Entry }[] = [];
|
|||
|
|
for (const id of selected) {
|
|||
|
|
counter.comparisons++;
|
|||
|
|
const idx = indexById.get(id);
|
|||
|
|
if (idx !== undefined) picked.push({ idx, item: items[idx] });
|
|||
|
|
}
|
|||
|
|
picked.sort((a, b) => a.idx - b.idx);
|
|||
|
|
return picked.map((p) => p.item);
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
describe('ResourceList selectedEntries projection (benchmark gate)', () => {
|
|||
|
|
it('identical output (order + membership) and O(k) vs O(2N) comparisons per toggle', () => {
|
|||
|
|
const N = 2000;
|
|||
|
|
const items = buildItems(N);
|
|||
|
|
const project = makeAfterProjector(items);
|
|||
|
|
|
|||
|
|
// Model a 50-item shift-range selection built one id at a time,
|
|||
|
|
// re-deriving after each toggle (what the reactive graph does).
|
|||
|
|
const selected = new Set<string>();
|
|||
|
|
const beforeCounter = { comparisons: 0 };
|
|||
|
|
const afterCounter = { comparisons: 0 };
|
|||
|
|
// Insert in REVERSE order so selection order ≠ item order — the
|
|||
|
|
// order-preservation gate below must still hold. Each toggle
|
|||
|
|
// re-derives both shapes (what the reactive graph does).
|
|||
|
|
for (let i = 149; i >= 100; i--) {
|
|||
|
|
selected.add(`it-${i}`);
|
|||
|
|
selectedBefore(items, selected, beforeCounter);
|
|||
|
|
project(selected, afterCounter);
|
|||
|
|
}
|
|||
|
|
// Stale ids (deleted rows) must be dropped by both shapes.
|
|||
|
|
selected.add('it-ghost');
|
|||
|
|
const lastBefore = selectedBefore(items, selected, beforeCounter).component;
|
|||
|
|
const lastAfter = project(selected, afterCounter);
|
|||
|
|
|
|||
|
|
expect(lastAfter).toEqual(lastBefore); // same entries, same (item) order
|
|||
|
|
// BEFORE: 2 scans × N per toggle. AFTER: k probes per toggle.
|
|||
|
|
expect(beforeCounter.comparisons).toBe(51 * 2 * N);
|
|||
|
|
expect(afterCounter.comparisons).toBeLessThan(51 * 51 + 1);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('wall clock: 500-toggle sweep on a 5k list is faster with the projection', () => {
|
|||
|
|
const N = 5000;
|
|||
|
|
const items = buildItems(N);
|
|||
|
|
const project = makeAfterProjector(items);
|
|||
|
|
const selected = new Set<string>();
|
|||
|
|
const nul = { comparisons: 0 };
|
|||
|
|
|
|||
|
|
const t0 = performance.now();
|
|||
|
|
for (let i = 0; i < 500; i++) {
|
|||
|
|
selected.add(`it-${i}`);
|
|||
|
|
selectedBefore(items, selected, nul);
|
|||
|
|
}
|
|||
|
|
const tBefore = performance.now() - t0;
|
|||
|
|
|
|||
|
|
selected.clear();
|
|||
|
|
const t1 = performance.now();
|
|||
|
|
for (let i = 0; i < 500; i++) {
|
|||
|
|
selected.add(`it-${i}`);
|
|||
|
|
project(selected, nul);
|
|||
|
|
}
|
|||
|
|
const tAfter = performance.now() - t1;
|
|||
|
|
|
|||
|
|
// Generous bound to keep CI stable; locally ~10-40x.
|
|||
|
|
expect(tAfter).toBeLessThan(tBefore);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ─── [2] Recent favorite-star dependency ────────────────────────────────────
|
|||
|
|
|
|||
|
|
interface RawItem {
|
|||
|
|
id: string;
|
|||
|
|
name: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** BEFORE — mapper reads the favorite set: every toggle re-maps ALL rows. */
|
|||
|
|
function entriesBefore(
|
|||
|
|
raw: RawItem[],
|
|||
|
|
favoriteIds: Set<string>,
|
|||
|
|
counter: { mapperRows: number }
|
|||
|
|
): { id: string; isFavorite: boolean }[] {
|
|||
|
|
return raw.map((it) => {
|
|||
|
|
counter.mapperRows++;
|
|||
|
|
return { id: it.id, isFavorite: favoriteIds.has(it.id) };
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** AFTER — mapper is set-independent; the star widget reads membership. */
|
|||
|
|
function entriesAfter(raw: RawItem[], counter: { mapperRows: number }): { id: string }[] {
|
|||
|
|
return raw.map((it) => {
|
|||
|
|
counter.mapperRows++;
|
|||
|
|
return { id: it.id };
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
function starStateAfter(favoriteIds: Set<string>, id: string): boolean {
|
|||
|
|
return favoriteIds.has(id);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
describe('Recent favorite-star fine-grained dependency (benchmark gate)', () => {
|
|||
|
|
it('a star toggle re-maps 0 rows (was N) and renders the same star states', () => {
|
|||
|
|
const N = 400;
|
|||
|
|
const raw: RawItem[] = Array.from({ length: N }, (_, i) => ({
|
|||
|
|
id: `r-${i}`,
|
|||
|
|
name: `File ${i}`
|
|||
|
|
}));
|
|||
|
|
const favoriteIds = new Set<string>(['r-3']);
|
|||
|
|
|
|||
|
|
const beforeCounter = { mapperRows: 0 };
|
|||
|
|
const afterCounter = { mapperRows: 0 };
|
|||
|
|
|
|||
|
|
// Initial render: both shapes map all rows once.
|
|||
|
|
let entriesB = entriesBefore(raw, favoriteIds, beforeCounter);
|
|||
|
|
const entriesA = entriesAfter(raw, afterCounter);
|
|||
|
|
expect(beforeCounter.mapperRows).toBe(N);
|
|||
|
|
expect(afterCounter.mapperRows).toBe(N);
|
|||
|
|
|
|||
|
|
// 10 star toggles. BEFORE: the mapper depends on the set → full
|
|||
|
|
// re-map each time. AFTER: the mapper doesn't run at all.
|
|||
|
|
for (let k = 0; k < 10; k++) {
|
|||
|
|
const id = `r-${k * 7}`;
|
|||
|
|
if (favoriteIds.has(id)) favoriteIds.delete(id);
|
|||
|
|
else favoriteIds.add(id);
|
|||
|
|
entriesB = entriesBefore(raw, favoriteIds, beforeCounter); // reactive re-run
|
|||
|
|
// AFTER: no mapper re-run; only the affected star re-reads.
|
|||
|
|
starStateAfter(favoriteIds, id);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
expect(beforeCounter.mapperRows).toBe(N + 10 * N);
|
|||
|
|
expect(afterCounter.mapperRows).toBe(N); // unchanged since initial render
|
|||
|
|
|
|||
|
|
// Gate: identical star state for every row under the AFTER shape.
|
|||
|
|
for (let i = 0; i < N; i++) {
|
|||
|
|
expect(starStateAfter(favoriteIds, entriesA[i].id)).toBe(entriesB[i].isFavorite);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ─── [3] admin timeAgo date fallback ────────────────────────────────────────
|
|||
|
|
|
|||
|
|
describe('admin timeAgo >30d fallback formatter cache (benchmark gate)', () => {
|
|||
|
|
it('cached formatter output is identical to toLocaleDateString()', async () => {
|
|||
|
|
const { dateTimeFormatFor } = await import('../utils/display');
|
|||
|
|
const dates = [
|
|||
|
|
new Date('2025-01-15T10:30:00Z'),
|
|||
|
|
new Date('2024-12-31T23:59:59Z'),
|
|||
|
|
new Date('2020-06-01T00:00:00Z'),
|
|||
|
|
new Date('1999-02-28T12:00:00Z')
|
|||
|
|
];
|
|||
|
|
for (const d of dates) {
|
|||
|
|
expect(dateTimeFormatFor(undefined).format(d)).toBe(d.toLocaleDateString());
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('1000 formats construct ≤1 Intl.DateTimeFormat (was 1000)', async () => {
|
|||
|
|
const { dateTimeFormatFor } = await import('../utils/display');
|
|||
|
|
const RealDTF = Intl.DateTimeFormat;
|
|||
|
|
let constructed = 0;
|
|||
|
|
// Count constructions through both paths.
|
|||
|
|
const Counting = new Proxy(RealDTF, {
|
|||
|
|
construct(target, args: [string?, Intl.DateTimeFormatOptions?]) {
|
|||
|
|
constructed++;
|
|||
|
|
return new target(...args);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|||
|
|
(Intl as any).DateTimeFormat = Counting;
|
|||
|
|
try {
|
|||
|
|
const d = new Date('2020-06-01T00:00:00Z');
|
|||
|
|
constructed = 0;
|
|||
|
|
for (let i = 0; i < 1000; i++) {
|
|||
|
|
d.toLocaleDateString();
|
|||
|
|
}
|
|||
|
|
// jsdom implements toLocaleDateString via Intl internally in some
|
|||
|
|
// versions; count only if observable. The load-bearing assertion
|
|||
|
|
// is the cached path below.
|
|||
|
|
const beforeConstructed = constructed;
|
|||
|
|
|
|||
|
|
constructed = 0;
|
|||
|
|
for (let i = 0; i < 1000; i++) {
|
|||
|
|
dateTimeFormatFor(undefined).format(d);
|
|||
|
|
}
|
|||
|
|
expect(constructed).toBeLessThanOrEqual(1);
|
|||
|
|
// When the environment exposes per-call constructions, require
|
|||
|
|
// the cached path to be strictly cheaper.
|
|||
|
|
if (beforeConstructed > 1) {
|
|||
|
|
expect(constructed).toBeLessThan(beforeConstructed);
|
|||
|
|
}
|
|||
|
|
} finally {
|
|||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|||
|
|
(Intl as any).DateTimeFormat = RealDTF;
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|