src/lib/device-memory.test.ts
Rama main · réplica de lectura del repositorio original.
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
COVERED_KEYS,
DEVICE_MEMORY_OPT_OUT_KEY,
deviceMemoryClass,
installDeviceMemorySuppression,
isOptedOut,
setOptOut,
subscribeToOptOut,
} from "./device-memory";
import {
DIVE_PROFILE_SERIES_KEY,
GAS_USE_SERIES_KEY,
readStoredSeries,
writeSeriesVisibility,
} from "./chart-series-view";
import {
readStoredEntryUnits,
subscribeToEntryUnits,
writeEntryUnits,
} from "./entry-units";
import { dismissPasskeyNudge, isPasskeyNudgeDismissed } from "./passkey-nudge";
import {
DIVE_ACTIVITY_VIEW_KEY,
GAS_USE_VIEW_KEY,
writePeriodView,
} from "./chart-period-view";
import { memoryStorage, useStorage } from "@/test/memory-storage";
// What jsdom cannot answer, stated once so no test here pretends otherwise: the
// interposition's interplay with next-themes needs a real document. jsdom runs
// neither the pre-hydration script nor cross-document `storage` events, so an
// assertion here about first-paint fallback or about tab B's rewrite being
// dropped would pass against an implementation that does nothing at all. What
// is pinned below is the part that is pure logic - which keys are dropped,
// when, and on which receiver - and `device-memory-switch.render.test.tsx`
// pins the one theme assertion that is real under jsdom.
beforeEach(() => {
// The order matters and is the whole reason it is spelled out here rather
// than tucked into a helper: the harness swaps in a fresh store after every
// test, and a fresh store is an unwrapped one, so the storage has to exist
// before the interposition is installed on it.
useStorage(memoryStorage());
installDeviceMemorySuppression();
});
describe("deviceMemoryClass", () => {
it("classifies every key it knows and nothing else", () => {
expect(deviceMemoryClass("theme")).toBe("covered");
expect(deviceMemoryClass("opendiving:entry-units")).toBe("covered");
expect(deviceMemoryClass("opendiving:post-auth-redirect")).toBe("excluded");
expect(deviceMemoryClass(DEVICE_MEMORY_OPT_OUT_KEY)).toBe("excluded");
expect(deviceMemoryClass("opendiving:invented-here")).toBeNull();
});
});
describe("setOptOut", () => {
it("clears by prefix, not by census", () => {
const store = window.localStorage;
store.setItem("theme", "dark");
store.setItem("opendiving:entry-units", '{"depth":"imperial"}');
store.setItem("opendiving:gas-use-view", '{"scope":"year","anchor":null}');
// Four orphans left behind by key bumps and one by a deleted feature, none
// of which has a literal anywhere in this tree - the case that forced
// prefix clearing rather than a list.
store.setItem("opendiving:dive-profile-series", "[]");
store.setItem("opendiving:dive-profile-series-v2", "[]");
store.setItem("opendiving:dive-profile-series-v3", "[]");
store.setItem("opendiving:dive-profile-series-v4", "[]");
store.setItem("opendiving:last-auth-method", "google");
// And one nobody has invented yet, which is the point of the prefix rule.
store.setItem("opendiving:never-existed", "x");
// The exclusions, which have to survive.
store.setItem("opendiving:post-auth-redirect", "/dives");
store.setItem("opendiving:google-sign-in-attempts", "{}");
setOptOut(true);
expect(store.getItem(DEVICE_MEMORY_OPT_OUT_KEY)).not.toBeNull();
expect(store.getItem("opendiving:post-auth-redirect")).toBe("/dives");
expect(store.getItem("opendiving:google-sign-in-attempts")).toBe("{}");
for (const gone of [
"theme",
"opendiving:entry-units",
"opendiving:gas-use-view",
"opendiving:dive-profile-series",
"opendiving:dive-profile-series-v2",
"opendiving:dive-profile-series-v3",
"opendiving:dive-profile-series-v4",
"opendiving:last-auth-method",
"opendiving:never-existed",
]) {
expect(store.getItem(gone)).toBeNull();
}
});
it("removes only the flag when switched back off, and restores nothing", () => {
const store = window.localStorage;
store.setItem("theme", "dark");
setOptOut(true);
setOptOut(false);
expect(store.getItem(DEVICE_MEMORY_OPT_OUT_KEY)).toBeNull();
expect(store.getItem("theme")).toBeNull();
expect(isOptedOut()).toBe(false);
});
it("notifies subscribers, so both surfaces read one state", () => {
const listener = vi.fn();
const unsubscribe = subscribeToOptOut(listener);
setOptOut(true);
expect(listener).toHaveBeenCalledTimes(1);
expect(isOptedOut()).toBe(true);
unsubscribe();
setOptOut(false);
expect(listener).toHaveBeenCalledTimes(1);
});
it("says nothing and throws nothing where the browser refuses storage", () => {
useStorage(undefined);
expect(() => setOptOut(true)).not.toThrow();
expect(isOptedOut()).toBe(false);
});
});
describe("the interposition", () => {
it("drops a covered write only while the flag is actually present", () => {
const store = window.localStorage;
store.setItem("theme", "dark");
expect(store.getItem("theme")).toBe("dark");
setOptOut(true);
store.setItem("theme", "dark");
expect(store.getItem("theme")).toBeNull();
setOptOut(false);
store.setItem("theme", "dark");
expect(store.getItem("theme")).toBe("dark");
});
// The two-tab case, reduced to the only half jsdom can see. Tab A's flag
// reaches tab B through storage rather than through module state, so the
// check has to read storage on every write - a boolean cached at install
// time would let tab B's next-themes rewrite through.
it("honours a flag it never wrote itself", () => {
const store = window.localStorage;
store.setItem(DEVICE_MEMORY_OPT_OUT_KEY, "1");
store.setItem("theme", "dark");
expect(store.getItem("theme")).toBeNull();
});
it("passes everything else through byte-identical", () => {
const store = window.localStorage;
setOptOut(true);
store.setItem("opendiving:post-auth-redirect", "/dives/abc");
store.setItem("something-else-entirely", "kept");
expect(store.getItem("opendiving:post-auth-redirect")).toBe("/dives/abc");
expect(store.getItem("something-else-entirely")).toBe("kept");
store.removeItem("something-else-entirely");
expect(store.getItem("something-else-entirely")).toBeNull();
expect(store.getItem("nothing-was-ever-here")).toBeNull();
});
it("wraps the object that defines `setItem`, not the one handed to it", () => {
const { local, session, prototype } = sharedPrototypeStorages();
useStorage(local);
installDeviceMemorySuppression();
expect(Object.prototype.hasOwnProperty.call(local, "setItem")).toBe(false);
expect(Object.prototype.hasOwnProperty.call(prototype, "setItem")).toBe(
true,
);
setOptOut(true);
local.setItem("theme", "dark");
// The receiver guard: in a browser `sessionStorage` shares this prototype,
// and nothing about the objection reaches it.
session.setItem("theme", "dark");
expect(local.getItem("theme")).toBeNull();
expect(session.getItem("theme")).toBe("dark");
});
it("installs once, however many times it is called", () => {
const owner = window.localStorage;
const wrapped = owner.setItem;
installDeviceMemorySuppression();
installDeviceMemorySuppression();
expect(owner.setItem).toBe(wrapped);
});
it("is a no-op where there is no storage to interpose on", () => {
useStorage(undefined);
expect(() => installDeviceMemorySuppression()).not.toThrow();
});
});
describe("the covered writers under the switch", () => {
beforeEach(() => {
setOptOut(true);
});
it("keeps the entry-unit toggles live in-session while storing nothing", () => {
const listener = vi.fn();
const unsubscribe = subscribeToEntryUnits(listener);
writeEntryUnits({ depth: "imperial" });
// The module caches what was *asked for* rather than re-reading, which is
// what makes a refused write indistinguishable from Safari private mode -
// the boxes still flip, the choice simply is not remembered.
expect(readStoredEntryUnits()).toBe('{"depth":"imperial"}');
expect(listener).toHaveBeenCalled();
expect(window.localStorage.getItem("opendiving:entry-units")).toBeNull();
unsubscribe();
});
it("stores no chart selection", () => {
writeSeriesVisibility(DIVE_PROFILE_SERIES_KEY, ["depth"]);
writeSeriesVisibility(GAS_USE_SERIES_KEY, ["dives"]);
expect(readStoredSeries(DIVE_PROFILE_SERIES_KEY)).toBeNull();
expect(readStoredSeries(GAS_USE_SERIES_KEY)).toBeNull();
});
it("stores neither Home card's remembered view", () => {
writePeriodView(GAS_USE_VIEW_KEY, { scope: "year", anchor: null });
writePeriodView(DIVE_ACTIVITY_VIEW_KEY, { scope: "year", anchor: null });
expect(window.localStorage.getItem("opendiving:gas-use-view")).toBeNull();
expect(
window.localStorage.getItem("opendiving:dive-activity-view"),
).toBeNull();
});
it("keeps offering the passkey nudge, because nothing records the dismissal", () => {
dismissPasskeyNudge();
expect(isPasskeyNudgeDismissed()).toBe(false);
expect(
window.localStorage.getItem("opendiving:passkey-nudge-dismissed"),
).toBeNull();
});
it("covers every key it says it covers", () => {
for (const key of COVERED_KEYS) {
window.localStorage.setItem(key, "value");
expect(window.localStorage.getItem(key)).toBeNull();
}
});
});
// Two `Storage`s sharing one prototype, which is the shape a browser has and
// the shape `test/memory-storage.ts` deliberately does not: its stub's methods
// are own properties, so an interposition hard-coded to `Storage.prototype`
// would be invisible to every other test in this repo and the suppression suite
// would pass against an implementation that does nothing.
class SharedPrototypeStorage {
private readonly entries = new Map<string, string>();
get length(): number {
return this.entries.size;
}
clear(): void {
this.entries.clear();
}
getItem(key: string): string | null {
return this.entries.get(key) ?? null;
}
key(index: number): string | null {
return [...this.entries.keys()][index] ?? null;
}
removeItem(key: string): void {
this.entries.delete(key);
}
setItem(key: string, value: string): void {
this.entries.set(key, value);
}
}
function sharedPrototypeStorages(): {
local: Storage;
session: Storage;
prototype: object;
} {
return {
local: new SharedPrototypeStorage() as Storage,
session: new SharedPrototypeStorage() as Storage,
prototype: SharedPrototypeStorage.prototype,
};
}