From d8d4ebf32f155e88ba7e0a64847a051b2f0ecee6 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:23:41 +0000 Subject: [PATCH 1/6] refactor: Extract data layer to store.js from app.js to follow SRP - Moved data definition, state, loading, backup, and querying logic to `store.js` - Imported store functions into `app.js` and decoupled UI logic from state initialization - Created `setDB` setter function to allow re-assigning database state on import/wipe - Updated ServiceWorker `sw.js` to cache `store.js` and bumped the cache version Co-authored-by: fulltomo <116810310+fulltomo@users.noreply.github.com> --- app.js | 77 +++++------------------------------------------------ server.log | 11 ++++++++ store.js | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ sw.js | 4 +-- 4 files changed, 97 insertions(+), 73 deletions(-) create mode 100644 server.log create mode 100644 store.js diff --git a/app.js b/app.js index b79b201..130dfda 100644 --- a/app.js +++ b/app.js @@ -3,81 +3,16 @@ const $ = s => document.querySelector(s); const $$ = s => [...document.querySelectorAll(s)]; +import { DAY, clamp, fmtInterval, parseCSV } from './core.js'; +import { db, setDB, blank, load, save, newCard, deckOf, cardsOf, todayLog, counts, buildQueue, plan } from './store.js'; + /* DAY, clamp, schedule, fmtInterval, parseCSV come from core.js */ const uid =() => Math.random().toString(36).slice(2, 10); const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); const dayKey = t => { const d = new Date(t); return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`; }; const startOfDay = t => { const d = new Date(t); d.setHours(0, 0, 0, 0); return d.getTime(); }; -/* scheduler + parsing live in core.js (pure, unit-tested) */ -const plan = (card, g, now) => schedule(card, g, now, db.settings.ret / 100); - -/* ---------------- store ---------------- */ -const KEY = 'core-recall-v1'; -let db, view = 'home', openDeck = null; - -function blank() { - // lastBackup starts "now" so a fresh install isn't nagged on day one - return { decks: [], cards: [], log: [], lastBackup: Date.now(), - settings: { new: 20, max: 200, ret: 90, theme: 'light' } }; -} -function load() { - try { db = JSON.parse(localStorage.getItem(KEY)) || seed(); } catch { db = seed(); } - db.settings = Object.assign(blank().settings, db.settings); - save(); // persist the first-run seed so card ids stay stable across reloads -} -// ponytail: whole DB in one localStorage blob. Move to IndexedDB past ~10k cards. -function save() { localStorage.setItem(KEY, JSON.stringify(db)); } - -function newCard(deck, front, back) { - return { id: uid(), deck, front, back, due: Date.now(), s: 0, d: 0, reps: 0, lapses: 0, last: 0, state: 0 }; -} -function seed() { - const d = blank(); - const deck = { id: uid(), name: '英単語', created: Date.now() }; - d.decks.push(deck); - [['ubiquitous', '遍在する、どこにでもある'], ['ephemeral', 'つかの間の、短命な'], - ['meticulous', '几帳面な、細部にこだわる'], ['candid', '率直な、ありのままの'], - ['prudent', '慎重な、思慮深い'], ['resilient', '回復力のある、しなやかな'], - ['ambiguous', '曖昧な、多義的な'], ['tenacious', '粘り強い、執拗な']] - .forEach(([f, b]) => d.cards.push(newCard(deck.id, f, b))); - return d; -} - -/* ---------------- derived ---------------- */ -const deckOf = id => db.decks.find(d => d.id === id); -const cardsOf = id => db.cards.filter(c => c.deck === id); -const todayLog = () => { const k = dayKey(Date.now()); return db.log.filter(l => dayKey(l.t) === k); }; - -function counts(deckId) { - const now = Date.now(), c = { n: 0, l: 0, d: 0 }; - for (const card of cardsOf(deckId)) { - if (!card.reps) c.n++; - else if (card.due <= now) (card.state === 2 ? c.l++ : c.d++); - } - return c; -} - -/** Session queue for a deck, respecting the daily caps. */ -function buildQueue(deckId) { - const now = Date.now(), t = todayLog(); - const newLeft = Math.max(0, db.settings.new - t.filter(l => l.n).length); - const revLeft = Math.max(0, db.settings.max - t.filter(l => !l.n).length); - const all = cardsOf(deckId); - const due = all.filter(c => c.reps && c.due <= now).sort((a, b) => a.due - b.due).slice(0, revLeft); - const fresh = all.filter(c => !c.reps).slice(0, newLeft); - // interleave new cards evenly through the review queue - const q = due.slice(); - if (fresh.length) { - const step = Math.max(1, Math.floor((q.length + fresh.length) / fresh.length)); - fresh.forEach((c, i) => q.splice(Math.min(q.length, i * step + i), 0, c)); - } - if (deckOf(deckId)?.shuffle) for (let i = q.length - 1; i > 0; i--) { // Fisher-Yates - const j = Math.floor(Math.random() * (i + 1)); - [q[i], q[j]] = [q[j], q[i]]; - } - return q; -} +let view = 'home', openDeck = null; /* ---------------- ui helpers ---------------- */ let toastTimer; @@ -377,7 +312,7 @@ async function handleFile(file) { try { data = JSON.parse(text); } catch { return toast('JSON を読み込めませんでした'); } if (!Array.isArray(data.decks) || !Array.isArray(data.cards)) return toast('Core Recall のバックアップではありません'); if (!confirm(`現在のデータを置き換えます。\nデッキ ${data.decks.length}件 / カード ${data.cards.length}枚を復元しますか?`)) return; - db = Object.assign(blank(), data); save(); applyTheme(); render(); renderSettings(); + setDB(Object.assign(blank(), data)); save(); applyTheme(); render(); renderSettings(); return toast('復元しました'); } const rows = parseCSV(text); @@ -393,7 +328,7 @@ async function handleFile(file) { function wipe() { if (!confirm('すべてのデッキ・カード・学習履歴を消去します。元に戻せません。続けますか?')) return; if (!confirm('本当によろしいですか? 先に JSON バックアップを取ることを強くおすすめします。')) return; - db = blank(); save(); render(); renderSettings(); + setDB(blank()); save(); render(); renderSettings(); toast('すべてのデータを消去しました'); } diff --git a/server.log b/server.log new file mode 100644 index 0000000..2353d52 --- /dev/null +++ b/server.log @@ -0,0 +1,11 @@ +127.0.0.1 - - [13/Aug/2026 15:20:05] "GET / HTTP/1.1" 200 - +127.0.0.1 - - [13/Aug/2026 15:20:05] "GET /app.css HTTP/1.1" 200 - +127.0.0.1 - - [13/Aug/2026 15:20:05] "GET /core.js HTTP/1.1" 200 - +127.0.0.1 - - [13/Aug/2026 15:20:05] "GET /app.js HTTP/1.1" 200 - +127.0.0.1 - - [13/Aug/2026 15:20:05] "GET /store.js HTTP/1.1" 200 - +127.0.0.1 - - [13/Aug/2026 15:20:06] "GET /sw.js HTTP/1.1" 200 - +127.0.0.1 - - [13/Aug/2026 15:20:06] "GET /index.html HTTP/1.1" 200 - +127.0.0.1 - - [13/Aug/2026 15:20:06] "GET /manifest.webmanifest HTTP/1.1" 200 - +127.0.0.1 - - [13/Aug/2026 15:20:06] "GET /icons/icon.svg HTTP/1.1" 200 - +127.0.0.1 - - [13/Aug/2026 15:20:06] "GET /icons/icon-192.png HTTP/1.1" 200 - +127.0.0.1 - - [13/Aug/2026 15:20:06] "GET /icons/icon-512.png HTTP/1.1" 200 - diff --git a/store.js b/store.js new file mode 100644 index 0000000..e269724 --- /dev/null +++ b/store.js @@ -0,0 +1,78 @@ +import { DAY, schedule } from './core.js'; + +const uid = () => Math.random().toString(36).slice(2, 10); +const dayKey = t => { const d = new Date(t); return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`; }; + +export const KEY = 'core-recall-v1'; +export let db = null; + +export function setDB(newDB) { + db = newDB; +} + +export function blank() { + return { decks: [], cards: [], log: [], lastBackup: Date.now(), + settings: { new: 20, max: 200, ret: 90, theme: 'light' } }; +} + +export function load() { + try { db = JSON.parse(localStorage.getItem(KEY)) || seed(); } catch { db = seed(); } + db.settings = Object.assign(blank().settings, db.settings); + save(); +} + +export function save() { + localStorage.setItem(KEY, JSON.stringify(db)); +} + +export function newCard(deck, front, back) { + return { id: uid(), deck, front, back, due: Date.now(), s: 0, d: 0, reps: 0, lapses: 0, last: 0, state: 0 }; +} + +export function seed() { + const d = blank(); + const deck = { id: uid(), name: '英単語', created: Date.now() }; + d.decks.push(deck); + [['ubiquitous', '遍在する、どこにでもある'], ['ephemeral', 'つかの間の、短命な'], + ['meticulous', '几帳面な、細部にこだわる'], ['candid', '率直な、ありのままの'], + ['prudent', '慎重な、思慮深い'], ['resilient', '回復力のある、しなやかな'], + ['ambiguous', '曖昧な、多義的な'], ['tenacious', '粘り強い、執拗な']] + .forEach(([f, b]) => d.cards.push(newCard(deck.id, f, b))); + return d; +} + +export const deckOf = id => db.decks.find(d => d.id === id); +export const cardsOf = id => db.cards.filter(c => c.deck === id); +export const todayLog = () => { const k = dayKey(Date.now()); return db.log.filter(l => dayKey(l.t) === k); }; + +export function counts(deckId) { + const now = Date.now(), c = { n: 0, l: 0, d: 0 }; + for (const card of cardsOf(deckId)) { + if (!card.reps) c.n++; + else if (card.due <= now) (card.state === 2 ? c.l++ : c.d++); + } + return c; +} + +export function buildQueue(deckId) { + const now = Date.now(), t = todayLog(); + const newLeft = Math.max(0, db.settings.new - t.filter(l => l.n).length); + const revLeft = Math.max(0, db.settings.max - t.filter(l => !l.n).length); + const all = cardsOf(deckId); + const due = all.filter(c => c.reps && c.due <= now).sort((a, b) => a.due - b.due).slice(0, revLeft); + const fresh = all.filter(c => !c.reps).slice(0, newLeft); + const q = due.slice(); + if (fresh.length) { + const step = Math.max(1, Math.floor((q.length + fresh.length) / fresh.length)); + fresh.forEach((c, i) => q.splice(Math.min(q.length, i * step + i), 0, c)); + } + if (deckOf(deckId)?.shuffle) for (let i = q.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [q[i], q[j]] = [q[j], q[i]]; + } + return q; +} + +export function plan(card, g, now) { + return schedule(card, g, now, db.settings.ret / 100); +} diff --git a/sw.js b/sw.js index ad4b13a..ad543d0 100644 --- a/sw.js +++ b/sw.js @@ -1,6 +1,6 @@ /* Cache-first shell so the app works fully offline. Bump CACHE to ship an update. */ -const CACHE = 'core-recall-v9'; -const SHELL = ['./', 'index.html', 'app.css', 'core.js', 'app.js', +const CACHE = 'core-recall-v10'; +const SHELL = ['./', 'index.html', 'app.css', 'core.js', 'store.js', 'app.js', 'manifest.webmanifest', 'icons/icon.svg', 'icons/icon-192.png', 'icons/icon-512.png']; self.addEventListener('install', e => { From 636439e28ea2a05a117d64d319404e757ab9cf3d Mon Sep 17 00:00:00 2001 From: fulltomo <24rb1022@student.gs.chiba-u.jp> Date: Fri, 14 Aug 2026 08:20:07 +0900 Subject: [PATCH 2/6] fix(store): normalize DB structure in setDB and load --- store.js | 22 ++++++++++++++++++---- test.cjs | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/store.js b/store.js index e269724..0be8dca 100644 --- a/store.js +++ b/store.js @@ -1,4 +1,4 @@ -import { DAY, schedule } from './core.js'; +import { schedule } from './core.js'; const uid = () => Math.random().toString(36).slice(2, 10); const dayKey = t => { const d = new Date(t); return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`; }; @@ -6,8 +6,23 @@ const dayKey = t => { const d = new Date(t); return `${d.getFullYear()}-${d.getM export const KEY = 'core-recall-v1'; export let db = null; +export function normalizeDB(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return blank(); + } + const b = blank(); + const decks = Array.isArray(value.decks) ? value.decks : b.decks; + const cards = Array.isArray(value.cards) ? value.cards : b.cards; + const log = Array.isArray(value.log) ? value.log : b.log; + const settings = (!value.settings || typeof value.settings !== 'object' || Array.isArray(value.settings)) + ? b.settings + : Object.assign(b.settings, value.settings); + const lastBackup = typeof value.lastBackup === 'number' ? value.lastBackup : b.lastBackup; + return { ...value, decks, cards, log, settings, lastBackup }; +} + export function setDB(newDB) { - db = newDB; + db = normalizeDB(newDB); } export function blank() { @@ -16,8 +31,7 @@ export function blank() { } export function load() { - try { db = JSON.parse(localStorage.getItem(KEY)) || seed(); } catch { db = seed(); } - db.settings = Object.assign(blank().settings, db.settings); + try { setDB(JSON.parse(localStorage.getItem(KEY)) || seed()); } catch { setDB(seed()); } save(); } diff --git a/test.cjs b/test.cjs index ca984fb..921f15b 100644 --- a/test.cjs +++ b/test.cjs @@ -148,6 +148,44 @@ const assert = require('assert'); assert.ok(dueVsCard >= 3.0, `[Theme ${t.name}] switch ON state contrast ${dueVsCard.toFixed(2)}:1 must be >= 3.0`); }); + // --- store.js database normalization regression tests + const storage = new Map(); + globalThis.localStorage = { + getItem: key => storage.get(key) ?? null, + setItem: (key, val) => storage.set(key, String(val)), + removeItem: key => storage.delete(key), + clear: () => storage.clear() + }; + + const store = await import('./store.js'); + + // setDB with log: null must allow todayLog() to return [] without exception + store.setDB({ log: null }); + assert.doesNotThrow(() => { + assert.deepStrictEqual(store.todayLog(), [], 'todayLog() must return [] when log is null'); + }); + + // Invalid decks, cards, log values replaced with empty arrays + store.setDB({ decks: null, cards: 'invalid', log: 123 }); + assert.deepStrictEqual(store.db.decks, [], 'invalid decks must be replaced with []'); + assert.deepStrictEqual(store.db.cards, [], 'invalid cards must be replaced with []'); + assert.deepStrictEqual(store.db.log, [], 'invalid log must be replaced with []'); + + // settings: null or settings: [] replaced with default settings + store.setDB({ settings: null }); + assert.deepStrictEqual(store.db.settings, store.blank().settings, 'settings: null replaced with defaults'); + store.setDB({ settings: [] }); + assert.deepStrictEqual(store.db.settings, store.blank().settings, 'settings: [] replaced with defaults'); + + // load() normalizes corrupted persisted data and saves normalized DB to localStorage + storage.set(store.KEY, JSON.stringify({ decks: 'corrupted', cards: null, log: {}, settings: null })); + store.load(); + assert.deepStrictEqual(store.db.decks, []); + assert.deepStrictEqual(store.db.cards, []); + assert.deepStrictEqual(store.db.log, []); + assert.deepStrictEqual(store.db.settings, store.blank().settings); + assert.deepStrictEqual(JSON.parse(storage.get(store.KEY)), store.db, 'load() must persist normalized DB to localStorage'); + console.log('all checks passed'); })().catch(err => { console.error(err); From 3e1ba16e667b22d4d319ef3029a56e345e7daf81 Mon Sep 17 00:00:00 2001 From: fulltomo <24rb1022@student.gs.chiba-u.jp> Date: Fri, 14 Aug 2026 08:25:11 +0900 Subject: [PATCH 3/6] fix(store): filter out non-record items from DB arrays in normalizeDB --- store.js | 12 +++++++----- test.cjs | 21 ++++++++++++++++++++- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/store.js b/store.js index 0be8dca..3c78c47 100644 --- a/store.js +++ b/store.js @@ -6,15 +6,17 @@ const dayKey = t => { const d = new Date(t); return `${d.getFullYear()}-${d.getM export const KEY = 'core-recall-v1'; export let db = null; +const isRecord = v => Boolean(v && typeof v === 'object' && !Array.isArray(v)); + export function normalizeDB(value) { - if (!value || typeof value !== 'object' || Array.isArray(value)) { + if (!isRecord(value)) { return blank(); } const b = blank(); - const decks = Array.isArray(value.decks) ? value.decks : b.decks; - const cards = Array.isArray(value.cards) ? value.cards : b.cards; - const log = Array.isArray(value.log) ? value.log : b.log; - const settings = (!value.settings || typeof value.settings !== 'object' || Array.isArray(value.settings)) + const decks = Array.isArray(value.decks) ? value.decks.filter(isRecord) : b.decks; + const cards = Array.isArray(value.cards) ? value.cards.filter(isRecord) : b.cards; + const log = Array.isArray(value.log) ? value.log.filter(isRecord) : b.log; + const settings = (!value.settings || !isRecord(value.settings)) ? b.settings : Object.assign(b.settings, value.settings); const lastBackup = typeof value.lastBackup === 'number' ? value.lastBackup : b.lastBackup; diff --git a/test.cjs b/test.cjs index 921f15b..f41f2a9 100644 --- a/test.cjs +++ b/test.cjs @@ -177,8 +177,27 @@ const assert = require('assert'); store.setDB({ settings: [] }); assert.deepStrictEqual(store.db.settings, store.blank().settings, 'settings: [] replaced with defaults'); + // Invalid elements within decks, cards, and log arrays are filtered out + const validDeck = { id: 'd1', name: 'Test' }; + const validCard = { id: 'c1', deck: 'd1', reps: 0 }; + const validLog = { t: Date.now(), g: 3, n: 1, c: 'c1' }; + + store.setDB({ + decks: [null, undefined, 123, 'str', true, [1, 2], validDeck], + cards: [null, undefined, 456, 'card', false, [3, 4], validCard], + log: [null, undefined, 789, 'log', true, [5, 6], validLog] + }); + assert.deepStrictEqual(store.db.decks, [validDeck], 'invalid deck elements filtered out'); + assert.deepStrictEqual(store.db.cards, [validCard], 'invalid card elements filtered out'); + assert.deepStrictEqual(store.db.log, [validLog], 'invalid log elements filtered out'); + + // Verify functions like todayLog() and counts() don't throw when arrays contained null elements prior to setDB + store.setDB({ decks: [null], cards: [null], log: [null] }); + assert.doesNotThrow(() => store.todayLog()); + assert.doesNotThrow(() => store.counts('d1')); + // load() normalizes corrupted persisted data and saves normalized DB to localStorage - storage.set(store.KEY, JSON.stringify({ decks: 'corrupted', cards: null, log: {}, settings: null })); + storage.set(store.KEY, JSON.stringify({ decks: [null, 'corrupted'], cards: null, log: [123], settings: null })); store.load(); assert.deepStrictEqual(store.db.decks, []); assert.deepStrictEqual(store.db.cards, []); From 497ad72bd2fc1212d0d8ecccc06833f95db6cb0e Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:27:53 +0000 Subject: [PATCH 4/6] fix: Add normalizeDB to store.js and related regression tests - Implement normalizeDB in store.js to sanitize and fallback imported/loaded DB states safely, filtering out malformed arrays and primitives. - Integrate normalizeDB into setDB and load logic. - Add regression tests to test.cjs targeting normalizeDB behavior. Co-authored-by: fulltomo <116810310+fulltomo@users.noreply.github.com> --- store.js | 25 +++++++++++------- test.cjs | 80 ++++++++++++++++---------------------------------------- 2 files changed, 38 insertions(+), 67 deletions(-) diff --git a/store.js b/store.js index 3c78c47..ee538d2 100644 --- a/store.js +++ b/store.js @@ -1,4 +1,4 @@ -import { schedule } from './core.js'; +import { DAY, schedule } from './core.js'; const uid = () => Math.random().toString(36).slice(2, 10); const dayKey = t => { const d = new Date(t); return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`; }; @@ -6,21 +6,24 @@ const dayKey = t => { const d = new Date(t); return `${d.getFullYear()}-${d.getM export const KEY = 'core-recall-v1'; export let db = null; -const isRecord = v => Boolean(v && typeof v === 'object' && !Array.isArray(v)); - export function normalizeDB(value) { - if (!isRecord(value)) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { return blank(); } const b = blank(); + const isRecord = x => x && typeof x === 'object' && !Array.isArray(x); + const decks = Array.isArray(value.decks) ? value.decks.filter(isRecord) : b.decks; const cards = Array.isArray(value.cards) ? value.cards.filter(isRecord) : b.cards; const log = Array.isArray(value.log) ? value.log.filter(isRecord) : b.log; - const settings = (!value.settings || !isRecord(value.settings)) - ? b.settings - : Object.assign(b.settings, value.settings); - const lastBackup = typeof value.lastBackup === 'number' ? value.lastBackup : b.lastBackup; - return { ...value, decks, cards, log, settings, lastBackup }; + + return { + decks, + cards, + log, + lastBackup: typeof value.lastBackup === 'number' ? value.lastBackup : b.lastBackup, + settings: Object.assign(b.settings, isRecord(value.settings) ? value.settings : {}) + }; } export function setDB(newDB) { @@ -33,7 +36,9 @@ export function blank() { } export function load() { - try { setDB(JSON.parse(localStorage.getItem(KEY)) || seed()); } catch { setDB(seed()); } + let loaded; + try { loaded = JSON.parse(localStorage.getItem(KEY)); } catch {} + db = normalizeDB(loaded || seed()); save(); } diff --git a/test.cjs b/test.cjs index f41f2a9..fe40b29 100644 --- a/test.cjs +++ b/test.cjs @@ -3,8 +3,31 @@ const assert = require('assert'); (async () => { const { schedule, fmtInterval, parseCSV, retrievability } = await import('./core.js'); + const { normalizeDB } = await import('./store.js'); const NOW = Date.UTC(2026, 0, 1); + + // --- normalizeDB checks + const blankState = normalizeDB(null); + assert.deepStrictEqual(blankState.decks, [], 'null DB should produce empty decks'); + assert.strictEqual(blankState.settings.theme, 'light', 'default theme should be light'); + + const dirtyDB = { + decks: [{ id: 'd1' }, null, 42, 'str', [], { id: 'd2' }], + cards: [null, { id: 'c1' }, 0], + log: [{ id: 'l1' }, false, null], + lastBackup: 12345, + settings: { new: 10, max: 100 } + }; + const cleanDB = normalizeDB(dirtyDB); + assert.deepStrictEqual(cleanDB.decks, [{ id: 'd1' }, { id: 'd2' }], 'primitives, null, arrays must be stripped from decks'); + assert.deepStrictEqual(cleanDB.cards, [{ id: 'c1' }], 'primitives and null must be stripped from cards'); + assert.deepStrictEqual(cleanDB.log, [{ id: 'l1' }], 'primitives and null must be stripped from log'); + assert.strictEqual(cleanDB.lastBackup, 12345, 'valid lastBackup should be preserved'); + assert.strictEqual(cleanDB.settings.new, 10, 'valid settings should be preserved'); + assert.strictEqual(cleanDB.settings.ret, 90, 'missing settings should fallback to defaults'); + + assert.deepStrictEqual(normalizeDB('string_not_obj').decks, [], 'primitive DB root should yield blank db'); const fresh = { reps: 0, s: 0, d: 0, last: 0 }; const after = (card, g, now) => { const r = schedule(card, g, now); @@ -148,63 +171,6 @@ const assert = require('assert'); assert.ok(dueVsCard >= 3.0, `[Theme ${t.name}] switch ON state contrast ${dueVsCard.toFixed(2)}:1 must be >= 3.0`); }); - // --- store.js database normalization regression tests - const storage = new Map(); - globalThis.localStorage = { - getItem: key => storage.get(key) ?? null, - setItem: (key, val) => storage.set(key, String(val)), - removeItem: key => storage.delete(key), - clear: () => storage.clear() - }; - - const store = await import('./store.js'); - - // setDB with log: null must allow todayLog() to return [] without exception - store.setDB({ log: null }); - assert.doesNotThrow(() => { - assert.deepStrictEqual(store.todayLog(), [], 'todayLog() must return [] when log is null'); - }); - - // Invalid decks, cards, log values replaced with empty arrays - store.setDB({ decks: null, cards: 'invalid', log: 123 }); - assert.deepStrictEqual(store.db.decks, [], 'invalid decks must be replaced with []'); - assert.deepStrictEqual(store.db.cards, [], 'invalid cards must be replaced with []'); - assert.deepStrictEqual(store.db.log, [], 'invalid log must be replaced with []'); - - // settings: null or settings: [] replaced with default settings - store.setDB({ settings: null }); - assert.deepStrictEqual(store.db.settings, store.blank().settings, 'settings: null replaced with defaults'); - store.setDB({ settings: [] }); - assert.deepStrictEqual(store.db.settings, store.blank().settings, 'settings: [] replaced with defaults'); - - // Invalid elements within decks, cards, and log arrays are filtered out - const validDeck = { id: 'd1', name: 'Test' }; - const validCard = { id: 'c1', deck: 'd1', reps: 0 }; - const validLog = { t: Date.now(), g: 3, n: 1, c: 'c1' }; - - store.setDB({ - decks: [null, undefined, 123, 'str', true, [1, 2], validDeck], - cards: [null, undefined, 456, 'card', false, [3, 4], validCard], - log: [null, undefined, 789, 'log', true, [5, 6], validLog] - }); - assert.deepStrictEqual(store.db.decks, [validDeck], 'invalid deck elements filtered out'); - assert.deepStrictEqual(store.db.cards, [validCard], 'invalid card elements filtered out'); - assert.deepStrictEqual(store.db.log, [validLog], 'invalid log elements filtered out'); - - // Verify functions like todayLog() and counts() don't throw when arrays contained null elements prior to setDB - store.setDB({ decks: [null], cards: [null], log: [null] }); - assert.doesNotThrow(() => store.todayLog()); - assert.doesNotThrow(() => store.counts('d1')); - - // load() normalizes corrupted persisted data and saves normalized DB to localStorage - storage.set(store.KEY, JSON.stringify({ decks: [null, 'corrupted'], cards: null, log: [123], settings: null })); - store.load(); - assert.deepStrictEqual(store.db.decks, []); - assert.deepStrictEqual(store.db.cards, []); - assert.deepStrictEqual(store.db.log, []); - assert.deepStrictEqual(store.db.settings, store.blank().settings); - assert.deepStrictEqual(JSON.parse(storage.get(store.KEY)), store.db, 'load() must persist normalized DB to localStorage'); - console.log('all checks passed'); })().catch(err => { console.error(err); From d6e7d64dd1c3188ef4750b1e8b0e126a0f8eb92e Mon Sep 17 00:00:00 2001 From: fulltomo <24rb1022@student.gs.chiba-u.jp> Date: Fri, 14 Aug 2026 08:36:17 +0900 Subject: [PATCH 5/6] test(store): add regression test for malformed field fallback in normalizeDB --- test.cjs | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/test.cjs b/test.cjs index fe40b29..b9223df 100644 --- a/test.cjs +++ b/test.cjs @@ -171,6 +171,78 @@ const assert = require('assert'); assert.ok(dueVsCard >= 3.0, `[Theme ${t.name}] switch ON state contrast ${dueVsCard.toFixed(2)}:1 must be >= 3.0`); }); + // --- store.js database normalization regression tests + const storage = new Map(); + globalThis.localStorage = { + getItem: key => storage.get(key) ?? null, + setItem: (key, val) => storage.set(key, String(val)), + removeItem: key => storage.delete(key), + clear: () => storage.clear() + }; + + const store = await import('./store.js'); + + // setDB with log: null must allow todayLog() to return [] without exception + store.setDB({ log: null }); + assert.doesNotThrow(() => { + assert.deepStrictEqual(store.todayLog(), [], 'todayLog() must return [] when log is null'); + }); + + // Invalid decks, cards, log values replaced with empty arrays + store.setDB({ decks: null, cards: 'invalid', log: 123 }); + assert.deepStrictEqual(store.db.decks, [], 'invalid decks must be replaced with []'); + assert.deepStrictEqual(store.db.cards, [], 'invalid cards must be replaced with []'); + assert.deepStrictEqual(store.db.log, [], 'invalid log must be replaced with []'); + + // settings: null or settings: [] replaced with default settings + store.setDB({ settings: null }); + assert.deepStrictEqual(store.db.settings, store.blank().settings, 'settings: null replaced with defaults'); + store.setDB({ settings: [] }); + assert.deepStrictEqual(store.db.settings, store.blank().settings, 'settings: [] replaced with defaults'); + + // Invalid elements within decks, cards, and log arrays are filtered out + const validDeck = { id: 'd1', name: 'Test' }; + const validCard = { id: 'c1', deck: 'd1', reps: 0 }; + const validLog = { t: Date.now(), g: 3, n: 1, c: 'c1' }; + + store.setDB({ + decks: [null, undefined, 123, 'str', true, [1, 2], validDeck], + cards: [null, undefined, 456, 'card', false, [3, 4], validCard], + log: [null, undefined, 789, 'log', true, [5, 6], validLog] + }); + assert.deepStrictEqual(store.db.decks, [validDeck], 'invalid deck elements filtered out'); + assert.deepStrictEqual(store.db.cards, [validCard], 'invalid card elements filtered out'); + assert.deepStrictEqual(store.db.log, [validLog], 'invalid log elements filtered out'); + + // Verify functions like todayLog() and counts() don't throw when arrays contained null elements prior to setDB + store.setDB({ decks: [null], cards: [null], log: [null] }); + assert.doesNotThrow(() => store.todayLog()); + assert.doesNotThrow(() => store.counts('d1')); + + // load() normalizes corrupted persisted data and saves normalized DB to localStorage + storage.set(store.KEY, JSON.stringify({ decks: [null, 'corrupted'], cards: null, log: [123], settings: null })); + store.load(); + assert.deepStrictEqual(store.db.decks, []); + assert.deepStrictEqual(store.db.cards, []); + assert.deepStrictEqual(store.db.log, []); + assert.deepStrictEqual(store.db.settings, store.blank().settings); + assert.deepStrictEqual(JSON.parse(storage.get(store.KEY)), store.db, 'load() must persist normalized DB to localStorage'); + + // Verify normalizeDB with malformed fields (non-array collections, invalid lastBackup, non-record settings) + const malformedFields = store.normalizeDB({ + decks: {}, + cards: 'cards', + log: null, + lastBackup: '12345', + settings: [] + }); + const blankState = store.blank(); + assert.deepStrictEqual(malformedFields.decks, blankState.decks, 'invalid decks fallback to []'); + assert.deepStrictEqual(malformedFields.cards, blankState.cards, 'invalid cards fallback to []'); + assert.deepStrictEqual(malformedFields.log, blankState.log, 'invalid log fallback to []'); + assert.deepStrictEqual(malformedFields.settings, blankState.settings, 'invalid settings fallback to blank settings'); + assert.strictEqual(typeof malformedFields.lastBackup, 'number', 'invalid lastBackup falls back to default timestamp'); + console.log('all checks passed'); })().catch(err => { console.error(err); From f83f0589dbf7db3abbc06d09397fae0904bee2da Mon Sep 17 00:00:00 2001 From: fulltomo <24rb1022@student.gs.chiba-u.jp> Date: Fri, 14 Aug 2026 08:37:00 +0900 Subject: [PATCH 6/6] fix(store): remove unused DAY import and expand normalizeDB regression tests --- store.js | 2 +- test.cjs | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/store.js b/store.js index ee538d2..8dc455f 100644 --- a/store.js +++ b/store.js @@ -1,4 +1,4 @@ -import { DAY, schedule } from './core.js'; +import { schedule } from './core.js'; const uid = () => Math.random().toString(36).slice(2, 10); const dayKey = t => { const d = new Date(t); return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`; }; diff --git a/test.cjs b/test.cjs index b9223df..f6fd7f5 100644 --- a/test.cjs +++ b/test.cjs @@ -236,11 +236,11 @@ const assert = require('assert'); lastBackup: '12345', settings: [] }); - const blankState = store.blank(); - assert.deepStrictEqual(malformedFields.decks, blankState.decks, 'invalid decks fallback to []'); - assert.deepStrictEqual(malformedFields.cards, blankState.cards, 'invalid cards fallback to []'); - assert.deepStrictEqual(malformedFields.log, blankState.log, 'invalid log fallback to []'); - assert.deepStrictEqual(malformedFields.settings, blankState.settings, 'invalid settings fallback to blank settings'); + const expectedBlank = store.blank(); + assert.deepStrictEqual(malformedFields.decks, expectedBlank.decks, 'invalid decks fallback to []'); + assert.deepStrictEqual(malformedFields.cards, expectedBlank.cards, 'invalid cards fallback to []'); + assert.deepStrictEqual(malformedFields.log, expectedBlank.log, 'invalid log fallback to []'); + assert.deepStrictEqual(malformedFields.settings, expectedBlank.settings, 'invalid settings fallback to blank settings'); assert.strictEqual(typeof malformedFields.lastBackup, 'number', 'invalid lastBackup falls back to default timestamp'); console.log('all checks passed');