96c96d0b7d
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
583 lines
24 KiB
TypeScript
583 lines
24 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import {
|
|
__resetOfficeTaskContentSyncForTests,
|
|
beginWrite,
|
|
closeFieldEditor,
|
|
endAllEditorsForTask,
|
|
endWrite,
|
|
getCanonicalValue,
|
|
isFieldGuarded,
|
|
nextTaskSequence,
|
|
openFieldEditor,
|
|
parseUpdatedAt,
|
|
recordRefetchCandidate,
|
|
recordWriteSettled,
|
|
recordWriteSuccess,
|
|
seedInitialCanonical,
|
|
shouldRestoreAfterFailedWrite,
|
|
subscribeField,
|
|
TASK_SCOPE,
|
|
} from "./office-task-content-sync";
|
|
|
|
const TASK = "task-1";
|
|
const T10 = "2026-08-20T10:00:00Z";
|
|
const T11 = "2026-08-20T11:00:00Z";
|
|
const T12 = "2026-08-20T12:00:00Z";
|
|
|
|
beforeEach(() => {
|
|
__resetOfficeTaskContentSyncForTests();
|
|
});
|
|
|
|
describe("parseUpdatedAt", () => {
|
|
it("parses the SQLite space-separated offset-bearing layout to nanosecond precision (AC-66)", () => {
|
|
const ns = parseUpdatedAt("2026-08-20 14:30:05.123456789+00:00");
|
|
const wholeSecondMs = Date.parse("2026-08-20T14:30:05+00:00");
|
|
expect(ns).toBe(BigInt(wholeSecondMs) * BigInt(1_000_000) + BigInt(123456789));
|
|
});
|
|
|
|
it("parses the RFC3339Nano T/Z layout emitted by a write response to nanosecond precision (AC-66)", () => {
|
|
const ns = parseUpdatedAt("2026-08-20T14:30:05.123456789Z");
|
|
const wholeSecondMs = Date.parse("2026-08-20T14:30:05Z");
|
|
expect(ns).toBe(BigInt(wholeSecondMs) * BigInt(1_000_000) + BigInt(123456789));
|
|
});
|
|
|
|
it("treats the space-separated form's offset as authoritative, matching its T/Z equivalent", () => {
|
|
const spaceForm = parseUpdatedAt("2026-08-20 14:30:05.123456789+00:00");
|
|
const tForm = parseUpdatedAt("2026-08-20T14:30:05.123456789Z");
|
|
expect(spaceForm).toBe(tForm);
|
|
});
|
|
|
|
it("resolves the space-separated form via its own embedded offset, not the host machine's local timezone (AC-66)", () => {
|
|
// Every other space-separated fixture above uses a +00:00 offset, which
|
|
// coincidentally equals local time on a UTC-TZ test runner and would not
|
|
// catch a regression to `new Date(rawSpaceSeparatedString)` — the spec
|
|
// explicitly calls out that browsers disagree on how (or whether) to
|
|
// parse that non-ISO-8601 form, with some falling back to local time.
|
|
// A non-zero offset makes the correct answer distinguishable from any
|
|
// such fallback regardless of what timezone the test happens to run in.
|
|
const ns = parseUpdatedAt("2026-08-20 14:30:05.123456789+05:30");
|
|
// 14:30:05+05:30 is 09:00:05Z — computed via an explicit offset, so this
|
|
// expected value is itself timezone-independent.
|
|
const wholeSecondMs = Date.parse("2026-08-20T09:00:05+00:00");
|
|
expect(ns).toBe(BigInt(wholeSecondMs) * BigInt(1_000_000) + BigInt(123456789));
|
|
});
|
|
|
|
it("does not collapse two distinct sub-millisecond instants to the same value", () => {
|
|
// AC-60/62 requires strict ordering between these two: both round to the
|
|
// same millisecond under Date.parse (123ms), but differ at the
|
|
// microsecond digit — a millisecond-truncating parse would wrongly
|
|
// report them equal.
|
|
const earlier = parseUpdatedAt("2026-08-20T14:30:05.123400000Z");
|
|
const later = parseUpdatedAt("2026-08-20T14:30:05.123500000Z");
|
|
expect(earlier).not.toBe(later);
|
|
expect(earlier! < later!).toBe(true);
|
|
});
|
|
|
|
it("returns null for an unparseable value (AC-67 candidate)", () => {
|
|
expect(parseUpdatedAt("not-a-date")).toBeNull();
|
|
expect(parseUpdatedAt("")).toBeNull();
|
|
expect(parseUpdatedAt(null)).toBeNull();
|
|
expect(parseUpdatedAt(undefined)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("seedInitialCanonical (AC-59, AC-72)", () => {
|
|
it("records the first candidate unconditionally, even if its updated_at is unparseable", () => {
|
|
seedInitialCanonical(TASK, "title", "Hello", "garbage");
|
|
expect(getCanonicalValue(TASK, "title")).toBe("Hello");
|
|
});
|
|
|
|
it("a same-session revisit re-seed overwrites a stale canonical with a newer value (AC-59, AC-64)", () => {
|
|
// A revisit's fresh initial-load seed must be able to supersede a
|
|
// canonical value recorded by an earlier visit in the same SPA session —
|
|
// otherwise a later failed commit's restore (AC-9/AC-64, which reads the
|
|
// canonical value live at restore time) could resurrect a value the
|
|
// server has already moved past instead of the value most recently
|
|
// loaded from it.
|
|
seedInitialCanonical(TASK, "title", "Hello", T10);
|
|
seedInitialCanonical(TASK, "title", "Newer on revisit", T11);
|
|
expect(getCanonicalValue(TASK, "title")).toBe("Newer on revisit");
|
|
});
|
|
|
|
it("a revisit re-seed does not resurrect a stale value once a newer one is canonical", () => {
|
|
// The revisit seed goes through the same AC-60/62 ordering as any other
|
|
// candidate — it is not a blind overwrite, just no longer a no-op.
|
|
seedInitialCanonical(TASK, "title", "Hello", T11);
|
|
seedInitialCanonical(TASK, "title", "Stale revisit", T10);
|
|
expect(getCanonicalValue(TASK, "title")).toBe("Hello");
|
|
});
|
|
|
|
it("normalises an absent description to the empty string (AC-71)", () => {
|
|
seedInitialCanonical(TASK, "description", undefined as unknown as string, T10);
|
|
expect(getCanonicalValue(TASK, "description")).toBe("");
|
|
});
|
|
|
|
it("records the first candidate unconditionally even when its sequence predates the field's last write (AC-72 overrides AC-52)", () => {
|
|
// AC-72: until a field has a recorded canonical value, the seed rule
|
|
// exempts the next candidate from every other rule, including the AC-52
|
|
// staleness check — that check compares against a write, but there is
|
|
// nothing yet to be stale relative to. This reproduces the initial page
|
|
// load's seed call landing after a title commit was already issued
|
|
// (e.g. the authoritative GET resolves late, or is retried, while the
|
|
// page already rendered the task interactively from a store cache).
|
|
const staleSeq = nextTaskSequence(TASK);
|
|
const writeSeq = nextTaskSequence(TASK);
|
|
beginWrite(TASK, "title", writeSeq);
|
|
|
|
const accepted = recordRefetchCandidate(TASK, "title", "first ever value", T10, staleSeq);
|
|
|
|
expect(accepted).toBe(true);
|
|
expect(getCanonicalValue(TASK, "title")).toBe("first ever value");
|
|
});
|
|
});
|
|
|
|
describe("recordRefetchCandidate ordering (AC-60, AC-62, AC-63, AC-73)", () => {
|
|
it("discards a strictly older candidate (AC-60)", () => {
|
|
seedInitialCanonical(TASK, "title", "v1", T10);
|
|
const accepted = recordRefetchCandidate(
|
|
TASK,
|
|
"title",
|
|
"older",
|
|
"2026-08-20T09:00:00Z",
|
|
nextTaskSequence(TASK),
|
|
);
|
|
expect(accepted).toBe(false);
|
|
expect(getCanonicalValue(TASK, "title")).toBe("v1");
|
|
});
|
|
|
|
it("accepts a strictly newer candidate", () => {
|
|
seedInitialCanonical(TASK, "title", "v1", T10);
|
|
const accepted = recordRefetchCandidate(TASK, "title", "newer", T11, nextTaskSequence(TASK));
|
|
expect(accepted).toBe(true);
|
|
expect(getCanonicalValue(TASK, "title")).toBe("newer");
|
|
});
|
|
|
|
it("breaks an equal-instant tie by request-issue sequence, higher sequence wins (AC-63)", () => {
|
|
seedInitialCanonical(TASK, "title", "v1", T10);
|
|
const loserSeq = nextTaskSequence(TASK);
|
|
const winnerSeq = nextTaskSequence(TASK);
|
|
// Winner recorded first, loser arrives after but carries a lower sequence.
|
|
expect(recordRefetchCandidate(TASK, "title", "winner", T10, winnerSeq)).toBe(true);
|
|
expect(getCanonicalValue(TASK, "title")).toBe("winner");
|
|
expect(recordRefetchCandidate(TASK, "title", "loser", T10, loserSeq)).toBe(false);
|
|
expect(getCanonicalValue(TASK, "title")).toBe("winner");
|
|
});
|
|
|
|
it("discards a candidate whose updated_at cannot be parsed (AC-67)", () => {
|
|
seedInitialCanonical(TASK, "title", "v1", T10);
|
|
const accepted = recordRefetchCandidate(
|
|
TASK,
|
|
"title",
|
|
"bad",
|
|
"garbage",
|
|
nextTaskSequence(TASK),
|
|
);
|
|
expect(accepted).toBe(false);
|
|
expect(getCanonicalValue(TASK, "title")).toBe("v1");
|
|
});
|
|
|
|
it("an unparseable seed loses to any later candidate that does parse (AC-73)", () => {
|
|
seedInitialCanonical(TASK, "title", "seed", "garbage");
|
|
const accepted = recordRefetchCandidate(
|
|
TASK,
|
|
"title",
|
|
"supersedes",
|
|
T10,
|
|
nextTaskSequence(TASK),
|
|
);
|
|
expect(accepted).toBe(true);
|
|
expect(getCanonicalValue(TASK, "title")).toBe("supersedes");
|
|
});
|
|
|
|
it("discards a stale SQLite-layout refetch that lands within the same millisecond as an already-recorded RFC3339Nano write (AC-60/62/66)", () => {
|
|
// Both instants round to the same millisecond, so a millisecond-precision
|
|
// comparison would wrongly treat them as an equal-instant tie (or worse,
|
|
// accept the stale one outright) instead of correctly ordering them.
|
|
seedInitialCanonical(TASK, "title", "seed", T10);
|
|
const writeSeq = nextTaskSequence(TASK);
|
|
expect(
|
|
recordWriteSuccess(TASK, "title", "from write", "2026-08-20T14:30:05.123500000Z", writeSeq),
|
|
).toBe(true);
|
|
endWrite(TASK, "title", writeSeq);
|
|
|
|
const refetchSeq = nextTaskSequence(TASK);
|
|
const accepted = recordRefetchCandidate(
|
|
TASK,
|
|
"title",
|
|
"from stale refetch",
|
|
"2026-08-20 14:30:05.123400000+00:00",
|
|
refetchSeq,
|
|
);
|
|
expect(accepted).toBe(false);
|
|
expect(getCanonicalValue(TASK, "title")).toBe("from write");
|
|
});
|
|
|
|
it("accepts an SQLite-layout refetch that truly postdates an RFC3339Nano write by sub-millisecond microseconds (AC-60/62/66)", () => {
|
|
seedInitialCanonical(TASK, "title", "seed", T10);
|
|
const writeSeq = nextTaskSequence(TASK);
|
|
expect(
|
|
recordWriteSuccess(TASK, "title", "from write", "2026-08-20T14:30:05.123400000Z", writeSeq),
|
|
).toBe(true);
|
|
endWrite(TASK, "title", writeSeq);
|
|
|
|
const refetchSeq = nextTaskSequence(TASK);
|
|
const accepted = recordRefetchCandidate(
|
|
TASK,
|
|
"title",
|
|
"from later refetch",
|
|
"2026-08-20 14:30:05.123500000+00:00",
|
|
refetchSeq,
|
|
);
|
|
expect(accepted).toBe(true);
|
|
expect(getCanonicalValue(TASK, "title")).toBe("from later refetch");
|
|
});
|
|
});
|
|
|
|
describe("recordRefetchCandidate staleness vs a committed write (AC-52)", () => {
|
|
it("discards a refetch whose request predates this client's most recent write to the field", () => {
|
|
seedInitialCanonical(TASK, "title", "v1", T10);
|
|
const refetchSeq = nextTaskSequence(TASK);
|
|
const writeSeq = nextTaskSequence(TASK);
|
|
beginWrite(TASK, "title", writeSeq);
|
|
|
|
const accepted = recordRefetchCandidate(TASK, "title", "stale", T12, refetchSeq);
|
|
expect(accepted).toBe(false);
|
|
expect(getCanonicalValue(TASK, "title")).toBe("v1");
|
|
});
|
|
|
|
it("does not apply write-staleness to a write's own response (AC-52 scoped to refetch only)", () => {
|
|
seedInitialCanonical(TASK, "title", "v1", T10);
|
|
const writeSeq = nextTaskSequence(TASK);
|
|
beginWrite(TASK, "title", writeSeq);
|
|
// A later write is also issued for the same field, making this write's
|
|
// sequence no longer "most recent" — its own response must still record.
|
|
const laterWriteSeq = nextTaskSequence(TASK);
|
|
beginWrite(TASK, "title", laterWriteSeq);
|
|
|
|
const accepted = recordWriteSuccess(TASK, "title", "first-write-result", T11, writeSeq);
|
|
expect(accepted).toBe(true);
|
|
});
|
|
|
|
it("still records a non-stale refetch while a write to that field is in flight (AC-49)", () => {
|
|
seedInitialCanonical(TASK, "title", "v1", T10);
|
|
const writeSeq = nextTaskSequence(TASK);
|
|
beginWrite(TASK, "title", writeSeq);
|
|
const refetchSeq = nextTaskSequence(TASK);
|
|
|
|
const accepted = recordRefetchCandidate(TASK, "title", "v2-from-refetch", T11, refetchSeq);
|
|
|
|
expect(accepted).toBe(true);
|
|
expect(getCanonicalValue(TASK, "title")).toBe("v2-from-refetch");
|
|
// The write is still in flight, so the field remains guarded: recording
|
|
// succeeded, but nothing was applied to the displayed value yet (AC-49
|
|
// scoped to recording; AC-50 governs the deferred apply on release).
|
|
expect(isFieldGuarded(TASK, "title")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("guard derivation (AC-28, AC-38, AC-68, AC-69, AC-70)", () => {
|
|
it("is unguarded by default", () => {
|
|
expect(isFieldGuarded(TASK, "title")).toBe(false);
|
|
});
|
|
|
|
it("is guarded while an editor is open", () => {
|
|
openFieldEditor(TASK, "title");
|
|
expect(isFieldGuarded(TASK, "title")).toBe(true);
|
|
closeFieldEditor(TASK, "title");
|
|
expect(isFieldGuarded(TASK, "title")).toBe(false);
|
|
});
|
|
|
|
it("is guarded while a write is in flight, independent of editor state", () => {
|
|
const seq = nextTaskSequence(TASK);
|
|
beginWrite(TASK, "title", seq);
|
|
expect(isFieldGuarded(TASK, "title")).toBe(true);
|
|
endWrite(TASK, "title", seq);
|
|
expect(isFieldGuarded(TASK, "title")).toBe(false);
|
|
});
|
|
|
|
it("stays guarded until both the editor closes and the write resolves", () => {
|
|
openFieldEditor(TASK, "title");
|
|
const seq = nextTaskSequence(TASK);
|
|
beginWrite(TASK, "title", seq);
|
|
closeFieldEditor(TASK, "title");
|
|
expect(isFieldGuarded(TASK, "title")).toBe(true);
|
|
endWrite(TASK, "title", seq);
|
|
expect(isFieldGuarded(TASK, "title")).toBe(false);
|
|
});
|
|
|
|
it("endAllEditorsForTask ends only the open-editor contribution, not pending writes (AC-68)", () => {
|
|
openFieldEditor(TASK, "title");
|
|
const seq = nextTaskSequence(TASK);
|
|
beginWrite(TASK, "description", seq);
|
|
openFieldEditor(TASK, "description");
|
|
|
|
endAllEditorsForTask(TASK);
|
|
|
|
expect(isFieldGuarded(TASK, "title")).toBe(false);
|
|
expect(isFieldGuarded(TASK, "description")).toBe(true); // write still pending
|
|
endWrite(TASK, "description", seq);
|
|
expect(isFieldGuarded(TASK, "description")).toBe(false);
|
|
});
|
|
|
|
it("a write resolving after unmount still records canonical and clears its guard (AC-69)", () => {
|
|
const seq = nextTaskSequence(TASK);
|
|
beginWrite(TASK, "title", seq);
|
|
endAllEditorsForTask(TASK); // simulate unmount; write is still in flight
|
|
|
|
const accepted = recordWriteSuccess(TASK, "title", "post-unmount", T12, seq);
|
|
expect(accepted).toBe(true);
|
|
endWrite(TASK, "title", seq);
|
|
expect(isFieldGuarded(TASK, "title")).toBe(false);
|
|
expect(getCanonicalValue(TASK, "title")).toBe("post-unmount");
|
|
});
|
|
|
|
it("ends a write's guard contribution the same way on failure as on success (AC-70)", () => {
|
|
const seq = nextTaskSequence(TASK);
|
|
beginWrite(TASK, "title", seq);
|
|
// Failure path: caller does NOT call recordWriteSuccess, only endWrite.
|
|
endWrite(TASK, "title", seq);
|
|
expect(isFieldGuarded(TASK, "title")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("shouldRestoreAfterFailedWrite (AC-9, AC-44, AC-53, R5-F6)", () => {
|
|
it("restores when no other commit to the field is pending or already succeeded", () => {
|
|
const seq = nextTaskSequence(TASK);
|
|
beginWrite(TASK, "title", seq);
|
|
expect(shouldRestoreAfterFailedWrite(TASK, "title", seq)).toBe(true);
|
|
});
|
|
|
|
it("suppresses restore when a later-sequenced commit already succeeded (AC-44)", () => {
|
|
const failingSeq = nextTaskSequence(TASK);
|
|
beginWrite(TASK, "title", failingSeq);
|
|
const laterSeq = nextTaskSequence(TASK);
|
|
beginWrite(TASK, "title", laterSeq);
|
|
recordWriteSuccess(TASK, "title", "later-wins", T11, laterSeq);
|
|
endWrite(TASK, "title", laterSeq);
|
|
|
|
expect(shouldRestoreAfterFailedWrite(TASK, "title", failingSeq)).toBe(false);
|
|
});
|
|
|
|
it("suppresses restore when a later-sequenced commit is still unresolved (AC-53)", () => {
|
|
const failingSeq = nextTaskSequence(TASK);
|
|
beginWrite(TASK, "title", failingSeq);
|
|
const laterSeq = nextTaskSequence(TASK);
|
|
beginWrite(TASK, "title", laterSeq);
|
|
|
|
expect(shouldRestoreAfterFailedWrite(TASK, "title", failingSeq)).toBe(false);
|
|
});
|
|
|
|
it("does not suppress restore because of an earlier-sequenced commit, resolved or not (R5-F6)", () => {
|
|
const earlierSeq = nextTaskSequence(TASK);
|
|
beginWrite(TASK, "title", earlierSeq); // still unresolved
|
|
const failingSeq = nextTaskSequence(TASK);
|
|
beginWrite(TASK, "title", failingSeq);
|
|
|
|
expect(shouldRestoreAfterFailedWrite(TASK, "title", failingSeq)).toBe(true);
|
|
});
|
|
|
|
it("does not suppress restore when a later-sequenced commit also resolves with failure (AC-65)", () => {
|
|
const failingSeq = nextTaskSequence(TASK);
|
|
beginWrite(TASK, "title", failingSeq);
|
|
const laterSeq = nextTaskSequence(TASK);
|
|
beginWrite(TASK, "title", laterSeq);
|
|
// The later commit also fails: caller does not call recordWriteSuccess, only endWrite.
|
|
endWrite(TASK, "title", laterSeq);
|
|
|
|
expect(shouldRestoreAfterFailedWrite(TASK, "title", failingSeq)).toBe(true);
|
|
});
|
|
|
|
it("chained double failure: earlier commit resolves first, restore is suppressed then completed by the later failure (AC-65)", () => {
|
|
// This module never mutates canonical on failure — recordWriteSuccess is
|
|
// the only writer, and it is deliberately never called here (both
|
|
// commits fail). The two shouldRestoreAfterFailedWrite return values
|
|
// below are the actual assertions; the real "does the displayed title
|
|
// get restored to canonical" behavior is exercised end-to-end (via
|
|
// useCommitTaskTitle's actual restore action) in
|
|
// use-optimistic-task-mutation.test.tsx's "chained double failure" tests.
|
|
seedInitialCanonical(TASK, "title", "original", T10);
|
|
const earlySeq = nextTaskSequence(TASK);
|
|
beginWrite(TASK, "title", earlySeq);
|
|
const laterSeq = nextTaskSequence(TASK);
|
|
beginWrite(TASK, "title", laterSeq);
|
|
|
|
expect(shouldRestoreAfterFailedWrite(TASK, "title", earlySeq)).toBe(false); // AC-53: later still pending
|
|
endWrite(TASK, "title", earlySeq);
|
|
|
|
expect(shouldRestoreAfterFailedWrite(TASK, "title", laterSeq)).toBe(true); // nothing left pending
|
|
endWrite(TASK, "title", laterSeq);
|
|
});
|
|
|
|
it("chained double failure: later commit resolves first, both restores fire, canonical unaffected (AC-65)", () => {
|
|
// See the comment on the previous test: this only exercises the boolean
|
|
// query, not an actual restore. The end-to-end restore-to-canonical
|
|
// behavior lives in use-optimistic-task-mutation.test.tsx.
|
|
seedInitialCanonical(TASK, "title", "original", T10);
|
|
const earlySeq = nextTaskSequence(TASK);
|
|
beginWrite(TASK, "title", earlySeq);
|
|
const laterSeq = nextTaskSequence(TASK);
|
|
beginWrite(TASK, "title", laterSeq);
|
|
|
|
expect(shouldRestoreAfterFailedWrite(TASK, "title", laterSeq)).toBe(true); // nothing later pending
|
|
endWrite(TASK, "title", laterSeq);
|
|
|
|
expect(shouldRestoreAfterFailedWrite(TASK, "title", earlySeq)).toBe(true); // the later write already resolved
|
|
endWrite(TASK, "title", earlySeq);
|
|
});
|
|
});
|
|
|
|
describe('"task" scope — generic whole-task mutation guard', () => {
|
|
it("suppresses restore for a stale failure once a later-sequenced task-scope write has succeeded", () => {
|
|
const failingSeq = nextTaskSequence(TASK);
|
|
beginWrite(TASK, TASK_SCOPE, failingSeq);
|
|
const laterSeq = nextTaskSequence(TASK);
|
|
beginWrite(TASK, TASK_SCOPE, laterSeq);
|
|
recordWriteSettled(TASK, TASK_SCOPE, laterSeq);
|
|
endWrite(TASK, TASK_SCOPE, laterSeq);
|
|
|
|
expect(shouldRestoreAfterFailedWrite(TASK, TASK_SCOPE, failingSeq)).toBe(false);
|
|
});
|
|
|
|
it("suppresses restore for a stale failure while a later-sequenced task-scope write is still pending", () => {
|
|
const failingSeq = nextTaskSequence(TASK);
|
|
beginWrite(TASK, TASK_SCOPE, failingSeq);
|
|
const laterSeq = nextTaskSequence(TASK);
|
|
beginWrite(TASK, TASK_SCOPE, laterSeq);
|
|
|
|
expect(shouldRestoreAfterFailedWrite(TASK, TASK_SCOPE, failingSeq)).toBe(false);
|
|
});
|
|
|
|
it("restores when it is the only or the last-standing task-scope write", () => {
|
|
const seq = nextTaskSequence(TASK);
|
|
beginWrite(TASK, TASK_SCOPE, seq);
|
|
expect(shouldRestoreAfterFailedWrite(TASK, TASK_SCOPE, seq)).toBe(true);
|
|
});
|
|
|
|
it("keeps the 'task' scope and a field scope independent: a task-scope write does not guard or suppress a title write", () => {
|
|
seedInitialCanonical(TASK, "title", "original", T10);
|
|
|
|
const taskSeq = nextTaskSequence(TASK);
|
|
beginWrite(TASK, TASK_SCOPE, taskSeq);
|
|
|
|
// The title field is untouched by the concurrent task-scope write: it is
|
|
// not guarded, its canonical value is unaffected, and a title write's own
|
|
// restore decision ignores the task-scope write entirely.
|
|
expect(isFieldGuarded(TASK, "title")).toBe(false);
|
|
expect(getCanonicalValue(TASK, "title")).toBe("original");
|
|
|
|
const titleSeq = nextTaskSequence(TASK);
|
|
beginWrite(TASK, "title", titleSeq);
|
|
expect(shouldRestoreAfterFailedWrite(TASK, "title", titleSeq)).toBe(true);
|
|
|
|
endWrite(TASK, TASK_SCOPE, taskSeq);
|
|
endWrite(TASK, "title", titleSeq);
|
|
});
|
|
});
|
|
|
|
describe("recordWriteSuccess ordering relative to guard release (AC-58)", () => {
|
|
it("records the canonical value while the write's guard contribution is still held, before the caller releases it", () => {
|
|
seedInitialCanonical(TASK, "title", "v1", T10);
|
|
const seq = nextTaskSequence(TASK);
|
|
beginWrite(TASK, "title", seq);
|
|
|
|
recordWriteSuccess(TASK, "title", "v2", T11, seq);
|
|
|
|
// Canonical must already reflect the persisted value even though the
|
|
// write's guard contribution has not been released yet (endWrite has not
|
|
// run) — AC-58 requires recording strictly before the guard releases,
|
|
// not merely "eventually" once it does.
|
|
expect(getCanonicalValue(TASK, "title")).toBe("v2");
|
|
expect(isFieldGuarded(TASK, "title")).toBe(true);
|
|
|
|
endWrite(TASK, "title", seq);
|
|
expect(isFieldGuarded(TASK, "title")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("convergence after overlapping successful title commits (AC-54)", () => {
|
|
it("converges on the later-persisted title when the earlier commit's response is recorded second", () => {
|
|
const seen: string[] = [];
|
|
seedInitialCanonical(TASK, "title", "original", T10);
|
|
subscribeField(TASK, "title", (value) => seen.push(value));
|
|
|
|
const seqA = nextTaskSequence(TASK);
|
|
beginWrite(TASK, "title", seqA);
|
|
const seqB = nextTaskSequence(TASK);
|
|
beginWrite(TASK, "title", seqB);
|
|
|
|
// B was issued after A, but its server response is recorded first while
|
|
// A is still in flight, so the field stays guarded and nothing notifies yet.
|
|
recordWriteSuccess(TASK, "title", "B-title", T11, seqB);
|
|
endWrite(TASK, "title", seqB);
|
|
expect(getCanonicalValue(TASK, "title")).toBe("B-title");
|
|
expect(seen).toEqual([]);
|
|
|
|
// A's response arrives second, carrying a later updated_at (it was
|
|
// persisted after B on the server) and releases the guard.
|
|
recordWriteSuccess(TASK, "title", "A-title", T12, seqA);
|
|
endWrite(TASK, "title", seqA);
|
|
|
|
expect(getCanonicalValue(TASK, "title")).toBe("A-title");
|
|
expect(seen).toEqual(["A-title"]);
|
|
});
|
|
|
|
it("converges on the same later-persisted title regardless of which response is recorded first", () => {
|
|
seedInitialCanonical(TASK, "title", "original", T10);
|
|
|
|
const seqA = nextTaskSequence(TASK);
|
|
beginWrite(TASK, "title", seqA);
|
|
const seqB = nextTaskSequence(TASK);
|
|
beginWrite(TASK, "title", seqB);
|
|
|
|
// This time the earlier-issued commit's response, carrying the earlier
|
|
// updated_at, is recorded first.
|
|
recordWriteSuccess(TASK, "title", "A-title", T11, seqA);
|
|
endWrite(TASK, "title", seqA);
|
|
recordWriteSuccess(TASK, "title", "B-title", T12, seqB);
|
|
endWrite(TASK, "title", seqB);
|
|
|
|
expect(getCanonicalValue(TASK, "title")).toBe("B-title");
|
|
});
|
|
});
|
|
|
|
describe("deferred-apply notification (AC-27, AC-50)", () => {
|
|
it("notifies immediately when a candidate is accepted while unguarded", () => {
|
|
const seen: string[] = [];
|
|
subscribeField(TASK, "title", (value) => seen.push(value));
|
|
seedInitialCanonical(TASK, "title", "v1", T10);
|
|
recordRefetchCandidate(TASK, "title", "v2", T11, nextTaskSequence(TASK));
|
|
expect(seen).toEqual(["v1", "v2"]);
|
|
});
|
|
|
|
it("defers notification while guarded, then fires once the guard releases", () => {
|
|
const seen: string[] = [];
|
|
seedInitialCanonical(TASK, "title", "v1", T10);
|
|
subscribeField(TASK, "title", (value) => seen.push(value));
|
|
|
|
openFieldEditor(TASK, "title");
|
|
recordRefetchCandidate(TASK, "title", "v2", T11, nextTaskSequence(TASK));
|
|
expect(seen).toEqual([]); // guarded: no notification yet
|
|
|
|
closeFieldEditor(TASK, "title");
|
|
expect(seen).toEqual(["v2"]);
|
|
});
|
|
|
|
it("unsubscribe stops further notifications", () => {
|
|
const seen: string[] = [];
|
|
const unsubscribe = subscribeField(TASK, "title", (value) => seen.push(value));
|
|
seedInitialCanonical(TASK, "title", "v1", T10);
|
|
unsubscribe();
|
|
recordRefetchCandidate(TASK, "title", "v2", T11, nextTaskSequence(TASK));
|
|
expect(seen).toEqual(["v1"]);
|
|
});
|
|
});
|
|
|
|
describe("multi-task isolation", () => {
|
|
it("does not leak canonical state between tasks", () => {
|
|
seedInitialCanonical("task-a", "title", "A", T10);
|
|
seedInitialCanonical("task-b", "title", "B", T10);
|
|
expect(getCanonicalValue("task-a", "title")).toBe("A");
|
|
expect(getCanonicalValue("task-b", "title")).toBe("B");
|
|
});
|
|
});
|