ktx/packages/cli/src/commands/connection-notion-tree.test.ts
2026-05-10 23:12:26 +02:00

283 lines
9.7 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
buildInitialState,
buildPickerTree,
canToggle,
clearExpiredTransientHint,
filterTree,
flattenSelection,
moveCursor,
reducer,
selectAllVisible,
selectNone,
toggleChecked,
TRANSIENT_HINT_DURATION_MS,
visibleNodeIds,
type NotionPickerPageInput,
} from './connection-notion-tree.js';
const IDS = {
engineering: '11111111-1111-1111-1111-111111111111',
architecture: '22222222-2222-2222-2222-222222222222',
onboarding: '33333333-3333-3333-3333-333333333333',
marketing: '44444444-4444-4444-4444-444444444444',
journal: '55555555-5555-5555-5555-555555555555',
orphan: '66666666-6666-6666-6666-666666666666',
duplicate: '77777777-7777-7777-7777-777777777777',
cycleA: '88888888-8888-8888-8888-888888888888',
cycleB: '99999999-9999-9999-9999-999999999999',
};
function pages(): NotionPickerPageInput[] {
return [
{ id: IDS.marketing, title: 'Marketing', archived: false, parentId: null },
{ id: IDS.onboarding, title: 'Onboarding', archived: false, parentId: IDS.engineering },
{ id: IDS.engineering, title: 'Engineering Docs', archived: false, parentId: null },
{ id: IDS.architecture, title: 'Architecture', archived: false, parentId: IDS.engineering },
{ id: IDS.journal, title: 'Daily journal', archived: true, parentId: IDS.marketing },
{ id: IDS.orphan, title: '', archived: false, parentId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' },
{ id: IDS.duplicate, title: 'Original duplicate', archived: false, parentId: null },
{ id: IDS.duplicate, title: 'Ignored duplicate', archived: true, parentId: IDS.marketing },
{ id: IDS.cycleA, title: 'Cycle A', archived: false, parentId: IDS.cycleB },
{ id: IDS.cycleB, title: 'Cycle B', archived: false, parentId: IDS.cycleA },
];
}
describe('buildPickerTree', () => {
it('deduplicates pages, sorts siblings, preserves archived flags, roots orphans, and breaks cycles', () => {
const tree = buildPickerTree(pages());
const byId = new Map(tree.map((node) => [node.id, node]));
expect(tree.map((node) => node.title)).toEqual([
'Cycle A',
'Cycle B',
'Engineering Docs',
'Architecture',
'Onboarding',
'Marketing',
'Daily journal',
'Original duplicate',
'Untitled',
]);
expect(byId.get(IDS.engineering)?.childIds).toEqual([IDS.architecture, IDS.onboarding]);
expect(byId.get(IDS.architecture)).toMatchObject({
depth: 1,
parentId: IDS.engineering,
path: 'Engineering Docs / Architecture',
});
expect(byId.get(IDS.journal)).toMatchObject({
archived: true,
depth: 1,
path: 'Marketing / Daily journal',
});
expect(byId.get(IDS.orphan)).toMatchObject({
title: 'Untitled',
parentId: null,
depth: 0,
path: 'Untitled',
});
expect(byId.get(IDS.duplicate)).toMatchObject({
title: 'Original duplicate',
archived: false,
parentId: null,
});
expect(byId.get(IDS.cycleA)?.parentId).toBeNull();
expect(byId.get(IDS.cycleB)?.parentId).toBe(IDS.cycleA);
});
});
describe('selection invariants', () => {
it('checking a parent locks descendants and keeps checked ids minimal', () => {
const state = buildInitialState({
tree: buildPickerTree(pages()),
existingRootPageIds: [],
currentCrawlMode: 'selected_roots',
});
const checkedParent = toggleChecked(state, IDS.engineering, 1000);
expect([...checkedParent.checked]).toEqual([IDS.engineering]);
expect(canToggle(IDS.architecture, checkedParent)).toEqual({
ok: false,
reason: "Locked by 'Engineering Docs' - uncheck parent first",
});
const lockedChildAttempt = toggleChecked(checkedParent, IDS.architecture, 2000);
expect([...lockedChildAttempt.checked]).toEqual([IDS.engineering]);
expect(lockedChildAttempt.transientHint).toEqual({
text: "Locked by 'Engineering Docs' - uncheck parent first",
expiresAt: 4500,
});
const uncheckedParent = toggleChecked(lockedChildAttempt, IDS.engineering, 3000);
expect([...uncheckedParent.checked]).toEqual([]);
expect(canToggle(IDS.architecture, uncheckedParent)).toEqual({ ok: true });
});
it('normalizes stored roots, reports stale roots, expands checked ancestors, and flattens descendants', () => {
const state = buildInitialState({
tree: buildPickerTree(pages()),
existingRootPageIds: [
IDS.engineering.replaceAll('-', ''),
IDS.architecture,
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
],
currentCrawlMode: 'selected_roots',
});
expect([...state.checked]).toEqual([IDS.engineering]);
expect([...state.expanded]).toEqual([]);
expect(state.cursorId).toBe(IDS.cycleA);
expect(state.preLoadWarnings).toEqual(['1 stored root_page_ids no longer visible']);
expect(flattenSelection(new Set([IDS.engineering, IDS.architecture]), state.byId)).toEqual([IDS.engineering]);
});
});
describe('search and cursor movement', () => {
it('filters by title and path while deriving auto-expanded ancestors', () => {
const state = buildInitialState({
tree: buildPickerTree(pages()),
existingRootPageIds: [],
currentCrawlMode: 'selected_roots',
});
const searching = {
...state,
search: { editing: false, query: 'architecture' },
};
expect(filterTree(searching)).toEqual({
visibleIds: new Set([IDS.engineering, IDS.architecture]),
autoExpand: new Set([IDS.engineering]),
});
expect(visibleNodeIds(searching)).toEqual([IDS.engineering, IDS.architecture]);
});
it('moves the cursor through visible nodes and implements left/right tree semantics', () => {
const state = buildInitialState({
tree: buildPickerTree(pages()),
existingRootPageIds: [],
currentCrawlMode: 'selected_roots',
});
const atEngineering = {
...state,
cursorId: IDS.engineering,
expanded: new Set([IDS.engineering]),
};
expect(moveCursor(atEngineering, 'down').cursorId).toBe(IDS.architecture);
expect(moveCursor({ ...atEngineering, cursorId: IDS.architecture }, 'up').cursorId).toBe(IDS.engineering);
expect(moveCursor(atEngineering, 'right').cursorId).toBe(IDS.architecture);
expect(moveCursor({ ...atEngineering, cursorId: IDS.architecture }, 'left').cursorId).toBe(IDS.engineering);
expect([...moveCursor(atEngineering, 'left').expanded]).toEqual([]);
expect([...moveCursor({ ...state, cursorId: IDS.marketing }, 'right').expanded]).toContain(IDS.marketing);
});
});
describe('bulk actions and reducer effects', () => {
it('selects only matching visible roots under search and clears selection', () => {
const state = buildInitialState({
tree: buildPickerTree(pages()),
existingRootPageIds: [IDS.marketing],
currentCrawlMode: 'selected_roots',
});
const searching = {
...state,
search: { editing: false, query: 'architecture' },
};
const selected = selectAllVisible(searching);
expect(flattenSelection(selected.checked, selected.byId)).toEqual([IDS.architecture, IDS.marketing]);
expect([...selectNone(selected).checked]).toEqual([]);
});
it('returns save immediately for selected_roots and requires confirmation for all_accessible', () => {
const selectedRoots = toggleChecked(
buildInitialState({
tree: buildPickerTree(pages()),
existingRootPageIds: [],
currentCrawlMode: 'selected_roots',
}),
IDS.marketing,
1000,
);
expect(reducer(selectedRoots, 'save-request')).toEqual({
next: selectedRoots,
effect: 'save',
});
const allAccessible = {
...selectedRoots,
currentCrawlMode: 'all_accessible' as const,
};
const confirm = reducer(allAccessible, 'save-request');
expect(confirm).toEqual({
next: { ...allAccessible, pendingConfirm: 'mode-switch' },
effect: null,
});
expect(reducer(confirm.next, 'save-cancel')).toEqual({
next: { ...allAccessible, pendingConfirm: null },
effect: null,
});
expect(reducer(confirm.next, 'save-confirm')).toEqual({
next: { ...allAccessible, pendingConfirm: null },
effect: 'save',
});
});
it('blocks empty saves, updates search state, and quits without saving', () => {
const state = buildInitialState({
tree: buildPickerTree(pages()),
existingRootPageIds: [],
currentCrawlMode: 'selected_roots',
});
const blockedSave = reducer(state, 'save-request', 9000);
expect(blockedSave).toEqual({
next: {
...state,
transientHint: {
text: 'Select at least one page or press q to quit',
expiresAt: 9000 + TRANSIENT_HINT_DURATION_MS,
},
},
effect: null,
});
expect(
reducer(
reducer(reducer(state, 'search-start').next, { type: 'search-input', value: 'a' }).next,
'search-submit',
).next.search,
).toEqual({ editing: false, query: 'a' });
expect(reducer(state, 'quit')).toEqual({
next: state,
effect: 'quit-without-save',
});
});
it('clears transient hints only when their expiry time has passed', () => {
const state = buildInitialState({
tree: buildPickerTree(pages()),
existingRootPageIds: [],
currentCrawlMode: 'selected_roots',
});
const withHint = {
...state,
transientHint: {
text: 'Select at least one page or press q to quit',
expiresAt: 11500,
},
};
expect(clearExpiredTransientHint(withHint, 11499)).toBe(withHint);
expect(clearExpiredTransientHint(withHint, 11500)).toEqual({
...withHint,
transientHint: null,
});
expect(reducer(withHint, 'clear-transient-hint', 11501)).toEqual({
next: {
...withHint,
transientHint: null,
},
effect: null,
});
});
});