- collections: the resync-failed-after-successful-write path now returns 422 (honest "reconcile on next sync" detail), NOT 502. The frontend treats 502/503/504 as gateway-unreachable and raises a sticky offline banner (api/core.ts RETRIABLE_GATEWAY); the app answered, so a 422 action-error toast is correct. Removes the false-offline regression the 502 introduced, and — since 422 isn't retried — the create double-write vector with it. - tests: make the mutate-then-resync rollback assertion non-vacuous — the resync stub now flushes a partial local change before raising, so the test proves db.rollback() actually undoes it; split into a write-failed (422) case and a resync-failed (422 + reconcile detail + rollback) case. - New-collection dup hint is now muted-tone with its own nameMaybeTaken copy (per-library uniqueness means the name may be free here) instead of the red authoritative nameTaken, which the still-enabled create flow contradicts. - history: cover the navCounter post-reload collision guard (ensureNavId advances the counter past a higher persisted sfScroll id).
67 lines
2.7 KiB
TypeScript
67 lines
2.7 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The per-history-entry scroll-id helpers + nav bus that back PageScroller's scroll restoration:
|
|
// a fresh id per new view (so a forward navigation opens at the top), a stable id per entry (so Back
|
|
// restores it), and a bus so a sub-view open that doesn't re-render the scroller still triggers it.
|
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
import { nextNavId, ensureNavId, peekNavId, subscribeNav, notifyNav } from "./history";
|
|
|
|
beforeEach(() => {
|
|
window.history.replaceState(null, "");
|
|
});
|
|
|
|
describe("nav scroll ids", () => {
|
|
it("nextNavId hands out strictly increasing ids", () => {
|
|
const a = nextNavId();
|
|
const b = nextNavId();
|
|
expect(b).toBeGreaterThan(a);
|
|
});
|
|
|
|
it("ensureNavId assigns an id to a stateless entry, then is idempotent", () => {
|
|
expect(peekNavId()).toBe(0); // no id yet
|
|
const id = ensureNavId();
|
|
expect(id).toBeGreaterThan(0);
|
|
expect(peekNavId()).toBe(id);
|
|
expect(ensureNavId()).toBe(id); // does not reassign
|
|
expect((window.history.state as { sfScroll?: number }).sfScroll).toBe(id);
|
|
});
|
|
|
|
it("ensureNavId preserves other history.state keys", () => {
|
|
window.history.replaceState({ sfPage: "plex" }, "");
|
|
ensureNavId();
|
|
expect((window.history.state as { sfPage?: string }).sfPage).toBe("plex");
|
|
});
|
|
|
|
it("a pushed entry keeps its own id (so Back restores the right one)", () => {
|
|
const first = ensureNavId();
|
|
window.history.pushState({ sfScroll: nextNavId() }, "");
|
|
const second = peekNavId();
|
|
expect(second).not.toBe(first);
|
|
window.history.back();
|
|
// jsdom doesn't truly pop synchronously, but the ids are distinct per entry — the invariant we need.
|
|
expect(second).toBeGreaterThan(first);
|
|
});
|
|
|
|
it("ensureNavId advances the counter past a higher persisted id (post-reload collision guard)", () => {
|
|
// After a full reload the module counter resets to 0 while sfScroll ids persist in history.state.
|
|
// Landing on an entry whose id is above the counter must push the counter past it, so the next
|
|
// minted id can't duplicate a live back-stack entry and mis-restore its scroll.
|
|
const high = 999999;
|
|
window.history.replaceState({ sfScroll: high }, "");
|
|
expect(ensureNavId()).toBe(high); // returns the persisted id, doesn't reassign
|
|
expect(nextNavId()).toBeGreaterThan(high); // and the next mint clears it
|
|
});
|
|
|
|
it("notifyNav fires subscribers until they unsubscribe", () => {
|
|
const cb = vi.fn();
|
|
const unsub = subscribeNav(cb);
|
|
notifyNav();
|
|
expect(cb).toHaveBeenCalledTimes(1);
|
|
notifyNav();
|
|
expect(cb).toHaveBeenCalledTimes(2);
|
|
unsub();
|
|
notifyNav();
|
|
expect(cb).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|