All checks were successful
CD / Build and push images (push) Successful in 3m1s
CI / Lint, typecheck, test (push) Successful in 2m13s
CI / Auth e2e pack (push) Successful in 2m36s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
Enable the third sidebar sort mode — a freely defined order. - api: `PATCH /pages/:id/position` (before/after neighbour) recomputes only the moved page's fractional `sort_key`. Pure `sort-key.ts` helpers (`nextKeyOrRebalance`, `evenlySpacedKeys`) decide between the cheap single-key path and a full pond rebalance to evenly-spaced keys when a key would exceed MAX_SORT_KEY_LENGTH or the client's neighbours are stale; rebalance runs in one transaction. Order is server-authoritative. - web: enable 'manual' in the sort-mode switch; in manual mode the owner can reorder via native drag-and-drop (drop above/below by pointer half) or the keyboard (per-row up/down buttons), each announced through an aria-live region. Reordering is hidden while a label filter narrows the list. New pages already append at the end (create uses generateKeyBetween(last, null)). Pure `reorder.ts` neighbour helpers, unit-tested. - i18n: manual sort mode + reorder strings (de + en). - tests: sort-key property test (10.000 adversarial reorders never collide or overflow — rebalance verified); reposition db test (persist, server-order, sort-mode switch keeps manual order); reorder e2e pack (keyboard reorder persists across reload + identical on a fresh read; aria-live announced). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
74 lines
2.8 KiB
TypeScript
74 lines
2.8 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import { MAX_SORT_KEY_LENGTH, evenlySpacedKeys, nextKeyOrRebalance } from './sort-key';
|
|
|
|
describe('sort-key helpers (issue #45)', () => {
|
|
it('produces a key strictly between two neighbours', () => {
|
|
const key = nextKeyOrRebalance('a0', 'a1');
|
|
expect(key).not.toBeNull();
|
|
expect(key! > 'a0' && key! < 'a1').toBe(true);
|
|
});
|
|
|
|
it('signals rebalance (null) when neighbours are out of order', () => {
|
|
expect(nextKeyOrRebalance('a1', 'a0')).toBeNull();
|
|
expect(nextKeyOrRebalance('a0', 'a0')).toBeNull();
|
|
});
|
|
|
|
it('evenlySpacedKeys returns n sorted, unique keys', () => {
|
|
const keys = evenlySpacedKeys(100);
|
|
expect(keys).toHaveLength(100);
|
|
expect(new Set(keys).size).toBe(100);
|
|
expect([...keys].sort()).toEqual(keys);
|
|
});
|
|
|
|
/**
|
|
* Property test (acceptance criterion): 10.000 reorders in the adversarial
|
|
* pattern — repeatedly drop the last page between the first two — must never
|
|
* collide and never overflow the key length, because the caller rebalances
|
|
* when {@link nextKeyOrRebalance} returns null.
|
|
*/
|
|
it('10.000 adversarial reorders never collide or overflow (rebalance verified)', () => {
|
|
// Start with five pages in a fixed order.
|
|
let order = evenlySpacedKeys(5).map((key, i) => ({ id: `p${i}`, key }));
|
|
let rebalances = 0;
|
|
|
|
const rebalance = (): void => {
|
|
const keys = evenlySpacedKeys(order.length);
|
|
order = order.map((page, i) => ({ ...page, key: keys[i]! }));
|
|
rebalances += 1;
|
|
};
|
|
|
|
for (let i = 0; i < 10_000; i += 1) {
|
|
// Move the last page to sit between the first and second — the tightest
|
|
// possible gap, which is what grows key length fastest.
|
|
const moved = order[order.length - 1]!;
|
|
const rest = order.slice(0, -1);
|
|
const afterKey = rest[0]!.key;
|
|
const beforeKey = rest[1]!.key;
|
|
|
|
const key = nextKeyOrRebalance(afterKey, beforeKey);
|
|
if (key === null) {
|
|
// Rebalance keeps the CURRENT order, then retry the move once.
|
|
rebalance();
|
|
const k2 = nextKeyOrRebalance(order[0]!.key, order[1]!.key);
|
|
expect(k2).not.toBeNull();
|
|
order = [order[0]!, { ...moved, key: k2! }, ...order.slice(1)];
|
|
} else {
|
|
order = [rest[0]!, { ...moved, key }, ...rest.slice(1)];
|
|
}
|
|
|
|
// Invariants after every move: keys unique, bounded, and consistent with
|
|
// the intended array order.
|
|
const keys = order.map((p) => p.key);
|
|
expect(new Set(keys).size).toBe(keys.length);
|
|
expect(Math.max(...keys.map((k) => k.length))).toBeLessThanOrEqual(MAX_SORT_KEY_LENGTH);
|
|
for (let j = 1; j < keys.length; j += 1) {
|
|
expect(keys[j - 1]! < keys[j]!).toBe(true);
|
|
}
|
|
}
|
|
|
|
// The adversarial pattern must have forced at least one rebalance.
|
|
expect(rebalances).toBeGreaterThan(0);
|
|
});
|
|
});
|