Files
inbuxa-webmail/web/src/lib/__tests__/theme.test.ts
T

184 lines
7.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, isDarkTheme, syncedPart, useSettings, type Theme } from "@/store/settings";
import { toggleTarget, type Mode, type PaletteId } from "@/lib/palette";
import { loadJson, saveJson, setDeviceTrusted } from "@/lib/storage";
/**
* "ihasmail" is a dark theme wearing ihasmail.org's palette. Everything that
* asks "is this dark?" has to say yes for it — the top-bar toggle picks its
* icon from the answer, and the message frame decides whether mail sits on a
* light card or follows the app. A theme that painted dark while reporting
* light would show a sun icon on a dark screen and light-card mail on it.
*/
describe("which themes paint dark", () => {
it("counts ihasmail as dark, regardless of the OS", () => {
expect(isDarkTheme("ihasmail", false)).toBe(true);
expect(isDarkTheme("ihasmail", true)).toBe(true);
});
it("still resolves the ordinary three the way it always did", () => {
expect(isDarkTheme("dark", false)).toBe(true);
expect(isDarkTheme("light", true)).toBe(false);
expect(isDarkTheme("system", true)).toBe(true);
expect(isDarkTheme("system", false)).toBe(false);
});
it("treats a missing OS preference as light, not as unknown", () => {
// matchMedia is absent in some embeddings; the default must not read dark.
expect(isDarkTheme("system")).toBe(false);
});
it("has an answer for every theme there is", () => {
// A theme added later without a branch here would silently paint light.
const all: Theme[] = ["system", "light", "dark", "ihasmail"];
for (const t of all) expect(typeof isDarkTheme(t, false), t).toBe("boolean");
});
});
describe("the default theme", () => {
it("is ihasmail, so a new account looks like ihasmail before anyone chooses", () => {
expect(DEFAULT_SETTINGS.theme).toBe("ihasmail");
});
/**
* The guarantee that matters when a default changes: it moves nobody who
* already has a theme stored — which is everyone using ihasmail today, since
* the setting is saved whether or not they deliberately picked it.
*
* `localStorage` is not available in this environment, and `saveJson`
* swallows that, so a plain round-trip here would pass for the wrong reason:
* both sides would be the fallback. Stub it, so what is under test is
* `loadJson`'s merge rather than the environment.
*/
const withStorage = (fn: () => void) => {
const store = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
},
});
// Reads and writes are gated on device trust now, and the gate defaults to
// closed. These tests are about `loadJson`'s merge, so open it and put it
// back -- an untrusted device is covered by storage.test.ts instead.
setDeviceTrusted(true);
try {
fn();
} finally {
setDeviceTrusted(false);
Reflect.deleteProperty(globalThis, "localStorage");
}
};
it("is only a default — a stored theme wins", () => {
withStorage(() => {
saveJson("theme-test", { ...DEFAULT_SETTINGS, theme: "light" });
expect(loadJson("theme-test", DEFAULT_SETTINGS).theme).toBe("light");
});
});
it("fills in from the default only for keys the stored settings lack", () => {
withStorage(() => {
// An older settings blob that predates a key must not lose the new one.
saveJson("theme-test-partial", { theme: "dark" });
const loaded = loadJson("theme-test-partial", DEFAULT_SETTINGS);
expect(loaded.theme).toBe("dark");
expect(loaded.accent).toBe(DEFAULT_SETTINGS.accent);
});
});
it("falls back to the default when nothing is stored", () => {
withStorage(() => {
expect(loadJson("theme-test-absent", DEFAULT_SETTINGS).theme).toBe("ihasmail");
});
});
});
describe("the top-bar toggle", () => {
it("goes to light from anything dark", () => {
expect(toggleTarget({ palette: "ihasmail", mode: "dark" }, false).mode).toBe("light");
expect(toggleTarget({ palette: "default", mode: "dark" }, false).mode).toBe("light");
expect(toggleTarget({ palette: "default", mode: "system" }, true).mode).toBe("light");
});
it("comes back to the palette you were actually on", () => {
// The whole point: two presses from ihasmail must return to ihasmail, not
// deposit you on plain dark.
const away = toggleTarget({ palette: "ihasmail", mode: "dark" }, false);
expect(toggleTarget(away, false).palette).toBe("ihasmail");
expect(toggleTarget({ palette: "default", mode: "light" }, false)).toMatchObject({ palette: "default", mode: "dark" });
});
});
describe("remembering the palette you were on", () => {
const set = (palette: PaletteId, mode: Mode) => {
useSettings.getState().update({ palette, mode });
return useSettings.getState().settings;
};
it("derives the legacy theme from whatever set the palette or mode", () => {
// `theme` is no longer chosen; it is kept in step so a device on an older
// build is not stranded on a theme nobody picked.
expect(set("default", "dark").theme).toBe("dark");
expect(set("ihasmail", "dark").theme).toBe("ihasmail");
expect(set("default", "system").theme).toBe("system");
expect(set("gruvbox", "light").theme).toBe("light");
expect(set("dracula", "dark").theme).toBe("dark");
});
it("survives a there-and-back through the toggle", () => {
// Two presses return you exactly where you started, and the palette never
// moves -- which is the whole of what the old lastDarkTheme existed for.
set("ihasmail", "dark");
const away = toggleTarget({ palette: "ihasmail", mode: "dark" }, false);
expect(away).toEqual({ palette: "ihasmail", mode: "light" });
expect(toggleTarget(away, false)).toEqual({ palette: "ihasmail", mode: "dark" });
});
it("keeps the colors when the palette has both sides", () => {
const away = toggleTarget({ palette: "gruvbox", mode: "dark" }, false);
expect(away.palette).toBe("gruvbox");
expect(away.mode).toBe("light");
});
});
describe("where the theme settings live", () => {
it("follows the account, not the browser", () => {
// Both of these ride in the account's settings.json, so a theme chosen on
// one machine — and the toggle's way back to it — are the same everywhere.
// Named explicitly rather than derived from DEVICE_KEYS: the test that
// does derive it would still pass if one of these were moved there, since
// its expectation would move too.
const synced = syncedPart(DEFAULT_SETTINGS);
expect(synced).toHaveProperty("theme");
expect(synced).toHaveProperty("palette");
expect(synced).toHaveProperty("mode");
for (const k of ["theme", "palette", "mode"] as const) {
expect(DEVICE_KEYS.has(k)).toBe(false);
}
});
it("is applied from a settings file another device wrote", () => {
expect(acceptRemote({ palette: "gruvbox", mode: "light" })).toEqual({ palette: "gruvbox", mode: "light" });
});
it("reads a file written before palettes existed through the old enum", () => {
// Settings live in the account's Files and are opened by whatever version
// runs next, so this is not a one-release migration.
expect(acceptRemote({ theme: "ihasmail" })).toMatchObject({ palette: "ihasmail", mode: "dark" });
expect(acceptRemote({ theme: "light" })).toMatchObject({ palette: "default", mode: "light" });
});
it("prefers the new fields when a file carries both", () => {
// A file with both is newer, and its `theme` is the derived copy rather
// than the choice -- so it must not overrule the palette beside it.
expect(acceptRemote({ theme: "dark", palette: "rose-pine", mode: "light" })).toMatchObject({
palette: "rose-pine",
mode: "light",
});
});
});