Files
xming521 ad288b7bd7 Refactor profile web UI into atlas/review modules and improve graph layout
- Split the frontend into atlas/ and review/ modules with a shared api client,
  and add Prettier, ESLint and node tests for graph layout and data helpers
- Lay out expanded dimensions as separate clusters around the root
- Let the force simulation cool slowly after a node drag instead of stopping abruptly
- Reopening the collapsed root resets the map to its initial layout
- Use one loading screen style for the page and canvas loading states
- Clear stale errors when retrying fact history
- Rebuild weclone/web/dist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:09:16 +08:00

296 lines
12 KiB
TypeScript

import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
buildModel,
chineseNumeral,
collectFacts,
dimensionColor,
exportProfile,
filterProfile,
levelIsExpanded,
levelOf,
reconcileNavigation,
retainedSelection,
toggleLevelExpansion,
withinLevel,
} from '../src/data.ts';
import type { ProfileInput } from '../src/data.ts';
const input: ProfileInput = {
dimensions: [
{
dim: 8,
name: '兴趣',
groups: [
{ id: 'topic-a', name: '运动', items: [{ id: 'attr-a', attr: '偏好', fact_indices: [0, 1] }] },
{ id: 'topic-b', name: '阅读', items: [{ id: 'attr-b', attr: '书籍', fact_indices: [2] }] },
],
},
],
facts: ['pending', 'approved', 'rejected'].map((status, i) => ({
id: `fact-${i}`,
node_id: i === 2 ? 'attr-b' : 'attr-a',
group_id: i === 2 ? 'topic-b' : 'topic-a',
dim: 8,
attr: i === 2 ? '书籍' : '偏好',
value: `内容${i}`,
status: status as 'pending' | 'approved' | 'rejected',
source_ids: [],
origin: 'extracted',
version: 1,
original: { attr: '偏好', value: `内容${i}`, source_ids: [] },
})),
sources: {},
locations: [],
snapshot_sha256: 'fixture',
};
test('status filtering prunes branches and remaps fact indices without changing node identity', () => {
const model = buildModel(filterProfile(input, 'approved'));
assert.deepEqual(model.attributeIds, ['attr-a']);
assert.deepEqual(model.nodes.get('attr-a')!.factIndices, [0]);
assert.deepEqual(
collectFacts(model, 'root').map((fact) => fact.id),
['fact-1'],
);
assert.equal(model.nodes.has('topic-b'), false);
assert.equal(model.nodes.get('root')!.attributeCount, 1);
assert.equal(input.facts.length, 3);
assert.deepEqual(input.dimensions[0].groups[0].items[0], { id: 'attr-a', attr: '偏好', fact_indices: [0, 1] });
});
test('all view retains independently reviewed facts under the same attribute', () => {
const model = buildModel(filterProfile(input, 'all'));
assert.deepEqual(
collectFacts(model, 'attr-a').map((fact) => fact.status),
['pending', 'approved'],
);
assert.equal(model.nodes.get('root')!.attributeCount, 2);
assert.equal(collectFacts(model, 'root').length, 3);
});
test('export carries each fact review status and the approved view exports only approved facts', () => {
const all = exportProfile(input, 'all');
assert.deepEqual(
all.facts.map((fact) => [fact.id, fact.status]),
[
['fact-0', 'pending'],
['fact-1', 'approved'],
['fact-2', 'rejected'],
],
);
const approved = exportProfile(input, 'approved');
assert.deepEqual(
approved.facts.map((fact) => [fact.id, fact.status]),
[['fact-1', 'approved']],
);
});
test('empty review view yields a valid root without empty dimensions or stale indices', () => {
const model = buildModel(
filterProfile({ ...input, facts: input.facts.map((fact) => ({ ...fact, status: 'pending' })) }, 'approved'),
);
assert.deepEqual(model.dimensionIds, []);
assert.deepEqual(collectFacts(model, 'root'), []);
assert.equal(model.nodes.size, 1);
});
test('review removal returns to the nearest surviving ancestor without selecting another branch', () => {
const previous = buildModel(input);
const next = buildModel(filterProfile(input, 'approved'));
assert.equal(retainedSelection(previous, next, 'attr-a'), 'attr-a');
assert.equal(retainedSelection(previous, next, 'attr-b'), 'dimension:8');
assert.equal(retainedSelection(previous, next, 'topic-b'), 'dimension:8');
assert.equal(retainedSelection(previous, next, null), null);
const withoutAttribute = { ...next, nodes: new Map(next.nodes) };
withoutAttribute.nodes.delete('attr-a');
assert.equal(retainedSelection(next, withoutAttribute, 'attr-a'), 'topic-a');
const empty = buildModel({ ...input, dimensions: [], facts: [] });
assert.equal(retainedSelection(previous, empty, 'attr-b'), 'root');
});
function reconcileFixture(): ProfileInput {
const fact = (i: number, status: 'pending' | 'approved', dim: number, node: string, group: string) => ({
id: `fact-${i}`,
node_id: node,
group_id: group,
dim,
attr: node,
value: `内容${i}`,
status,
source_ids: [],
origin: 'extracted' as const,
version: 1,
original: { attr: node, value: `内容${i}`, source_ids: [] },
});
return {
dimensions: [
{
dim: 8,
name: '兴趣',
groups: [{ id: 'topic-a', name: '运动', items: [{ id: 'attr-a', attr: '偏好', fact_indices: [0] }] }],
},
{
dim: 3,
name: '目标',
groups: [{ id: 'topic-c', name: '学习', items: [{ id: 'attr-c', attr: '计划', fact_indices: [1] }] }],
},
],
facts: [fact(0, 'pending', 8, 'attr-a', 'topic-a'), fact(1, 'approved', 3, 'attr-c', 'topic-c')],
sources: {},
locations: [],
snapshot_sha256: 'fixture',
};
}
test('navigation drops expanded nodes that no longer exist and keeps the rest', () => {
const full = buildModel(reconcileFixture());
const pendingOnly = buildModel(filterProfile(reconcileFixture(), 'pending'));
const expandedIds = new Set(['root', 'dimension:8', 'dimension:3', 'topic-c', 'topic-a']);
const next = reconcileNavigation(full, pendingOnly, { expandedIds, selectedId: 'root' });
assert.deepEqual([...next.expandedIds].sort(), ['dimension:8', 'root', 'topic-a']);
assert.equal(expandedIds.size, 5);
});
test('navigation expands dimensions that newly appear and always keeps root expanded', () => {
const full = buildModel(reconcileFixture());
const pendingOnly = buildModel(filterProfile(reconcileFixture(), 'pending'));
const next = reconcileNavigation(pendingOnly, full, { expandedIds: new Set(['dimension:8']), selectedId: null });
assert.deepEqual([...next.expandedIds].sort(), ['dimension:3', 'dimension:8', 'root']);
assert.equal(next.selectedId, null);
assert.equal(next.focusedDimension, null);
});
test('navigation selection falls back to the nearest surviving ancestor and refocuses its dimension', () => {
const full = buildModel(reconcileFixture());
const pendingOnly = buildModel(filterProfile(reconcileFixture(), 'pending'));
const kept = reconcileNavigation(full, pendingOnly, { expandedIds: new Set(), selectedId: 'attr-a' });
assert.equal(kept.selectedId, 'attr-a');
assert.equal(kept.focusedDimension, 8);
const nearest = reconcileNavigation(buildModel(input), buildModel(filterProfile(input, 'approved')), {
expandedIds: new Set(),
selectedId: 'attr-b',
});
assert.equal(nearest.selectedId, 'dimension:8');
assert.equal(nearest.focusedDimension, 8);
const fallback = reconcileNavigation(full, pendingOnly, { expandedIds: new Set(), selectedId: 'attr-c' });
assert.equal(fallback.selectedId, 'root');
assert.equal(fallback.focusedDimension, null);
const empty = buildModel(filterProfile(reconcileFixture(), 'rejected'));
const toRoot = reconcileNavigation(full, empty, { expandedIds: new Set(['dimension:8']), selectedId: 'attr-a' });
assert.equal(toRoot.selectedId, 'root');
assert.deepEqual([...toRoot.expandedIds], ['root']);
});
function deepFixture(): ProfileInput {
return {
...input,
dimensions: [
{
dim: 8,
name: '兴趣',
groups: [
{
id: 'topic-a',
name: '运动',
items: [
{ id: 'sub-a', name: '球类', items: [{ id: 'attr-a', attr: '偏好', fact_indices: [0] }] },
{ id: 'attr-top', attr: '频率', fact_indices: [1] },
],
},
],
},
{
dim: 3,
name: '目标',
groups: [{ id: 'topic-c', name: '学习', items: [{ id: 'attr-c', attr: '计划', fact_indices: [2] }] }],
},
],
};
}
test('levelOf and withinLevel place only dimension, topic and subtopic nodes in the hierarchy', () => {
const model = buildModel(deepFixture());
assert.deepEqual(
['root', 'dimension:8', 'topic-a', 'sub-a', 'attr-a'].map((id) => levelOf(model.nodes.get(id)!.kind)),
[null, 1, 2, 3, null],
);
assert.equal(withinLevel(model.nodes.get('topic-a')!, 1), false);
assert.equal(withinLevel(model.nodes.get('topic-a')!, 2), true);
assert.equal(withinLevel(model.nodes.get('sub-a')!, 2), false);
assert.equal(withinLevel(model.nodes.get('sub-a')!, 3), true);
assert.equal(withinLevel(model.nodes.get('root')!, 3), false);
assert.equal(withinLevel(model.nodes.get('attr-a')!, 3), false);
});
test('levelIsExpanded requires every branching node of the dimension down to that level', () => {
const model = buildModel(deepFixture());
const expanded = (...ids: string[]) => new Set(['root', ...ids]);
assert.equal(levelIsExpanded(model, expanded(), 1, 8), false);
assert.equal(levelIsExpanded(model, expanded('dimension:8'), 1, 8), true);
assert.equal(levelIsExpanded(model, expanded('dimension:8'), 2, 8), false);
assert.equal(levelIsExpanded(model, expanded('dimension:8', 'topic-a'), 2, 8), true);
assert.equal(levelIsExpanded(model, expanded('dimension:8', 'topic-a'), 3, 8), false);
assert.equal(levelIsExpanded(model, expanded('dimension:8', 'topic-a', 'sub-a'), 3, 8), true);
// Other dimensions do not count.
assert.equal(levelIsExpanded(model, expanded('dimension:3'), 1, 8), false);
assert.equal(levelIsExpanded(model, expanded('dimension:8', 'topic-a', 'sub-a'), 3, 3), false);
});
test('levelIsExpanded is false without a focused dimension or an expanded root', () => {
const model = buildModel(deepFixture());
const all = new Set(['root', 'dimension:8', 'dimension:3', 'topic-a', 'topic-c', 'sub-a']);
assert.equal(levelIsExpanded(model, all, 3, null), false);
all.delete('root');
for (const level of [1, 2, 3] as const) assert.equal(levelIsExpanded(model, all, level, 8), false);
});
test('toggling a level expands branching nodes down to it, then collapses it and the deeper levels', () => {
const model = buildModel(deepFixture());
const start = new Set(['dimension:3']);
const two = toggleLevelExpansion(model, start, 2, 8);
assert.deepEqual([...two].sort(), ['dimension:3', 'dimension:8', 'root', 'topic-a']);
assert.deepEqual([...start], ['dimension:3']);
const three = toggleLevelExpansion(model, two, 3, 8);
assert.deepEqual([...three].sort(), ['dimension:3', 'dimension:8', 'root', 'sub-a', 'topic-a']);
// Collapsing level 3 leaves the shallower levels, collapsing level 2 also drops subtopics.
assert.deepEqual([...toggleLevelExpansion(model, three, 3, 8)].sort(), [
'dimension:3',
'dimension:8',
'root',
'topic-a',
]);
assert.deepEqual([...toggleLevelExpansion(model, three, 2, 8)].sort(), ['dimension:3', 'dimension:8', 'root']);
assert.deepEqual([...toggleLevelExpansion(model, three, 1, 8)].sort(), ['dimension:3', 'root']);
});
test('dimensionColor returns configured colours and falls back to dimension 1', () => {
assert.equal(dimensionColor(1), '#008f85');
assert.equal(dimensionColor(8), '#3775b4');
assert.equal(dimensionColor(2), '#008f85');
assert.equal(dimensionColor(null), '#008f85');
});
test('chineseNumeral renders small counts', () => {
assert.equal(chineseNumeral(5), '五');
});
test('buildModel skips dimensions that are not configured', () => {
const model = buildModel({
...input,
dimensions: [
...input.dimensions,
{
dim: 2,
name: '未配置',
groups: [{ id: 'topic-x', name: '其他', items: [{ id: 'attr-x', attr: 'x', fact_indices: [] }] }],
},
],
});
assert.deepEqual(model.dimensionIds, ['dimension:8']);
assert.equal(model.nodes.has('dimension:2'), false);
assert.equal(model.nodes.has('attr-x'), false);
});