// The pocket vault's two rules about other people's writing. // // A vault in a pocket has two writers by construction: this phone and the // laptop that pushes to the same repository. Everything that can go wrong // between them goes wrong in one of two places, and each of them is a pure // function that can be held to its promise here rather than discovered on a // phone at an airport. // // 1. THE CONFLICT RULE (mobile/src/pocket/conflict.ts). The phone never // merges prose. When both sides changed one note, the remote's version // keeps its name and the phone's is set down beside it as // ` (phone).md`. Nothing is merged, nothing is discarded. // 2. THE SYNC LINE (mobile/src/pocket/sync.ts). One line of text is the // ONLY account anybody gets of whether their writing has left the phone, // so it must never be optimistic. A pending push is always louder than a // past success; a conflict is louder still and never ages out. import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { conflictCommitMessage, phonePathFor, planConflicts, standingConflicts } from "../mobile/src/pocket/conflict.ts"; import { initialSyncState, syncLine, syncReduce, type SyncState } from "../mobile/src/pocket/sync.ts"; const none = (): boolean => false; describe("the conflict rule — where the phone's version goes", () => { it("sets it down beside the note, keeping the extension", () => { assert.equal(phonePathFor("Ideas/Astrolabe.md", none), "Ideas/Astrolabe (phone).md"); assert.equal(phonePathFor("Welcome.md", none), "Welcome (phone).md"); assert.equal(phonePathFor("Notes/Paper.tex", none), "Notes/Paper (phone).tex"); }); it("never overwrites an unresolved conflict from last time", () => { const taken = new Set(["Ideas/Astrolabe (phone).md"]); assert.equal(phonePathFor("Ideas/Astrolabe.md", (p) => taken.has(p)), "Ideas/Astrolabe (phone) 2.md"); taken.add("Ideas/Astrolabe (phone) 2.md"); assert.equal(phonePathFor("Ideas/Astrolabe.md", (p) => taken.has(p)), "Ideas/Astrolabe (phone) 3.md"); }); it("treats a leading dot as a name, not an extension", () => { assert.equal(phonePathFor(".gitignore", none), ".gitignore (phone)"); }); it("is not fooled into a collision inside ONE pull", () => { const plan = planConflicts( [ { path: "A.md", ours: "mine", theirs: "theirs" }, { path: "A.md", ours: "mine again", theirs: "theirs again" }, ], none, ); assert.deepEqual( plan.writes.map((w) => w.path), ["A (phone).md", "A (phone) 2.md"], ); }); it("agreement is not a conflict", () => { // Two people typing the same correction is agreement. Naming it a // conflict would teach the owner to ignore the line that names them. const plan = planConflicts([{ path: "A.md", ours: "same", theirs: "same" }], none); assert.deepEqual(plan.writes, []); assert.deepEqual(plan.pairs, []); }); it("keeps BOTH versions, and says which is which", () => { const plan = planConflicts( [{ path: "Ideas/Astrolabe.md", ours: "the phone's paragraph", theirs: "the laptop's paragraph" }], none, ); assert.deepEqual(plan.writes, [{ path: "Ideas/Astrolabe (phone).md", content: "the phone's paragraph" }]); assert.deepEqual(plan.pairs, [{ path: "Ideas/Astrolabe.md", phonePath: "Ideas/Astrolabe (phone).md" }]); // The remote's bytes are not in the plan at all: they are already under // the original name, untouched. Nothing here can lose them. assert.ok(!plan.writes.some((w) => w.content.includes("laptop"))); }); it("recovers the standing pairs from the tree, because a conflict is a FACT", () => { // A phone closed with two unresolved pairs and reopened saying "synced" // would be the same lie as saying it while commits wait. So the count is // read off the working tree at every open, never remembered. assert.deepEqual( standingConflicts([ "Welcome.md", "Ideas/Astrolabe.md", "Ideas/Astrolabe (phone).md", "Ideas/Astrolabe (phone) 2.md", ]), [ { path: "Ideas/Astrolabe.md", phonePath: "Ideas/Astrolabe (phone) 2.md" }, { path: "Ideas/Astrolabe.md", phonePath: "Ideas/Astrolabe (phone).md" }, ], ); }); it("a `(phone)` file whose partner is gone is a note, not a conflict", () => { // The owner resolved it by deleting the other one. What they kept is a // note with an odd name, and the line has nothing left to say. assert.deepEqual(standingConflicts(["Ideas/Astrolabe (phone).md", "Welcome.md"]), []); }); it("names what it did, in a message a `git log` on the laptop explains", () => { const one = conflictCommitMessage([{ path: "A.md", phonePath: "A (phone).md" }]); assert.match(one, /A \(phone\)\.md/); assert.match(one, /A\.md/); const many = conflictCommitMessage([ { path: "A.md", phonePath: "A (phone).md" }, { path: "B.md", phonePath: "B (phone).md" }, ]); assert.match(many, /2 notes/); }); }); describe("the sync line — what the one line says, and in what order", () => { const NOW = 1_700_000_000_000; const after = (state: SyncState, ...events: Parameters[1][]): SyncState => events.reduce(syncReduce, state); it("says nothing has been sent before anything has", () => { assert.deepEqual(syncLine(initialSyncState, NOW), { key: "never" }); }); it("counts what is waiting, and NEVER says synced while something waits", () => { const state = after(initialSyncState, { kind: "push-done", atMs: NOW }, { kind: "committed" }, { kind: "committed" }); // A pull-and-push finished thirty seconds ago and two commits have // happened since. "Synced just now" would be true and useless; the line // that matters is the one about the two. assert.deepEqual(syncLine(state, NOW + 30_000), { key: "toPush", count: 2 }); }); it("says synced, with how long ago, only when nothing is waiting", () => { const state = after(initialSyncState, { kind: "committed" }, { kind: "push-done", atMs: NOW }); assert.deepEqual(syncLine(state, NOW + 1000), { key: "syncedJustNow" }); assert.deepEqual(syncLine(state, NOW + 5 * 60_000), { key: "syncedAgo", minutes: 5 }); }); it("says offline rather than failed when the network is the reason", () => { const state = after(initialSyncState, { kind: "push-done", atMs: NOW }, { kind: "offline" }); assert.deepEqual(syncLine(state, NOW), { key: "offline" }); }); it("offline with work waiting says how much work, not just offline", () => { const state = after(initialSyncState, { kind: "committed" }, { kind: "offline" }); assert.deepEqual(syncLine(state, NOW), { key: "toPush", count: 1 }); }); it("a conflict is the loudest thing there is, and does not age out", () => { const state = after( initialSyncState, { kind: "pull-started" }, { kind: "pull-done", conflicts: [{ path: "A.md", phonePath: "A (phone).md" }] }, { kind: "push-done", atMs: NOW }, ); // Everything is pushed and the vault is level with the remote — and the // line still names the pair, because nobody has decided which sentence // survives. assert.equal(state.ahead, 0); assert.deepEqual(syncLine(state, NOW + 60 * 60_000), { key: "conflicts", count: 1 }); const cleared = syncReduce(state, { kind: "conflicts-cleared" }); assert.deepEqual(syncLine(cleared, NOW), { key: "syncedJustNow" }); }); it("a diverged pull leaves a commit to push", () => { const state = syncReduce(initialSyncState, { kind: "pull-done", conflicts: [{ path: "A.md", phonePath: "A (phone).md" }], }); // The conflict commit is a commit like any other. If it never went out, // the laptop would never learn that the pair exists. assert.equal(state.ahead, 1); }); it("does not announce the same pair twice", () => { const pair = { path: "A.md", phonePath: "A (phone).md" }; const state = after( initialSyncState, { kind: "pull-done", conflicts: [pair] }, { kind: "pull-done", conflicts: [pair] }, ); assert.equal(state.conflicts.length, 1); }); it("work in flight beats work waiting", () => { const state = after(initialSyncState, { kind: "committed" }, { kind: "pull-started" }); assert.deepEqual(syncLine(state, NOW), { key: "syncing" }); assert.deepEqual(syncLine(syncReduce(state, { kind: "push-started" }), NOW), { key: "pushing" }); }); it("a failure is a sentence, and the next success clears it", () => { const failed = syncReduce(initialSyncState, { kind: "failed", message: "403" }); assert.deepEqual(syncLine(failed, NOW), { key: "failed", message: "403" }); const fixed = syncReduce(failed, { kind: "push-done", atMs: NOW }); assert.deepEqual(syncLine(fixed, NOW), { key: "syncedJustNow" }); }); });