feat(store): load/save and target accessors with validation
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(); },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user