dorfteich/packages/shared/src/labels.test.ts
Claude Opus 4.8 03e72242d3
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
Add label UI: tree management, page assignment, and sidebar filter (#44)
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
2026-07-09 11:41:42 +02:00

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
});
});