diff --git a/src/store.js b/src/store.js new file mode 100644 index 0000000..73ace45 --- /dev/null +++ b/src/store.js @@ -0,0 +1,78 @@ +import { parseTarget } from './pure.js'; + +const KEY_TARGETS = 'tat.targets'; +const KEY_HISTORY = 'tat.history'; +const KEY_PREFS = 'tat.prefs'; + +const DEFAULT_PREFS = { version: 1, mode: 'free', pos: { x: 0, y: 0 } }; + +export class Store { + constructor({ storage, onWarn = (m) => console.warn(m) } = {}) { + if (!storage) throw new Error('Store requires a storage object'); + this.storage = storage; + this.onWarn = onWarn; + this.targets = this._loadJson(KEY_TARGETS, {}); + this.history = this._loadJson(KEY_HISTORY, {}); + this.prefs = this._mergePrefs(this._loadJson(KEY_PREFS, null)); + } + + _loadJson(key, fallback) { + let raw; + try { + raw = this.storage.getItem(key); + } catch { + return fallback; + } + if (raw == null) return fallback; + try { + return JSON.parse(raw); + } catch { + this.onWarn(`[tat] discarding corrupted ${key}`); + try { this.storage.removeItem(key); } catch { /* noop */ } + return fallback; + } + } + + _saveJson(key, value) { + try { + this.storage.setItem(key, JSON.stringify(value)); + return true; + } catch (e) { + this.onWarn(`[tat] failed to persist ${key}: ${e.message}`); + return false; + } + } + + _mergePrefs(loaded) { + if (!loaded || loaded.version !== 1) return { ...DEFAULT_PREFS }; + return { ...DEFAULT_PREFS, ...loaded }; + } + + getTarget(attr) { + const v = this.targets[attr]; + return typeof v === 'number' && v > 0 ? v : null; + } + + setTarget(attr, value) { + const parsed = parseTarget(value); + if (parsed === null) return false; + this.targets[attr] = parsed; + return this._saveJson(KEY_TARGETS, this.targets); + } + + getPrefs() { + return { ...this.prefs }; + } + + setMode(mode) { + if (mode !== 'free' && mode !== 'anchored') return false; + this.prefs.mode = mode; + return this._saveJson(KEY_PREFS, this.prefs); + } + + setPos(pos) { + if (!pos || typeof pos.x !== 'number' || typeof pos.y !== 'number') return false; + this.prefs.pos = { x: pos.x, y: pos.y }; + return this._saveJson(KEY_PREFS, this.prefs); + } +} diff --git a/tests/localstorage-shim.js b/tests/localstorage-shim.js new file mode 100644 index 0000000..307fa26 --- /dev/null +++ b/tests/localstorage-shim.js @@ -0,0 +1,9 @@ +export function createLocalStorage() { + const data = new Map(); + return { + getItem(k) { return data.has(k) ? data.get(k) : null; }, + setItem(k, v) { data.set(k, String(v)); }, + removeItem(k) { data.delete(k); }, + clear() { data.clear(); }, + }; +} diff --git a/tests/store.test.js b/tests/store.test.js new file mode 100644 index 0000000..286e2a9 --- /dev/null +++ b/tests/store.test.js @@ -0,0 +1,51 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { Store } from '../src/store.js'; +import { createLocalStorage } from './localstorage-shim.js'; + +function freshStore() { + return new Store({ storage: createLocalStorage() }); +} + +test('Store: starts empty', () => { + const s = freshStore(); + assert.equal(s.getTarget('strength'), null); +}); + +test('Store: setTarget validates via parseTarget', () => { + const s = freshStore(); + assert.equal(s.setTarget('strength', '25M'), true); + assert.equal(s.getTarget('strength'), 25_000_000); +}); + +test('Store: setTarget rejects invalid and keeps previous', () => { + const s = freshStore(); + s.setTarget('strength', 25_000_000); + assert.equal(s.setTarget('strength', 'abc'), false); + assert.equal(s.getTarget('strength'), 25_000_000); +}); + +test('Store: targets are per-attribute', () => { + const s = freshStore(); + s.setTarget('strength', 25_000_000); + s.setTarget('speed', 50_000_000); + assert.equal(s.getTarget('strength'), 25_000_000); + assert.equal(s.getTarget('speed'), 50_000_000); +}); + +test('Store: persists across instances via storage', () => { + const storage = createLocalStorage(); + const a = new Store({ storage }); + a.setTarget('strength', 25_000_000); + const b = new Store({ storage }); + assert.equal(b.getTarget('strength'), 25_000_000); +}); + +test('Store: corrupted JSON is wiped with a warning', () => { + const storage = createLocalStorage(); + storage.setItem('tat.targets', '{not json'); + const warnings = []; + const s = new Store({ storage, onWarn: (m) => warnings.push(m) }); + assert.equal(s.getTarget('strength'), null); + assert.equal(warnings.length, 1); +});