vicky469_aside/tests/sideNoteSyncEvents.test.ts
vicky469 a51f11d8ce feat(comments): nest anchored side notes
Preserve child anchors, source highlights, and ordering when threads are nested. Suppress the missing-anchor notice because orphan styling already carries that state.
2026-06-07 02:15:33 +08:00

725 lines
24 KiB
TypeScript

import * as assert from "node:assert/strict";
import test from "node:test";
import type { CommentThread } from "../src/commentManager";
import {
mergeSideNoteSyncEventStates,
SideNoteSyncEventStore,
} from "../src/sync/sideNoteSyncEventStore";
import type { PersistedPluginData } from "../src/settings/indexNoteSettingsPlanner";
import {
buildSideNoteSyncEventInputsForThreadDiff,
decodeSideNoteSyncEventLine,
encodeSideNoteSyncEvent,
parseSideNoteSyncEvents,
reduceSideNoteSyncEvents,
type SideNoteSyncEvent,
} from "../src/core/storage/sideNoteSyncEvents";
function createThread(filePath: string, overrides: Partial<CommentThread> = {}): CommentThread {
return {
id: "thread-1",
filePath,
startLine: 1,
startChar: 2,
endLine: 1,
endChar: 7,
selectedText: "target",
selectedTextHash: "hash-target",
anchorKind: "selection",
orphaned: false,
entries: [{
id: "entry-1",
body: "hello",
timestamp: 1710000000000,
}],
createdAt: 1710000000000,
updatedAt: 1710000000000,
...overrides,
};
}
function createEvent(overrides: Partial<SideNoteSyncEvent> = {}): SideNoteSyncEvent {
return {
schemaVersion: 1,
eventId: "event-1",
deviceId: "device-a",
notePath: "docs/note.md",
noteHash: "hash-docs_note.md",
logicalClock: 1,
baseRevisionId: null,
createdAt: 1710000000100,
op: "createThread",
payload: {
thread: createThread("docs/note.md"),
},
...overrides,
};
}
test("side-note sync event encoder round-trips event lines and rejects malformed lines", () => {
const event = createEvent();
const encoded = encodeSideNoteSyncEvent(event);
const decoded = decodeSideNoteSyncEventLine(encoded);
assert.deepEqual(decoded, event);
assert.deepEqual(parseSideNoteSyncEvents(`ignored\n${encoded}\n%% bad %%`), [event]);
assert.equal(decodeSideNoteSyncEventLine("not an event"), null);
});
test("side-note sync reducer applies duplicate events idempotently and keeps deletes authoritative", () => {
const create = createEvent();
const append = createEvent({
eventId: "event-2",
logicalClock: 2,
op: "appendEntry",
payload: {
threadId: "thread-1",
entry: {
id: "entry-2",
body: "reply",
timestamp: 1710000000200,
},
},
});
const deleteThread = createEvent({
eventId: "event-3",
logicalClock: 3,
op: "setThreadDeleted",
payload: {
threadId: "thread-1",
deleted: true,
deletedAt: 1710000000300,
updatedAt: 1710000000300,
},
});
const staleUpdate = createEvent({
eventId: "event-4",
logicalClock: 4,
op: "updateEntry",
payload: {
threadId: "thread-1",
entryId: "entry-2",
entry: {
id: "entry-2",
body: "stale edit",
timestamp: 1710000000400,
},
},
});
const reduced = reduceSideNoteSyncEvents([], [create, create, append, deleteThread, staleUpdate]);
assert.equal(reduced.appliedEvents.length, 4);
assert.equal(reduced.threads.length, 0);
assert.equal(reduced.appliedEventIds.has("event-1"), true);
assert.equal(reduced.appliedLogicalClocks.has("device-a:1"), true);
});
test("side-note sync reducer retargets renameSource events that use nextPath", () => {
const previous = createThread("docs/old.md");
const renameSource = createEvent({
op: "renameSource",
notePath: "docs/old.md",
noteHash: "hash-docs_old.md",
payload: {
sourceId: "src-1",
previousPath: "docs/old.md",
nextPath: "docs/new.md",
},
});
const reduced = reduceSideNoteSyncEvents([previous], [renameSource]);
assert.equal(reduced.threads[0].filePath, "docs/new.md");
});
test("side-note sync diff emits compact mutation events", () => {
const previous = createThread("docs/note.md");
const next = createThread("docs/note.md", {
entries: [
previous.entries[0],
{
id: "entry-2",
body: "reply",
timestamp: 1710000000100,
},
],
updatedAt: 1710000000100,
});
const inputs = buildSideNoteSyncEventInputsForThreadDiff([previous], [next]);
assert.deepEqual(inputs.map((input) => input.op), ["appendEntry"]);
});
test("side-note sync reducer preserves child entry anchors on append", () => {
const previous = createThread("docs/note.md");
const append = createEvent({
eventId: "event-2",
logicalClock: 2,
op: "appendEntry",
payload: {
threadId: "thread-1",
entry: {
id: "entry-2",
body: "anchored reply",
timestamp: 1710000000100,
anchor: {
filePath: "docs/note.md",
startLine: 4,
startChar: 2,
endLine: 4,
endChar: 16,
selectedText: "anchored reply",
selectedTextHash: "hash-anchored-reply",
anchorKind: "selection",
},
},
},
});
const reduced = reduceSideNoteSyncEvents([previous], [append]);
assert.deepEqual(reduced.threads[0].entries[1]?.anchor, {
filePath: "docs/note.md",
startLine: 4,
startChar: 2,
endLine: 4,
endChar: 16,
selectedText: "anchored reply",
selectedTextHash: "hash-anchored-reply",
anchorKind: "selection",
});
});
test("side-note sync diff updates child entries when only anchors change", () => {
const previous = createThread("docs/note.md", {
entries: [
{
id: "thread-1",
body: "Parent",
timestamp: 1710000000000,
},
{
id: "entry-2",
body: "Point",
timestamp: 1710000000100,
anchor: {
filePath: "docs/note.md",
startLine: 3,
startChar: 0,
endLine: 3,
endChar: 5,
selectedText: "alpha",
selectedTextHash: "hash-alpha",
anchorKind: "selection",
},
},
],
updatedAt: 1710000000100,
});
const next = createThread("docs/note.md", {
entries: [
previous.entries[0],
{
...previous.entries[1],
anchor: {
filePath: "docs/note.md",
startLine: 5,
startChar: 1,
endLine: 5,
endChar: 5,
selectedText: "beta",
selectedTextHash: "hash-beta",
anchorKind: "selection",
},
},
],
updatedAt: 1710000000200,
});
const inputs = buildSideNoteSyncEventInputsForThreadDiff([previous], [next]);
const update = inputs.find((input) => input.op === "updateEntry");
assert.deepEqual(inputs.map((input) => input.op), ["updateEntry"]);
assert.deepEqual((update?.payload as { entry?: { anchor?: unknown } }).entry?.anchor, {
filePath: "docs/note.md",
startLine: 5,
startChar: 1,
endLine: 5,
endChar: 5,
selectedText: "beta",
selectedTextHash: "hash-beta",
anchorKind: "selection",
});
});
test("side-note sync diff preserves child-targeted append entry order", () => {
const previous = createThread("docs/note.md", {
entries: [
{
id: "thread-1",
body: "Parent",
timestamp: 1710000000000,
},
{
id: "entry-2",
body: "First child",
timestamp: 1710000000100,
},
{
id: "entry-3",
body: "Second child",
timestamp: 1710000000200,
},
],
updatedAt: 1710000000200,
});
const next = createThread("docs/note.md", {
entries: [
previous.entries[0],
previous.entries[1],
{
id: "entry-4",
body: "Inserted after first child",
timestamp: 1710000000300,
},
previous.entries[2],
],
updatedAt: 1710000000300,
});
const inputs = buildSideNoteSyncEventInputsForThreadDiff([previous], [next]);
const events = inputs.map((input, index) => createEvent({
eventId: `event-${index + 1}`,
logicalClock: index + 1,
op: input.op,
payload: input.payload,
}));
const reduced = reduceSideNoteSyncEvents([previous], events);
assert.deepEqual(inputs.map((input) => input.op), ["appendEntry", "moveEntry"]);
assert.deepEqual(
reduced.threads[0].entries.map((entry) => entry.id),
["thread-1", "entry-2", "entry-4", "entry-3"],
);
});
test("side-note sync diff removes source thread after nesting it under another thread", () => {
const source = createThread("docs/note.md", {
id: "thread-source",
startLine: 3,
startChar: 0,
endLine: 3,
endChar: 12,
selectedText: "source point",
selectedTextHash: "hash-source",
entries: [{
id: "thread-source",
body: "source body",
timestamp: 1710000000100,
anchor: {
filePath: "docs/note.md",
startLine: 3,
startChar: 0,
endLine: 3,
endChar: 12,
selectedText: "source point",
selectedTextHash: "hash-source",
anchorKind: "selection",
},
}],
createdAt: 1710000000100,
updatedAt: 1710000000100,
});
const target = createThread("docs/note.md", {
id: "thread-target",
entries: [{
id: "thread-target",
body: "target body",
timestamp: 1710000000000,
}],
});
const nextTarget = {
...target,
entries: [
target.entries[0],
source.entries[0],
],
updatedAt: 1710000000200,
};
const inputs = buildSideNoteSyncEventInputsForThreadDiff([source, target], [nextTarget]);
const events = inputs.map((input, index) => createEvent({
eventId: `event-${index + 1}`,
logicalClock: index + 1,
op: input.op,
payload: input.payload,
}));
const reduced = reduceSideNoteSyncEvents([source, target], events);
assert.deepEqual(inputs.map((input) => input.op), ["appendEntry", "removeThread"]);
assert.deepEqual(reduced.threads.map((thread) => thread.id), ["thread-target"]);
assert.deepEqual(
reduced.threads[0].entries.map((entry) => entry.id),
["thread-target", "thread-source"],
);
});
test("side-note sync diff preserves reordered child entries", () => {
const previous = createThread("docs/note.md", {
entries: [
{
id: "thread-1",
body: "Parent",
timestamp: 1710000000000,
},
{
id: "entry-2",
body: "First child",
timestamp: 1710000000100,
},
{
id: "entry-3",
body: "Second child",
timestamp: 1710000000200,
},
{
id: "entry-4",
body: "Third child",
timestamp: 1710000000300,
},
],
updatedAt: 1710000000300,
});
const next = createThread("docs/note.md", {
entries: [
previous.entries[0],
previous.entries[2],
previous.entries[3],
previous.entries[1],
],
updatedAt: 1710000000400,
});
const inputs = buildSideNoteSyncEventInputsForThreadDiff([previous], [next]);
const events = inputs.map((input, index) => createEvent({
eventId: `event-${index + 1}`,
logicalClock: index + 1,
op: input.op,
payload: input.payload,
}));
const reduced = reduceSideNoteSyncEvents([previous], events);
assert.deepEqual(inputs.map((input) => input.op), ["moveEntry", "moveEntry"]);
assert.deepEqual(
reduced.threads[0].entries.map((entry) => entry.id),
["thread-1", "entry-3", "entry-4", "entry-2"],
);
});
test("side-note sync diff includes previous entry snapshots for conflict recovery", () => {
const previous = createThread("docs/note.md");
const next = createThread("docs/note.md", {
entries: [{
...previous.entries[0],
body: "edited body",
timestamp: 1710000000100,
}],
updatedAt: 1710000000100,
});
const update = buildSideNoteSyncEventInputsForThreadDiff([previous], [next])
.find((input) => input.op === "updateEntry");
assert.ok(update);
assert.deepEqual((update.payload as { previousEntry?: unknown }).previousEntry, previous.entries[0]);
});
test("side-note sync reducer preserves overwritten body when the same entry is edited concurrently", () => {
const baseThread = createThread("docs/note.md", {
entries: [{
id: "entry-1",
body: "base body",
timestamp: 1710000000000,
}],
});
const deviceAUpdate = createEvent({
eventId: "event-a",
deviceId: "device-a",
logicalClock: 1,
op: "updateEntry",
payload: {
threadId: "thread-1",
entryId: "entry-1",
previousEntry: baseThread.entries[0],
entry: {
id: "entry-1",
body: "device A body",
timestamp: 1710000000100,
},
},
});
const deviceBUpdate = createEvent({
eventId: "event-b",
deviceId: "device-b",
logicalClock: 1,
createdAt: 1710000000200,
op: "updateEntry",
payload: {
threadId: "thread-1",
entryId: "entry-1",
previousEntry: baseThread.entries[0],
entry: {
id: "entry-1",
body: "device B body",
timestamp: 1710000000200,
},
},
});
const reduced = reduceSideNoteSyncEvents([baseThread], [deviceBUpdate, deviceAUpdate]);
const thread = reduced.threads[0];
const recoveryEntry = thread.entries.find((entry) => entry.id === "sync-conflict-event-b");
assert.equal(thread.entries[0].body, "device B body");
assert.ok(recoveryEntry);
assert.match(recoveryEntry.body, /device A body/);
});
test("side-note sync event store appends local events and marks them processed for the current device", async () => {
let persistedData: PersistedPluginData = {};
let eventCounter = 0;
const store = new SideNoteSyncEventStore({
readPersistedPluginData: () => persistedData,
writePersistedPluginData: async (data) => {
persistedData = data;
},
getDeviceId: () => "device-a",
createEventId: () => `event-${++eventCounter}`,
hashText: async (text) => `hash-${text.replace(/\//g, "_")}`,
now: () => 1710000000100,
});
const events = await store.appendLocalEvents("docs/note.md", [{
op: "createThread",
payload: {
thread: createThread("docs/note.md"),
},
}]);
const state = store.readState();
assert.equal(events.length, 1);
assert.equal(events[0].deviceId, "device-a");
assert.equal(events[0].logicalClock, 1);
assert.equal(state.deviceLogs["device-a"]?.lastClock, 1);
assert.equal(state.processedWatermarks["device-a"]?.["device-a"], 1);
assert.deepEqual(store.getUnprocessedEvents(), []);
});
test("side-note sync event store merges latest plugin data before writing stale local state", async () => {
let diskData: PersistedPluginData = {};
let deviceAData: PersistedPluginData = {};
let deviceBData: PersistedPluginData = {};
let eventCounter = 0;
const createStore = (deviceId: string, getLocalData: () => PersistedPluginData, setLocalData: (data: PersistedPluginData) => void) =>
new SideNoteSyncEventStore({
readPersistedPluginData: getLocalData,
readLatestPersistedPluginData: async () => diskData,
writePersistedPluginData: async (data) => {
setLocalData(data);
diskData = data;
},
getDeviceId: () => deviceId,
createEventId: () => `event-${++eventCounter}`,
hashText: async (text) => `hash-${text.replace(/\//g, "_")}`,
now: () => 1710000000100 + eventCounter,
});
await createStore("device-a", () => deviceAData, (data) => {
deviceAData = data;
}).appendLocalEvents("docs/a.md", [{
op: "createThread",
payload: {
thread: createThread("docs/a.md", { id: "thread-a" }),
},
}]);
await createStore("device-b", () => deviceBData, (data) => {
deviceBData = data;
}).appendLocalEvents("docs/b.md", [{
op: "createThread",
payload: {
thread: createThread("docs/b.md", { id: "thread-b" }),
},
}]);
const finalStore = createStore("device-c", () => diskData, (data) => {
diskData = data;
});
const state = finalStore.readState();
assert.equal(state.deviceLogs["device-a"]?.events.length, 1);
assert.equal(state.deviceLogs["device-b"]?.events.length, 1);
assert.equal(state.processedWatermarks["device-a"]?.["device-a"], 1);
assert.equal(state.processedWatermarks["device-b"]?.["device-b"], 1);
});
test("side-note sync event state merge keeps device logs, max watermarks, and newest snapshots", () => {
const left = new SideNoteSyncEventStore({
readPersistedPluginData: () => ({}),
writePersistedPluginData: async () => {},
getDeviceId: () => "device-a",
createEventId: () => "event-a",
hashText: async (text) => `hash-${text}`,
now: () => 1710000000000,
}).readState();
const right = new SideNoteSyncEventStore({
readPersistedPluginData: () => ({}),
writePersistedPluginData: async () => {},
getDeviceId: () => "device-b",
createEventId: () => "event-b",
hashText: async (text) => `hash-${text}`,
now: () => 1710000000000,
}).readState();
left.deviceLogs["device-a"] = {
lastClock: 1,
events: [createEvent({ eventId: "event-a", deviceId: "device-a", logicalClock: 1 })],
};
left.processedWatermarks["device-a"] = { "device-a": 1 };
left.noteSnapshots["hash-docs_note.md"] = {
notePath: "docs/note.md",
noteHash: "hash-docs_note.md",
updatedAt: 1,
coveredWatermarks: { "device-a": 1 },
threads: [createThread("docs/note.md", { id: "old-thread" })],
};
right.deviceLogs["device-b"] = {
lastClock: 1,
events: [createEvent({ eventId: "event-b", deviceId: "device-b", logicalClock: 1 })],
};
right.processedWatermarks["device-a"] = { "device-a": 0, "device-b": 1 };
right.processedWatermarks["device-b"] = { "device-b": 1 };
right.noteSnapshots["hash-docs_note.md"] = {
notePath: "docs/note.md",
noteHash: "hash-docs_note.md",
updatedAt: 2,
coveredWatermarks: { "device-a": 1, "device-b": 1 },
threads: [createThread("docs/note.md", { id: "new-thread" })],
};
const merged = mergeSideNoteSyncEventStates(left, right);
assert.equal(merged.deviceLogs["device-a"]?.events.length, 1);
assert.equal(merged.deviceLogs["device-b"]?.events.length, 1);
assert.equal(merged.processedWatermarks["device-a"]?.["device-a"], 1);
assert.equal(merged.processedWatermarks["device-a"]?.["device-b"], 1);
assert.equal(merged.noteSnapshots["hash-docs_note.md"]?.threads[0].id, "new-thread");
});
test("side-note sync event store exposes remote events until the current device processes them", async () => {
let persistedData: PersistedPluginData = {};
let eventCounter = 0;
const createStore = (deviceId: string) => new SideNoteSyncEventStore({
readPersistedPluginData: () => persistedData,
writePersistedPluginData: async (data) => {
persistedData = data;
},
getDeviceId: () => deviceId,
createEventId: () => `event-${++eventCounter}`,
hashText: async (text) => `hash-${text.replace(/\//g, "_")}`,
now: () => 1710000000100,
});
await createStore("device-b").appendLocalEvents("docs/note.md", [{
op: "createThread",
payload: {
thread: createThread("docs/note.md"),
},
}]);
const store = createStore("device-a");
const unprocessedEvents = store.getUnprocessedEvents();
assert.equal(unprocessedEvents.length, 1);
assert.equal(unprocessedEvents[0].deviceId, "device-b");
assert.equal(unprocessedEvents[0].logicalClock, 1);
await store.markEventsProcessed(unprocessedEvents);
assert.deepEqual(store.getUnprocessedEvents(), []);
assert.equal(store.readState().processedWatermarks["device-a"]?.["device-b"], 1);
});
test("side-note sync event store skips no-op processed watermark writes", async () => {
let persistedData: PersistedPluginData = {};
let writeCount = 0;
const store = new SideNoteSyncEventStore({
readPersistedPluginData: () => persistedData,
writePersistedPluginData: async (data) => {
persistedData = data;
writeCount += 1;
},
getDeviceId: () => "device-a",
createEventId: () => "event-1",
hashText: async (text) => `hash-${text.replace(/\//g, "_")}`,
now: () => 1710000000100,
});
await store.markWatermarksProcessed({ "device-b": 2 });
await store.markWatermarksProcessed({ "device-b": 1 });
await store.markWatermarksProcessed({ "device-b": 2 });
assert.equal(writeCount, 1);
assert.equal(store.readState().processedWatermarks["device-a"]?.["device-b"], 2);
});
test("side-note sync event store compacts only globally covered log prefixes", async () => {
let persistedData: PersistedPluginData = {};
let eventCounter = 0;
const createStore = (deviceId: string) => new SideNoteSyncEventStore({
readPersistedPluginData: () => persistedData,
writePersistedPluginData: async (data) => {
persistedData = data;
},
getDeviceId: () => deviceId,
createEventId: () => `event-${++eventCounter}`,
hashText: async (text) => `hash-${text.replace(/\//g, "_")}`,
now: () => 1710000000100,
});
const noteAThread = createThread("docs/a.md", { id: "thread-a" });
const noteBThread = createThread("docs/b.md", { id: "thread-b" });
const deviceA = createStore("device-a");
await deviceA.appendLocalEvents("docs/a.md", [{
op: "createThread",
payload: {
thread: noteAThread,
},
}]);
await deviceA.appendLocalEvents("docs/b.md", [{
op: "createThread",
payload: {
thread: noteBThread,
},
}]);
const compacted = await deviceA.compactProcessedEventsForSnapshots([{
notePath: "docs/a.md",
threads: [noteAThread],
}]);
const state = deviceA.readState();
assert.equal(compacted.removedEventCount, 1);
assert.equal(state.compactedWatermarks["device-a"], 1);
assert.deepEqual(state.deviceLogs["device-a"]?.events.map((event) => event.logicalClock), [2]);
assert.equal(deviceA.getSnapshots().length, 1);
const deviceC = createStore("device-c");
const unprocessedEvents = deviceC.getUnprocessedEvents();
assert.deepEqual(unprocessedEvents.map((event) => event.logicalClock), [2]);
await deviceC.markEventsProcessed(unprocessedEvents);
assert.equal(deviceC.readState().processedWatermarks["device-c"]?.["device-a"], 2);
});