Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 47 additions & 3 deletions src/components/player-settings-panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ import { NextIntlClientProvider } from "next-intl";
import messages from "../../messages/fr.json";
import {
PlayerSettingsPanel,
playerSettingsChangedEvent,
playerStorageKey,
replaceEquipmentSkills,
safePlayerSettings,
} from "./player-settings-panel";
import { defaultPlayerSettings } from "../lib/player-settings";
import { templarRates } from "../lib/gems-templars";

// Bloc 68/F: the league field is a LeagueButtons group now, not a <select>
Expand All @@ -32,6 +34,50 @@ describe("PlayerSettingsPanel", () => {
beforeEach(() => window.localStorage.clear());
afterEach(cleanup);

// Bloc 99: the panel used to answer its own save by replacing its state
// again. safePlayerSettings spread the stored object wholesale, so the
// version stamp `v` — storage bookkeeping, not a setting — came back out
// inside the settings, and syncFromStorage's equality guard therefore
// compared a value carrying `v` against one that never does: never equal,
// whatever the settings held. Each mount then ran a second write/broadcast
// cycle whose effect closure held the pre-transfer value, and an external
// write landing in that window (the Stuff simulator's transfer button) was
// overwritten by it — the transferred skills silently went back to 0.
it("Bloc99: hands back the settings alone, without storage bookkeeping", () => {
const settings = defaultPlayerSettings();
const stored = JSON.stringify({ ...settings, v: 2 });

expect(Object.keys(safePlayerSettings(stored))).toEqual(
Object.keys(settings),
);
// The comparison syncFromStorage makes, on settings that did not change:
// it has to hold, or the panel answers its own write with a new object.
expect(JSON.stringify(safePlayerSettings(stored))).toBe(
JSON.stringify(settings),
);
});

it("Bloc99: settles in a single save, instead of answering its own", async () => {
const broadcasts: unknown[] = [];
const listener = (event: Event) => broadcasts.push(event);
window.addEventListener(playerSettingsChangedEvent, listener);
try {
render(
<NextIntlClientProvider locale="fr" messages={messages}>
<PlayerSettingsPanel />
</NextIntlClientProvider>,
);
await waitFor(() =>
expect(window.localStorage.getItem(playerStorageKey)).not.toBeNull(),
);
// One save, one broadcast. A second one is the redundant cycle whose
// stale snapshot is what reverted an external transfer.
expect(broadcasts).toHaveLength(1);
} finally {
window.removeEventListener(playerSettingsChangedEvent, listener);
}
});

it("starts with no league selected", () => {
render(
<NextIntlClientProvider locale="fr" messages={messages}>
Expand Down Expand Up @@ -252,9 +298,7 @@ describe("PlayerSettingsPanel", () => {
<PlayerSettingsPanel />
</NextIntlClientProvider>,
);
expect(
screen.queryByRole("group", { name: "Ligue" }),
).not.toBeVisible();
expect(screen.queryByRole("group", { name: "Ligue" })).not.toBeVisible();
const line2 = screen.getByTestId("player-summary-line2");
expect(line2).toBeVisible();
// Attaque and Vitesse are temple skills: even with no input yet, their
Expand Down
29 changes: 19 additions & 10 deletions src/components/player-settings-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,25 +44,34 @@ function isTemplarKey(key: SkillKey): key is TemplarKey {
export function safePlayerSettings(raw: string): PlayerSettings {
const fallback = defaultPlayerSettings();
try {
const parsed = JSON.parse(raw) as Partial<PlayerSettings> & {
v?: number;
};
if (!("equipmentSkills" in parsed)) return fallback;
const clanTemple = { ...fallback.clanTemple, ...parsed.clanTemple };
if ((parsed.v ?? 1) < currentSettingsVersion && parsed.clanTemple) {
// Bloc 99: `v` is separated from the settings here rather than spread
// along with them. It is storage bookkeeping — PlayerSettings has no such
// field — and letting it ride into the returned object made every
// stored-vs-current comparison unequal by construction, whatever the
// settings held. The panel's syncFromStorage is one such comparison: it
// answered the panel's own save with a fresh object, and the extra
// write/broadcast cycle that followed carried a pre-transfer snapshot,
// which overwrote an external write (the Stuff simulator's transfer)
// that had landed in between.
const { v: storedVersion, ...saved } = JSON.parse(
raw,
) as Partial<PlayerSettings> & { v?: number };
if (!("equipmentSkills" in saved)) return fallback;
const clanTemple = { ...fallback.clanTemple, ...saved.clanTemple };
if ((storedVersion ?? 1) < currentSettingsVersion && saved.clanTemple) {
for (const key of templarKeys) {
clanTemple[key] = Math.max(0, clanTemple[key] - templeBase[key]);
}
}
return {
...fallback,
...parsed,
...saved,
equipmentSkills: {
...fallback.equipmentSkills,
...parsed.equipmentSkills,
...saved.equipmentSkills,
},
skillPoints: { ...fallback.skillPoints, ...parsed.skillPoints },
templars: { ...fallback.templars, ...parsed.templars },
skillPoints: { ...fallback.skillPoints, ...saved.skillPoints },
templars: { ...fallback.templars, ...saved.templars },
clanTemple,
};
} catch {
Expand Down
Loading