mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 12:28:54 +00:00
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.
198 lines
6.2 KiB
JavaScript
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();
|
|
});
|
|
});
|