tests/pocketSync.test.ts
Rama main · réplica de lectura del repositorio original.
// 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
// `<Note> (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<typeof syncReduce>[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" });
});
});