mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +02:00
283 lines
9.7 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
});
|