mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-19 01:17:31 +00:00
* perf(plugins): skip no-op doc-mode saves via lastSeenDocBytes equality
Closes #7815.
flushSave and flushSaveSync now compute the would-be-written raw bytes
first and short-circuit when they equal lastSeenDocBytes — a typed-then-
reverted cycle inside the 30s throttle window no longer produces a
postMessage round-trip, an IDB transaction, or an op-log entry.
The baseline is intentionally NOT updated on a skip: it already equals
these bytes, so the self-echo invariant for PERSISTED_DATA_CHANGED
(#7752) is preserved.
Drive-by: added the isDocCorrupt guard to flushSave for symmetry with
flushSaveSync / scheduleSave. The schedule gate normally prevents
flushSave from firing on a corrupt doc, but corruption can be set
between schedule and fire (e.g. a remote-update reload turning up an
unparseable blob), so the explicit guard is cheaper than reasoning
about that invariant.
Persistence rename: saveContextDoc → persistContextDocRaw. The caller
now owns serialisation (via serializeContextDoc) so the byte-compare
can run before the persist dispatch. Three persistence.spec.ts tests
replace the dropped saveContextDoc test: exact-raw write, encoder
determinism (the premise the byte-compare relies on), and
write-idempotence (which proves the skip loses no information).
* refactor(plugins): apply doc-mode no-op-save review findings (#7815)
Multi-review follow-ups for #7815:
- Tighten serializeContextDoc determinism test: previously asserted
equality between a doc and its `{...doc}` spread, which preserves V8
insertion order — the test would pass even against a future encoder
that randomised iteration over Map-backed nodes (the real failure
mode for the byte-compare). Now asserts repeated-call determinism on
the same input directly, plus type+non-empty shape so a future return-
type change (e.g. Uint8Array) breaks here, not in the editor.
- Document the sync/async stamp asymmetry in flushSaveSync: it stamps
lastSeenDocBytes BEFORE the void dispatch, whereas flushSave stamps
AFTER `await`. Both are correct (the sync path is fire-and-forget and
the iframe can be torn down mid-call, so a post-dispatch stamp would
silently drop on teardown) but the divergence wasn't called out in
the inline comments.
* refactor(plugins): rename document-mode to doc-mode
Renames the bundled plugin's id (`document-mode` → `doc-mode`), display
name (`Document Mode (Alpha)` → `Doc Mode (Alpha)`), package name,
directory, and asset path. No migration: the plugin has never been
published, so there is no on-disk user data to preserve.
Touched everywhere the id was hardcoded:
- Plugin manifest, package.json, build/deploy/test scripts, log prefixes
- Host BUNDLED_PLUGIN_PATHS entry (src/app/plugins/plugin.service.ts)
- Host comments and test fixtures (plugin-hooks, plugin-persistence-key,
plugin-persistence.model, conflict-resolution.service.spec)
- E2E spec filenames + PLUGIN_ID constant + label assertions
- build-all.js plugin orchestrator
- build/release-notes.md user-facing entry
Test pass: 88/88 plugin specs, 8/8 plugin-hooks, 132/132
conflict-resolution, 15/15 plugin-persistence-key.util.
Bundle redeployed to src/assets/bundled-plugins/doc-mode (gitignored).
The old `src/assets/bundled-plugins/document-mode/` dir is gitignored and
left on disk after this commit; the host loader no longer references it,
so it's inert. Remove with `rm -rf src/assets/bundled-plugins/document-mode`.
* refactor(plugins): apply doc-mode rename review findings
Multi-review follow-ups for the rename in 3443bcacd0:
- editor.ts:1920: missed runtime log string ('Document mode:' →
'doc-mode:') — leaked into the dev console with inconsistent brand
because the rename sweep matched on the kebab `document-mode` or the
PascalCase `Document Mode`, not the bare-space `Document mode`.
- editor.ts + background.ts header JSDoc: `Document-Mode editor` /
`Document-Mode background script` → `Doc-Mode …`. Cosmetic but the
only remaining branded references in the source.
- scripts/build.js: esbuild iife `globalName: 'DocumentModePlugin'` →
`'DocModePlugin'`. Internal global, no consumers, but worth keeping
consistent with the new id.
- src/app/plugins/plugin.service.ts: add a header comment to
BUNDLED_PLUGIN_PATHS noting that pluginIds become entityId prefixes in
IDB / op-log / sync wire and must be treated as permanent for any
plugin that has ever shipped. Records the rule we relied on being
absent for this rename.
DOM-document references (`installDocumentDragHandlers`,
`Document-status banner`, `Document-level pointer handlers`) left
unchanged — those refer to the browser document object, not the brand.
Historical plan docs in `docs/plans/2026-05-2[123]-*.md` also left
unchanged per the rename commit's intent.
607 lines
21 KiB
TypeScript
607 lines
21 KiB
TypeScript
/**
|
|
* Unit tests for the pure document-transform helpers. Run with
|
|
* `npm test` (see scripts/test.js) — esbuild transpiles this file and the
|
|
* built-in `node --test` runner executes it.
|
|
*/
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
buildSeedDoc,
|
|
ensureSubtasksInJSON,
|
|
isInContext,
|
|
migrateStoredDoc,
|
|
prepareStoredDoc,
|
|
reconcileTopLevelTaskRefs,
|
|
refreshChipContentFromCache,
|
|
snapshotInContextTaskIds,
|
|
stripChipContent,
|
|
taskNodeJSON,
|
|
taskRefWithSubtasksJSON,
|
|
type PMNode,
|
|
type TaskLookup,
|
|
} from './doc-transform';
|
|
import type { ActiveWorkContext, Task } from '@super-productivity/plugin-api';
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Fixtures */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
const mkTask = (partial: Partial<Task> & { id: string }): Task => {
|
|
const base: Task = {
|
|
id: partial.id,
|
|
title: '',
|
|
timeEstimate: 0,
|
|
timeSpent: 0,
|
|
isDone: false,
|
|
projectId: null,
|
|
tagIds: [],
|
|
created: 0,
|
|
subTaskIds: [],
|
|
};
|
|
return { ...base, ...partial };
|
|
};
|
|
|
|
const mkLookup = (tasks: Task[]): TaskLookup => {
|
|
const map = new Map(tasks.map((t) => [t.id, t]));
|
|
return (id) => map.get(id);
|
|
};
|
|
|
|
const mkCtx = (
|
|
partial: Partial<ActiveWorkContext> & { id: string },
|
|
): ActiveWorkContext => ({
|
|
type: 'PROJECT',
|
|
title: 'Untitled',
|
|
taskIds: [],
|
|
...partial,
|
|
});
|
|
|
|
/** Compact `[type:taskId, ...]` view of a doc's top-level children. */
|
|
const summary = (doc: unknown): string[] =>
|
|
((doc as PMNode).content ?? []).map((n) => {
|
|
const node = n as PMNode;
|
|
if (node.type === 'taskRef' || node.type === 'subTaskRef') {
|
|
return `${node.type}:${String(node.attrs?.taskId)}`;
|
|
}
|
|
return node.type ?? '?';
|
|
});
|
|
|
|
/** Concatenated inline text of a chip node. */
|
|
const chipText = (node: PMNode): string =>
|
|
(node.content ?? []).map((c) => (c as PMNode).text ?? '').join('');
|
|
|
|
const childAt = (doc: unknown, i: number): PMNode =>
|
|
((doc as PMNode).content ?? [])[i] as PMNode;
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* taskNodeJSON / taskRefWithSubtasksJSON */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
test('taskNodeJSON: builds a content-bearing chip from the lookup', () => {
|
|
const look = mkLookup([mkTask({ id: 'a', title: 'Buy milk', isDone: true })]);
|
|
assert.deepEqual(taskNodeJSON('a', 'taskRef', look), {
|
|
type: 'taskRef',
|
|
attrs: { taskId: 'a', isDone: true },
|
|
content: [{ type: 'text', text: 'Buy milk' }],
|
|
});
|
|
});
|
|
|
|
test('taskNodeJSON: unknown task yields an empty (but valid) chip', () => {
|
|
const node = taskNodeJSON('ghost', 'subTaskRef', mkLookup([]));
|
|
assert.deepEqual(node, {
|
|
type: 'subTaskRef',
|
|
attrs: { taskId: 'ghost', isDone: false },
|
|
content: [],
|
|
});
|
|
});
|
|
|
|
test('taskRefWithSubtasksJSON: emits the parent followed by each subtask', () => {
|
|
const look = mkLookup([
|
|
mkTask({ id: 'p', title: 'Parent', subTaskIds: ['s1', 's2'] }),
|
|
mkTask({ id: 's1', title: 'Sub 1' }),
|
|
mkTask({ id: 's2', title: 'Sub 2' }),
|
|
]);
|
|
assert.deepEqual(summary({ content: taskRefWithSubtasksJSON('p', look) }), [
|
|
'taskRef:p',
|
|
'subTaskRef:s1',
|
|
'subTaskRef:s2',
|
|
]);
|
|
});
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* buildSeedDoc */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
test('buildSeedDoc: heading + chips with content + trailing paragraph', () => {
|
|
const look = mkLookup([
|
|
mkTask({ id: 'a', title: 'Alpha', subTaskIds: ['s1'] }),
|
|
mkTask({ id: 's1', title: 'Sub' }),
|
|
]);
|
|
const doc = buildSeedDoc(
|
|
mkCtx({ id: 'P1', title: 'Project One', taskIds: ['a'] }),
|
|
look,
|
|
);
|
|
assert.equal((doc as PMNode).type, 'doc');
|
|
assert.deepEqual(summary(doc), ['heading', 'taskRef:a', 'subTaskRef:s1', 'paragraph']);
|
|
assert.equal(chipText(childAt(doc, 0)), 'Project One');
|
|
assert.equal(chipText(childAt(doc, 1)), 'Alpha');
|
|
});
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* migrateStoredDoc */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
test('migrateStoredDoc: backfills content for old atom-shape taskRefs', () => {
|
|
const look = mkLookup([mkTask({ id: 'a', title: 'Hello', isDone: true })]);
|
|
const raw = { type: 'doc', content: [{ type: 'taskRef', attrs: { taskId: 'a' } }] };
|
|
const out = migrateStoredDoc(raw, look) as PMNode;
|
|
assert.deepEqual(childAt(out, 0), {
|
|
type: 'taskRef',
|
|
attrs: { taskId: 'a', isDone: true },
|
|
content: [{ type: 'text', text: 'Hello' }],
|
|
});
|
|
});
|
|
|
|
test('migrateStoredDoc: leaves chips that already have content (idempotent)', () => {
|
|
const look = mkLookup([mkTask({ id: 'a', title: 'Fresh title' })]);
|
|
const raw = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'taskRef',
|
|
attrs: { taskId: 'a', isDone: false },
|
|
content: [{ type: 'text', text: 'Edited title' }],
|
|
},
|
|
],
|
|
};
|
|
const out = migrateStoredDoc(raw, look) as PMNode;
|
|
assert.equal(chipText(childAt(out, 0)), 'Edited title');
|
|
});
|
|
|
|
test('migrateStoredDoc: preserves non-task nodes', () => {
|
|
const raw = {
|
|
type: 'doc',
|
|
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'note' }] }],
|
|
};
|
|
assert.deepEqual(migrateStoredDoc(raw, mkLookup([])), raw);
|
|
});
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* stripChipContent */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
test('stripChipContent: strips taskRef/subTaskRef content + isDone, keeps taskId', () => {
|
|
const doc = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'taskRef',
|
|
attrs: { taskId: 'a', isDone: true },
|
|
content: [{ type: 'text', text: 'Buy milk' }],
|
|
},
|
|
{
|
|
type: 'subTaskRef',
|
|
attrs: { taskId: 's1', isDone: false },
|
|
content: [{ type: 'text', text: 'Sub one' }],
|
|
},
|
|
],
|
|
};
|
|
const out = stripChipContent(doc) as PMNode;
|
|
assert.deepEqual(childAt(out, 0), { type: 'taskRef', attrs: { taskId: 'a' } });
|
|
assert.deepEqual(childAt(out, 1), { type: 'subTaskRef', attrs: { taskId: 's1' } });
|
|
});
|
|
|
|
test('stripChipContent: leaves prose blocks and their text content intact', () => {
|
|
const doc = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'heading',
|
|
attrs: { level: 1 },
|
|
content: [{ type: 'text', text: 'Title' }],
|
|
},
|
|
{ type: 'paragraph', content: [{ type: 'text', text: 'a note' }] },
|
|
{
|
|
type: 'bulletList',
|
|
content: [
|
|
{
|
|
type: 'listItem',
|
|
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'item' }] }],
|
|
},
|
|
],
|
|
},
|
|
{ type: 'taskRef', attrs: { taskId: 'a' }, content: [{ type: 'text', text: 'A' }] },
|
|
],
|
|
};
|
|
const out = stripChipContent(doc) as PMNode;
|
|
assert.deepEqual(childAt(out, 0), doc.content[0]);
|
|
assert.deepEqual(childAt(out, 1), doc.content[1]);
|
|
assert.deepEqual(childAt(out, 2), doc.content[2]);
|
|
// Chip inside the same doc is still stripped.
|
|
assert.deepEqual(childAt(out, 3), { type: 'taskRef', attrs: { taskId: 'a' } });
|
|
});
|
|
|
|
test('stripChipContent: does not mutate the input object', () => {
|
|
const doc = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'taskRef',
|
|
attrs: { taskId: 'a', isDone: true },
|
|
content: [{ type: 'text', text: 'Buy milk' }],
|
|
},
|
|
{ type: 'paragraph', content: [{ type: 'text', text: 'a note' }] },
|
|
],
|
|
};
|
|
const before = JSON.stringify(doc);
|
|
const out = stripChipContent(doc);
|
|
assert.notEqual(out, doc);
|
|
assert.equal(JSON.stringify(doc), before);
|
|
});
|
|
|
|
test('stripChipContent: round-trips through prepareStoredDoc to rebuild chip titles', () => {
|
|
const look = mkLookup([
|
|
mkTask({ id: 'a', title: 'Alpha', isDone: true, subTaskIds: ['s1'] }),
|
|
mkTask({ id: 's1', title: 'Sub one' }),
|
|
]);
|
|
const live = {
|
|
type: 'doc',
|
|
content: [
|
|
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Proj' }] },
|
|
{
|
|
type: 'taskRef',
|
|
attrs: { taskId: 'a', isDone: true },
|
|
content: [{ type: 'text', text: 'Alpha' }],
|
|
},
|
|
{
|
|
type: 'subTaskRef',
|
|
attrs: { taskId: 's1', isDone: false },
|
|
content: [{ type: 'text', text: 'Sub one' }],
|
|
},
|
|
{ type: 'paragraph', content: [{ type: 'text', text: 'a note' }] },
|
|
],
|
|
};
|
|
const stripped = stripChipContent(live);
|
|
const out = prepareStoredDoc(
|
|
stripped,
|
|
mkCtx({ id: 'P', taskIds: ['a'] }),
|
|
look,
|
|
) as PMNode;
|
|
assert.deepEqual(summary(out), [
|
|
'heading',
|
|
'taskRef:a',
|
|
'subTaskRef:s1',
|
|
'paragraph',
|
|
'paragraph',
|
|
]);
|
|
assert.equal(chipText(childAt(out, 1)), 'Alpha');
|
|
assert.equal(childAt(out, 1).attrs?.isDone, true);
|
|
assert.equal(chipText(childAt(out, 2)), 'Sub one');
|
|
assert.equal(chipText(childAt(out, 3)), 'a note');
|
|
});
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* reconcileTopLevelTaskRefs */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
test('reconcile: rebuilds order from ctx, drops stale chips, appends new ones', () => {
|
|
const look = mkLookup([
|
|
mkTask({ id: 'a', title: 'A' }),
|
|
mkTask({ id: 'b', title: 'B' }),
|
|
mkTask({ id: 'c', title: 'C' }),
|
|
]);
|
|
const stored = {
|
|
type: 'doc',
|
|
content: [
|
|
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'T' }] },
|
|
{ type: 'taskRef', attrs: { taskId: 'b' }, content: [{ type: 'text', text: 'B' }] },
|
|
{
|
|
type: 'taskRef',
|
|
attrs: { taskId: 'x' },
|
|
content: [{ type: 'text', text: 'gone' }],
|
|
},
|
|
{ type: 'taskRef', attrs: { taskId: 'a' }, content: [{ type: 'text', text: 'A' }] },
|
|
],
|
|
};
|
|
const out = reconcileTopLevelTaskRefs(
|
|
stored,
|
|
mkCtx({ id: 'P', taskIds: ['a', 'b', 'c'] }),
|
|
look,
|
|
);
|
|
assert.deepEqual(summary(out), [
|
|
'heading',
|
|
'taskRef:a',
|
|
'taskRef:b',
|
|
'taskRef:c',
|
|
'paragraph',
|
|
]);
|
|
// The freshly-appended chip carries its title, not an empty body.
|
|
assert.equal(chipText(childAt(out, 3)), 'C');
|
|
});
|
|
|
|
test('reconcile: dedupes duplicate subtask rows and duplicate parent groups', () => {
|
|
const look = mkLookup([mkTask({ id: 'a', title: 'A' })]);
|
|
const stored = {
|
|
type: 'doc',
|
|
content: [
|
|
{ type: 'taskRef', attrs: { taskId: 'a' } },
|
|
{ type: 'subTaskRef', attrs: { taskId: 's1' } },
|
|
{ type: 'subTaskRef', attrs: { taskId: 's1' } },
|
|
{ type: 'subTaskRef', attrs: { taskId: 's2' } },
|
|
{ type: 'taskRef', attrs: { taskId: 'a' } },
|
|
{ type: 'subTaskRef', attrs: { taskId: 's3' } },
|
|
],
|
|
};
|
|
const out = reconcileTopLevelTaskRefs(stored, mkCtx({ id: 'P', taskIds: ['a'] }), look);
|
|
assert.deepEqual(summary(out), [
|
|
'taskRef:a',
|
|
'subTaskRef:s1',
|
|
'subTaskRef:s2',
|
|
'paragraph',
|
|
]);
|
|
});
|
|
|
|
test('reconcile: drops orphan subtasks and keeps non-chip blocks in place', () => {
|
|
const look = mkLookup([mkTask({ id: 'a', title: 'A' })]);
|
|
const stored = {
|
|
type: 'doc',
|
|
content: [
|
|
{ type: 'paragraph', content: [{ type: 'text', text: 'intro' }] },
|
|
{ type: 'subTaskRef', attrs: { taskId: 'orphan' } },
|
|
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'T' }] },
|
|
{ type: 'taskRef', attrs: { taskId: 'a' } },
|
|
{ type: 'paragraph', content: [{ type: 'text', text: 'mid' }] },
|
|
],
|
|
};
|
|
const out = reconcileTopLevelTaskRefs(stored, mkCtx({ id: 'P', taskIds: ['a'] }), look);
|
|
// Orphan subtask dropped; intro + heading stay above the chip, mid below it.
|
|
assert.deepEqual(summary(out), [
|
|
'paragraph',
|
|
'heading',
|
|
'taskRef:a',
|
|
'paragraph',
|
|
'paragraph',
|
|
]);
|
|
});
|
|
|
|
test('reconcile: text inserted between two tasks stays between them', () => {
|
|
const look = mkLookup([
|
|
mkTask({ id: 'a', title: 'A' }),
|
|
mkTask({ id: 'b', title: 'B' }),
|
|
]);
|
|
const stored = {
|
|
type: 'doc',
|
|
content: [
|
|
{ type: 'taskRef', attrs: { taskId: 'a' }, content: [{ type: 'text', text: 'A' }] },
|
|
{ type: 'paragraph', content: [{ type: 'text', text: 'a note' }] },
|
|
{ type: 'taskRef', attrs: { taskId: 'b' }, content: [{ type: 'text', text: 'B' }] },
|
|
],
|
|
};
|
|
const out = reconcileTopLevelTaskRefs(
|
|
stored,
|
|
mkCtx({ id: 'P', taskIds: ['a', 'b'] }),
|
|
look,
|
|
);
|
|
assert.deepEqual(summary(out), ['taskRef:a', 'paragraph', 'taskRef:b', 'paragraph']);
|
|
assert.equal(chipText(childAt(out, 1)), 'a note');
|
|
});
|
|
|
|
test('reconcile: an anchored block follows its chip when ctx reorders', () => {
|
|
const look = mkLookup([
|
|
mkTask({ id: 'a', title: 'A' }),
|
|
mkTask({ id: 'b', title: 'B' }),
|
|
]);
|
|
const stored = {
|
|
type: 'doc',
|
|
content: [
|
|
{ type: 'taskRef', attrs: { taskId: 'a' } },
|
|
{ type: 'paragraph', content: [{ type: 'text', text: 'under a' }] },
|
|
{ type: 'taskRef', attrs: { taskId: 'b' } },
|
|
],
|
|
};
|
|
// Context order is now b, a — the note must travel with its anchor (a).
|
|
const out = reconcileTopLevelTaskRefs(
|
|
stored,
|
|
mkCtx({ id: 'P', taskIds: ['b', 'a'] }),
|
|
look,
|
|
);
|
|
assert.deepEqual(summary(out), ['taskRef:b', 'taskRef:a', 'paragraph', 'paragraph']);
|
|
});
|
|
|
|
test('reconcile: appends a trailing paragraph when the doc ends with a chip', () => {
|
|
const look = mkLookup([mkTask({ id: 'a', title: 'A' })]);
|
|
const stored = { type: 'doc', content: [{ type: 'taskRef', attrs: { taskId: 'a' } }] };
|
|
const out = reconcileTopLevelTaskRefs(stored, mkCtx({ id: 'P', taskIds: ['a'] }), look);
|
|
assert.deepEqual(summary(out), ['taskRef:a', 'paragraph']);
|
|
});
|
|
|
|
test('reconcile: returns the input untouched when it is not a doc node', () => {
|
|
const notADoc = { type: 'paragraph' };
|
|
assert.equal(
|
|
reconcileTopLevelTaskRefs(notADoc, mkCtx({ id: 'P' }), mkLookup([])),
|
|
notADoc,
|
|
);
|
|
assert.equal(reconcileTopLevelTaskRefs(null, mkCtx({ id: 'P' }), mkLookup([])), null);
|
|
});
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* ensureSubtasksInJSON */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
test('ensureSubtasksInJSON: backfills host subtasks missing from the doc', () => {
|
|
const look = mkLookup([
|
|
mkTask({ id: 'a', title: 'A', subTaskIds: ['s1', 's2'] }),
|
|
mkTask({ id: 's1', title: 'S1' }),
|
|
mkTask({ id: 's2', title: 'S2' }),
|
|
]);
|
|
const doc = { type: 'doc', content: [{ type: 'taskRef', attrs: { taskId: 'a' } }] };
|
|
const out = ensureSubtasksInJSON(doc, look);
|
|
assert.deepEqual(summary(out), ['taskRef:a', 'subTaskRef:s1', 'subTaskRef:s2']);
|
|
});
|
|
|
|
test('ensureSubtasksInJSON: idempotent — keeps existing rows, adds only the gaps', () => {
|
|
const look = mkLookup([
|
|
mkTask({ id: 'a', title: 'A', subTaskIds: ['s1', 's2'] }),
|
|
mkTask({ id: 's1', title: 'S1' }),
|
|
mkTask({ id: 's2', title: 'S2' }),
|
|
]);
|
|
const doc = {
|
|
type: 'doc',
|
|
content: [
|
|
{ type: 'taskRef', attrs: { taskId: 'a' } },
|
|
{ type: 'subTaskRef', attrs: { taskId: 's1' } },
|
|
],
|
|
};
|
|
const out = ensureSubtasksInJSON(doc, look);
|
|
assert.deepEqual(summary(out), ['taskRef:a', 'subTaskRef:s1', 'subTaskRef:s2']);
|
|
});
|
|
|
|
test('ensureSubtasksInJSON: skips subtasks the host does not know yet', () => {
|
|
const look = mkLookup([
|
|
mkTask({ id: 'a', title: 'A', subTaskIds: ['s1', 'ghost'] }),
|
|
mkTask({ id: 's1', title: 'S1' }),
|
|
]);
|
|
const doc = { type: 'doc', content: [{ type: 'taskRef', attrs: { taskId: 'a' } }] };
|
|
const out = ensureSubtasksInJSON(doc, look);
|
|
assert.deepEqual(summary(out), ['taskRef:a', 'subTaskRef:s1']);
|
|
});
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* refreshChipContentFromCache */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
test('refreshChipContentFromCache: replaces stale title + isDone from the lookup', () => {
|
|
const look = mkLookup([mkTask({ id: 'a', title: 'New title', isDone: true })]);
|
|
const doc = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'taskRef',
|
|
attrs: { taskId: 'a', isDone: false },
|
|
content: [{ type: 'text', text: 'Stale title' }],
|
|
},
|
|
{ type: 'paragraph', content: [{ type: 'text', text: 'untouched' }] },
|
|
],
|
|
};
|
|
const out = refreshChipContentFromCache(doc, look) as PMNode;
|
|
assert.equal(chipText(childAt(out, 0)), 'New title');
|
|
assert.equal(childAt(out, 0).attrs?.isDone, true);
|
|
assert.equal(chipText(childAt(out, 1)), 'untouched');
|
|
});
|
|
|
|
test('refreshChipContentFromCache: leaves chips whose task is unknown', () => {
|
|
const doc = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'taskRef',
|
|
attrs: { taskId: 'gone' },
|
|
content: [{ type: 'text', text: 'last known' }],
|
|
},
|
|
],
|
|
};
|
|
const out = refreshChipContentFromCache(doc, mkLookup([])) as PMNode;
|
|
assert.equal(chipText(childAt(out, 0)), 'last known');
|
|
});
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* prepareStoredDoc — end-to-end pipeline / regression guard */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
test('prepareStoredDoc: migrates, reconciles, backfills subtasks and refreshes titles', () => {
|
|
const look = mkLookup([
|
|
mkTask({ id: 'a', title: 'Alpha', subTaskIds: ['s1'] }),
|
|
mkTask({ id: 's1', title: 'Sub one' }),
|
|
mkTask({ id: 'b', title: 'Beta' }),
|
|
]);
|
|
const stored = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'heading',
|
|
attrs: { level: 1 },
|
|
content: [{ type: 'text', text: 'My Project' }],
|
|
},
|
|
// Old atom shape — no content array.
|
|
{ type: 'taskRef', attrs: { taskId: 'a' } },
|
|
// Duplicate parent group — must be discarded.
|
|
{ type: 'taskRef', attrs: { taskId: 'a' }, content: [{ type: 'text', text: 'A' }] },
|
|
// Stale chip for a task no longer in the context.
|
|
{
|
|
type: 'taskRef',
|
|
attrs: { taskId: 'old' },
|
|
content: [{ type: 'text', text: 'gone' }],
|
|
},
|
|
{ type: 'paragraph', content: [{ type: 'text', text: 'a note' }] },
|
|
],
|
|
};
|
|
const out = prepareStoredDoc(
|
|
stored,
|
|
mkCtx({ id: 'P', taskIds: ['a', 'b'] }),
|
|
look,
|
|
) as PMNode;
|
|
|
|
// 'a note' was anchored to the stale 'old' chip; with that chip dropped it
|
|
// is kept (appended), followed by the structural trailing paragraph.
|
|
assert.deepEqual(summary(out), [
|
|
'heading',
|
|
'taskRef:a',
|
|
'subTaskRef:s1',
|
|
'taskRef:b',
|
|
'paragraph',
|
|
'paragraph',
|
|
]);
|
|
// Every chip carries current host content — the invariant whose violation
|
|
// caused content-less chips to be written back as title erasures.
|
|
assert.equal(chipText(childAt(out, 1)), 'Alpha');
|
|
assert.equal(chipText(childAt(out, 2)), 'Sub one');
|
|
assert.equal(chipText(childAt(out, 3)), 'Beta');
|
|
assert.equal(chipText(childAt(out, 4)), 'a note');
|
|
});
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* isInContext */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
test('isInContext: PROJECT matches on projectId', () => {
|
|
const ctx = mkCtx({ id: 'P1', type: 'PROJECT' });
|
|
assert.equal(isInContext(mkTask({ id: 't', projectId: 'P1' }), ctx), true);
|
|
assert.equal(isInContext(mkTask({ id: 't', projectId: 'P2' }), ctx), false);
|
|
});
|
|
|
|
test('isInContext: subtasks never surface at the top level', () => {
|
|
const ctx = mkCtx({ id: 'P1', type: 'PROJECT' });
|
|
assert.equal(
|
|
isInContext(mkTask({ id: 't', projectId: 'P1', parentId: 'a' }), ctx),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test('isInContext: TODAY accepts the TODAY tag, a dueDay or a dueWithTime', () => {
|
|
const today = mkCtx({ id: 'TODAY', type: 'TAG' });
|
|
assert.equal(isInContext(mkTask({ id: 't', tagIds: ['TODAY'] }), today), true);
|
|
assert.equal(isInContext(mkTask({ id: 't', dueDay: '2026-05-22' }), today), true);
|
|
assert.equal(isInContext(mkTask({ id: 't', dueWithTime: 1_700_000_000 }), today), true);
|
|
assert.equal(isInContext(mkTask({ id: 't' }), today), false);
|
|
});
|
|
|
|
test('isInContext: TAG matches on tagIds membership', () => {
|
|
const ctx = mkCtx({ id: 'work', type: 'TAG' });
|
|
assert.equal(isInContext(mkTask({ id: 't', tagIds: ['work'] }), ctx), true);
|
|
assert.equal(isInContext(mkTask({ id: 't', tagIds: ['home'] }), ctx), false);
|
|
});
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* snapshotInContextTaskIds */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
test('snapshotInContextTaskIds: includes in-context parents and their subtask ids', () => {
|
|
const tasks = [
|
|
mkTask({ id: 'a', projectId: 'P1', subTaskIds: ['s1', 's2'] }),
|
|
mkTask({ id: 's1', projectId: 'P1', parentId: 'a' }),
|
|
mkTask({ id: 's2', projectId: 'P1', parentId: 'a' }),
|
|
mkTask({ id: 'b', projectId: 'P2', subTaskIds: ['s3'] }),
|
|
mkTask({ id: 's3', projectId: 'P2', parentId: 'b' }),
|
|
];
|
|
const snapshot = snapshotInContextTaskIds(tasks, mkCtx({ id: 'P1', type: 'PROJECT' }));
|
|
assert.deepEqual([...snapshot].sort(), ['a', 's1', 's2']);
|
|
});
|