ui/phase-26: history mode (turn navigator + read-only banner)
Split GameStateStore into currentTurn (server's latest) and viewedTurn (displayed snapshot) so history excursions don't corrupt the resume bookmark or the live-turn bound. Add viewTurn / returnToCurrent / historyMode rune, plus a game-history cache namespace that stores past-turn reports for fast re-entry. OrderDraftStore.bindClient takes a getHistoryMode getter and short-circuits add / remove / move while the user is viewing a past turn; RenderedReportSource skips the order overlay in the same case. Header replaces the static "turn N" with a clickable triplet (TurnNavigator), the layout mounts HistoryBanner under the header, and visibility-refresh is a no-op while history is active. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -809,3 +809,102 @@ describe("OrderDraftStore Phase 25 conflict / paused / offline", () => {
|
||||
store.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OrderDraftStore Phase 26 history-mode gate", () => {
|
||||
test("add is a no-op while getHistoryMode returns true", async () => {
|
||||
const store = new OrderDraftStore();
|
||||
await store.init({ cache, gameId: GAME_ID });
|
||||
await store.add(placeholder("c1", "first"));
|
||||
expect(store.commands.map((c) => c.id)).toEqual(["c1"]);
|
||||
|
||||
let history = false;
|
||||
// The store would short-circuit even without bindClient (the
|
||||
// gate runs before any sync logic). Binding a fake client
|
||||
// here mirrors the real layout where `bindClient` is the path
|
||||
// that wires `getHistoryMode` in.
|
||||
store.bindClient(
|
||||
{ executeCommand: async () => ({ resultCode: "ok", payloadBytes: new Uint8Array() }) } as never,
|
||||
{ getHistoryMode: () => history },
|
||||
);
|
||||
|
||||
history = true;
|
||||
await store.add(placeholder("c2", "second"));
|
||||
expect(store.commands.map((c) => c.id)).toEqual(["c1"]);
|
||||
|
||||
history = false;
|
||||
await store.add(placeholder("c3", "third"));
|
||||
expect(store.commands.map((c) => c.id)).toEqual(["c1", "c3"]);
|
||||
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("remove is a no-op while getHistoryMode returns true", async () => {
|
||||
const store = new OrderDraftStore();
|
||||
await store.init({ cache, gameId: GAME_ID });
|
||||
await store.add(placeholder("c1", "first"));
|
||||
await store.add(placeholder("c2", "second"));
|
||||
|
||||
let history = true;
|
||||
store.bindClient(
|
||||
{ executeCommand: async () => ({ resultCode: "ok", payloadBytes: new Uint8Array() }) } as never,
|
||||
{ getHistoryMode: () => history },
|
||||
);
|
||||
|
||||
await store.remove("c1");
|
||||
expect(store.commands.map((c) => c.id)).toEqual(["c1", "c2"]);
|
||||
|
||||
history = false;
|
||||
await store.remove("c1");
|
||||
expect(store.commands.map((c) => c.id)).toEqual(["c2"]);
|
||||
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("move is a no-op while getHistoryMode returns true", async () => {
|
||||
const store = new OrderDraftStore();
|
||||
await store.init({ cache, gameId: GAME_ID });
|
||||
await store.add(placeholder("c1", "first"));
|
||||
await store.add(placeholder("c2", "second"));
|
||||
await store.add(placeholder("c3", "third"));
|
||||
|
||||
let history = true;
|
||||
store.bindClient(
|
||||
{ executeCommand: async () => ({ resultCode: "ok", payloadBytes: new Uint8Array() }) } as never,
|
||||
{ getHistoryMode: () => history },
|
||||
);
|
||||
|
||||
await store.move(0, 2);
|
||||
expect(store.commands.map((c) => c.id)).toEqual(["c1", "c2", "c3"]);
|
||||
|
||||
history = false;
|
||||
await store.move(0, 2);
|
||||
expect(store.commands.map((c) => c.id)).toEqual(["c2", "c3", "c1"]);
|
||||
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("draft survives entering and leaving history mode untouched", async () => {
|
||||
const store = new OrderDraftStore();
|
||||
await store.init({ cache, gameId: GAME_ID });
|
||||
await store.add(placeholder("c1", "first"));
|
||||
await store.add(placeholder("c2", "second"));
|
||||
|
||||
let history = false;
|
||||
store.bindClient(
|
||||
{ executeCommand: async () => ({ resultCode: "ok", payloadBytes: new Uint8Array() }) } as never,
|
||||
{ getHistoryMode: () => history },
|
||||
);
|
||||
|
||||
history = true;
|
||||
// Inspector affordances try to push commands, gate refuses.
|
||||
await store.add(placeholder("c3", "history attempt"));
|
||||
expect(store.commands.map((c) => c.id)).toEqual(["c1", "c2"]);
|
||||
|
||||
history = false;
|
||||
expect(store.commands.map((c) => c.id)).toEqual(["c1", "c2"]);
|
||||
await store.add(placeholder("c4", "back live"));
|
||||
expect(store.commands.map((c) => c.id)).toEqual(["c1", "c2", "c4"]);
|
||||
|
||||
store.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user