All checks were successful
CD / Build and push images (push) Successful in 2m53s
CI / Lint, typecheck, test (push) Successful in 2m8s
CI / Auth e2e pack (push) Successful in 2m31s
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
Build the M4 label experience on top of the #43 label API. - shared: `flattenLabelTree` (tree → depth-first list) for chip lookup, filtering, and the picker; `PageListItemView` adds each page's `labelIds` to the sidebar list response. - api: `GET /ponds/:id/pages` now includes `labelIds` per page (one grouped query), so the sidebar can render chips and filter without extra calls. - web: - Pond settings page (`/p/:pondSlug/settings`) with a `LabelManager` tree: inline create, rename, recolour (`<input type=color>`), move via a parent picker that excludes the label's own subtree, and delete that confirms then force-detaches assigned pages. Every control is a native button/input/select — the tree is fully keyboard-operable. - `LabelPicker` panel on the page editor: searchable, hierarchy-indented multi-select that assigns/unassigns immediately and refreshes the page's labels and the sidebar. - Sidebar: colored label chips on page entries (readable text via a luminance-based contrast helper) and a descendant-inclusive label filter (selecting a parent matches pages tagged with its children, via the shared `collectSubtreeIds`). Owner link to pond settings. - i18n `labels` namespace (de + en). - e2e `labels.spec.ts` (new CI pack): full lifecycle from the settings UI and picker-assign + parent-filter-includes-child. Selectors are language-independent because the UI language follows the user's locale. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
114 lines
3.6 KiB
TypeScript
114 lines
3.6 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
LabelView,
|
|
buildLabelTree,
|
|
collectAncestorIds,
|
|
collectSubtreeIds,
|
|
flattenLabelTree,
|
|
labelDepth,
|
|
subtreeHeight,
|
|
} from './labels';
|
|
|
|
/** Minimal label row; timestamps are irrelevant to the tree helpers. */
|
|
function label(id: string, parentId: string | null, name = id): LabelView {
|
|
return {
|
|
id,
|
|
pondId: 'pond',
|
|
parentId,
|
|
name,
|
|
color: '#64748b',
|
|
createdAt: '2026-01-01T00:00:00.000Z',
|
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Tree used across the cases:
|
|
* a z
|
|
* ├─ b └─ y
|
|
* │ └─ d
|
|
* └─ c
|
|
*/
|
|
const tree: LabelView[] = [
|
|
label('a', null, 'Alpha'),
|
|
label('b', 'a', 'Bravo'),
|
|
label('c', 'a', 'Charlie'),
|
|
label('d', 'b', 'Delta'),
|
|
label('z', null, 'Zulu'),
|
|
label('y', 'z', 'Yankee'),
|
|
];
|
|
|
|
describe('buildLabelTree (issue #43)', () => {
|
|
it('nests children under parents and sorts siblings and roots by name', () => {
|
|
const roots = buildLabelTree(tree);
|
|
expect(roots.map((r) => r.id)).toEqual(['a', 'z']); // Alpha before Zulu
|
|
|
|
const a = roots.find((r) => r.id === 'a')!;
|
|
expect(a.children.map((c) => c.id)).toEqual(['b', 'c']); // Bravo before Charlie
|
|
const b = a.children.find((c) => c.id === 'b')!;
|
|
expect(b.children.map((c) => c.id)).toEqual(['d']);
|
|
});
|
|
|
|
it('treats an orphaned parent reference as a root instead of dropping the row', () => {
|
|
const roots = buildLabelTree([label('x', 'missing')]);
|
|
expect(roots.map((r) => r.id)).toEqual(['x']);
|
|
});
|
|
});
|
|
|
|
describe('flattenLabelTree (issue #44)', () => {
|
|
it('round-trips buildLabelTree back into a depth-first list', () => {
|
|
const flat = flattenLabelTree(buildLabelTree(tree));
|
|
// Depth-first, siblings by name: a, b, d, c, z, y.
|
|
expect(flat.map((l) => l.id)).toEqual(['a', 'b', 'd', 'c', 'z', 'y']);
|
|
// Views carry no `children` field.
|
|
expect(flat.every((l) => !('children' in l))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('collectSubtreeIds (issue #43)', () => {
|
|
it('returns the label and all its descendants', () => {
|
|
expect(collectSubtreeIds(tree, 'a')).toEqual(new Set(['a', 'b', 'c', 'd']));
|
|
expect(collectSubtreeIds(tree, 'b')).toEqual(new Set(['b', 'd']));
|
|
expect(collectSubtreeIds(tree, 'd')).toEqual(new Set(['d']));
|
|
});
|
|
|
|
it('detects that a new parent inside the subtree would form a cycle', () => {
|
|
// Moving 'a' under 'd' is illegal: 'd' is in a's subtree.
|
|
expect(collectSubtreeIds(tree, 'a').has('d')).toBe(true);
|
|
// Moving 'c' under 'z' is fine: 'z' is not in c's subtree.
|
|
expect(collectSubtreeIds(tree, 'c').has('z')).toBe(false);
|
|
});
|
|
|
|
it('terminates on a malformed cycle in the input', () => {
|
|
const cyclic = [label('p', 'q'), label('q', 'p')];
|
|
expect(collectSubtreeIds(cyclic, 'p')).toEqual(new Set(['p', 'q']));
|
|
});
|
|
});
|
|
|
|
describe('collectAncestorIds + labelDepth (issue #43)', () => {
|
|
it('walks ancestors nearest-first', () => {
|
|
expect(collectAncestorIds(tree, 'd')).toEqual(['b', 'a']);
|
|
expect(collectAncestorIds(tree, 'a')).toEqual([]);
|
|
});
|
|
|
|
it('reports depth as a 1-based level', () => {
|
|
expect(labelDepth(tree, 'a')).toBe(1);
|
|
expect(labelDepth(tree, 'b')).toBe(2);
|
|
expect(labelDepth(tree, 'd')).toBe(3);
|
|
});
|
|
|
|
it('terminates on a malformed cycle', () => {
|
|
const cyclic = [label('p', 'q'), label('q', 'p')];
|
|
expect(collectAncestorIds(cyclic, 'p')).toEqual(['q']);
|
|
});
|
|
});
|
|
|
|
describe('subtreeHeight (issue #43)', () => {
|
|
it('measures the deepest branch in levels', () => {
|
|
expect(subtreeHeight(tree, 'a')).toBe(3); // a → b → d
|
|
expect(subtreeHeight(tree, 'b')).toBe(2); // b → d
|
|
expect(subtreeHeight(tree, 'c')).toBe(1); // leaf
|
|
});
|
|
});
|