Files
noitu/web/tests/settings-store.test.js
T
tiennm99 d5f89665d8 test(web): cover the difficulty-hint table and the last-difficulty setting
difficultyHints is walked against the Difficulty schema the same way
difficultyLabels already is, and the settings store gets coverage for
lastDifficulty: starts unset, persists across a reload, ignores a corrupt
stored value, and stays usable in memory when storage is hostile.
2026-09-21 16:59:39 +07:00

198 lines
6.2 KiB
JavaScript

// @vitest-environment jsdom
// Settings are the only state the server has no opinion about, so the risk
// here is not correctness of the values but survival when storage refuses.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { Difficulty } from '../src/lib/proto/noitu/v1/game_pb.js';
import { MAX_NICKNAME_LENGTH, createSettingsStore } from '../src/lib/stores/settings.svelte.js';
const realStorage = globalThis.localStorage;
/** Replaces localStorage for one test. */
function useStorage(stub) {
Object.defineProperty(globalThis, 'localStorage', {
value: stub,
configurable: true,
writable: true
});
}
/** A storage that fails every operation, like a browser blocking site data. */
function hostileStorage() {
const boom = () => {
throw new DOMException('denied', 'SecurityError');
};
return { getItem: boom, setItem: boom, removeItem: boom, clear: boom, key: boom, length: 0 };
}
beforeEach(() => {
useStorage(realStorage);
localStorage.clear();
});
afterEach(() => {
useStorage(realStorage);
});
describe('nickname', () => {
it('persists what the player typed', () => {
const store = createSettingsStore();
store.setNickname('Minh');
expect(store.state.nickname).toBe('Minh');
expect(createSettingsStore().state.nickname).toBe('Minh');
});
it('caps at the server limit, counting characters rather than bytes', () => {
const store = createSettingsStore();
// Vietnamese is multi-byte; a byte cap would cut this far shorter.
store.setNickname('ăăăăăăăăăăăăăăăăăăăăăăăăă');
expect([...store.state.nickname]).toHaveLength(MAX_NICKNAME_LENGTH);
});
});
describe('theme', () => {
it('persists an explicit choice', () => {
const store = createSettingsStore();
store.setTheme('dark');
expect(store.state.theme).toBe('dark');
expect(createSettingsStore().state.theme).toBe('dark');
});
it('applies the theme to the document so the page repaints', () => {
const store = createSettingsStore();
store.setTheme('dark');
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
});
it('toggles between the two themes', () => {
const store = createSettingsStore();
store.setTheme('light');
store.toggleTheme();
expect(store.state.theme).toBe('dark');
store.toggleTheme();
expect(store.state.theme).toBe('light');
});
it('treats an unrecognised stored value as light rather than trusting it', () => {
localStorage.setItem('noitu.theme', 'neon');
expect(createSettingsStore().state.theme).toBe('light');
});
});
describe('best scores', () => {
it('records a first score as a new record', () => {
const store = createSettingsStore();
expect(store.recordScore(Difficulty.EASY, 30)).toBe(true);
expect(store.bestScore(Difficulty.EASY)).toBe(30);
});
it('keeps records separate per difficulty', () => {
const store = createSettingsStore();
store.recordScore(Difficulty.EASY, 30);
store.recordScore(Difficulty.HARD, 12);
expect(store.bestScore(Difficulty.EASY)).toBe(30);
expect(store.bestScore(Difficulty.HARD)).toBe(12);
expect(store.bestScore(Difficulty.MEDIUM)).toBe(0);
});
it('does not call a tie a new record', () => {
const store = createSettingsStore();
store.recordScore(Difficulty.EASY, 30);
expect(store.recordScore(Difficulty.EASY, 30)).toBe(false);
});
it('leaves the record alone when a later game scores lower', () => {
const store = createSettingsStore();
store.recordScore(Difficulty.EASY, 30);
expect(store.recordScore(Difficulty.EASY, 10)).toBe(false);
expect(store.bestScore(Difficulty.EASY)).toBe(30);
});
it('survives a reload', () => {
createSettingsStore().recordScore(Difficulty.MEDIUM, 44);
expect(createSettingsStore().bestScore(Difficulty.MEDIUM)).toBe(44);
});
it('degrades to no records when the stored value is not valid JSON', () => {
localStorage.setItem('noitu.bestScores', '{not json');
const store = createSettingsStore();
expect(store.bestScore(Difficulty.EASY)).toBe(0);
});
it('ignores entries that are not finite non-negative numbers', () => {
localStorage.setItem(
'noitu.bestScores',
JSON.stringify({ 1: 10, 2: 'lots', 3: -5, 4: null })
);
const store = createSettingsStore();
expect(store.bestScore(1)).toBe(10);
expect(store.bestScore(2)).toBe(0);
expect(store.bestScore(3)).toBe(0);
expect(store.bestScore(4)).toBe(0);
});
});
describe('last difficulty', () => {
it('starts with none picked, so a caller falls back to its own default', () => {
const store = createSettingsStore();
expect(store.state.lastDifficulty).toBeNull();
});
it('remembers the rung last picked', () => {
const store = createSettingsStore();
store.setLastDifficulty(Difficulty.HARD);
expect(store.state.lastDifficulty).toBe(Difficulty.HARD);
expect(createSettingsStore().state.lastDifficulty).toBe(Difficulty.HARD);
});
it('ignores a stored value that is not a number', () => {
localStorage.setItem('noitu.lastDifficulty', 'not-a-number');
expect(createSettingsStore().state.lastDifficulty).toBeNull();
});
});
describe('when storage is unavailable', () => {
it('constructs with working defaults instead of throwing', () => {
useStorage(hostileStorage());
const store = createSettingsStore();
expect(store.state.nickname).toBe('');
expect(store.state.bestScores).toEqual({});
expect(store.state.lastDifficulty).toBeNull();
expect(['light', 'dark']).toContain(store.state.theme);
});
it('keeps every setting usable in memory for the rest of the session', () => {
useStorage(hostileStorage());
const store = createSettingsStore();
expect(() => store.setNickname('Minh')).not.toThrow();
expect(() => store.setTheme('dark')).not.toThrow();
expect(store.recordScore(Difficulty.HARD, 7)).toBe(true);
expect(() => store.setLastDifficulty(Difficulty.EASY)).not.toThrow();
expect(store.state.nickname).toBe('Minh');
expect(store.state.theme).toBe('dark');
expect(store.bestScore(Difficulty.HARD)).toBe(7);
expect(store.state.lastDifficulty).toBe(Difficulty.EASY);
});
it('survives a browser that throws on the property itself', () => {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
get() {
throw new DOMException('denied', 'SecurityError');
}
});
expect(() => createSettingsStore()).not.toThrow();
});
});