diff --git a/README.md b/README.md index 15a51fbac..e7474851f 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2937 keys - 7336+ tests / 595 files + 7350+ tests / 595 files Codecov Coverage License MIT CI Status @@ -511,7 +511,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and | **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) | | **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking | | **i18n** | Custom React Context (`I18nContext.tsx`) | 2937 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence | -| **Testing** | Vitest 4.x (7336+ tests / 595 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (7350+ tests / 595 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | | **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy | | **Visualization** | Force-directed graph | Interactive character relationship network | | **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` | @@ -549,7 +549,7 @@ WorldScript-Studio/ │ ├── sw.js # PWA Service Worker │ └── manifest.json # PWA Web App Manifest v3 ├── tests/ -│ ├── unit/ # Vitest unit tests (7336+ tests, 595 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (7350+ tests, 595 files) — count spans tests/, components/, packages/*/tests/, not just this folder │ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths │ │ └── settings/ # WebLlmPanel, AiSections │ └── e2e/ # Playwright specs + helpers.ts @@ -711,7 +711,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt | `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning | **Current test metrics (2026-08-30, source-synchronized; CI remains authoritative for pass/fail):** -- **7336+ unit tests** across **595 test files** — CI is authoritative for pass/fail +- **7350+ unit tests** across **595 test files** — CI is authoritative for pass/fail - Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics) - i18n: **2937 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta) diff --git a/features/project/adapters.ts b/features/project/adapters.ts index 7095471e5..920b26be6 100644 --- a/features/project/adapters.ts +++ b/features/project/adapters.ts @@ -1,5 +1,10 @@ import { createEntityAdapter } from '@reduxjs/toolkit'; import type { Character, World } from '../../types'; -export const charactersAdapter = createEntityAdapter(); -export const worldsAdapter = createEntityAdapter(); +// QNBS-v3: the stable no-op comparer selects RTK's object-safe update path without changing entity insertion order. +const preserveEntityOrder = () => 0; + +export const charactersAdapter = createEntityAdapter({ + sortComparer: preserveEntityOrder, +}); +export const worldsAdapter = createEntityAdapter({ sortComparer: preserveEntityOrder }); diff --git a/index.tsx b/index.tsx index 447242c4a..e1efc73e8 100644 --- a/index.tsx +++ b/index.tsx @@ -8,8 +8,8 @@ import { IdbUnlockModal } from './components/settings/IdbUnlockModal'; import { I18nProvider } from './contexts/I18nContext'; import { versionControlActions } from './features/versionControl/versionControlSlice'; import { - getPersistedProjectPayload, loadPersistedRootState, + normalizePersistedProjectForStore, shouldAllowInitialMetadataSeed, } from './services/appBootstrap'; import { initializeStorage } from './services/dbInitialization'; @@ -132,20 +132,12 @@ async function bootApp(): Promise { // We must manually reconstruct the undo envelope if we loaded flat data. if (preloadedState?.project) { const projectPart = preloadedState.project; - const persistedProjectData = getPersistedProjectPayload(projectPart); - const hasUndoPayload = - projectPart.present !== undefined && - getPersistedProjectPayload({ present: projectPart.present }) !== undefined; - - if (persistedProjectData && !hasUndoPayload) { - logger.debug('Hydrating flat project state into Redux-Undo envelope.'); - preloadedState.project = { - past: [], - present: { data: persistedProjectData }, // Reconstruct the slice structure - future: [], - _latestUnfiltered: persistedProjectData, // Helper for redux-undo if needed - }; - } else if (!persistedProjectData) { + const normalizedProject = normalizePersistedProjectForStore(projectPart); + + if (normalizedProject) { + logger.debug('Hydrating persisted project state into Redux-Undo envelope.'); + preloadedState.project = normalizedProject; + } else { // Fallback: Corrupt or empty project state logger.warn('Project state corrupted. Resetting project.'); delete (preloadedState as Record)['project']; diff --git a/services/appBootstrap.ts b/services/appBootstrap.ts index 32b7951bc..afc35ec77 100644 --- a/services/appBootstrap.ts +++ b/services/appBootstrap.ts @@ -1,3 +1,5 @@ +import type { EntityAdapter, EntityState } from '@reduxjs/toolkit'; +import { charactersAdapter, worldsAdapter } from '../features/project/adapters'; import type { ProjectData } from '../features/project/projectSlice'; import type { PersistedRootState } from '../types'; import { dbService } from './dbService'; @@ -41,26 +43,98 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -function hasEntityStateShape(value: unknown): boolean { +function hasOwn(value: object, key: PropertyKey): boolean { + return Object.hasOwn(value, key); +} + +// QNBS-v3: null-prototype entity records preserve legal persisted IDs that collide with Object.prototype during bootstrap. +function normalizeEntityCollection( + value: unknown, + adapter: EntityAdapter, +): EntityState | undefined { + if (Array.isArray(value)) { + const ids: string[] = []; + const entities = Object.create(null) as Record; + for (const entity of value) { + if (!isRecord(entity) || typeof entity['id'] !== 'string' || !entity['id'].trim()) + return undefined; + const id = entity['id']; + if (hasOwn(entities, id)) return undefined; + ids.push(id); + entities[id] = entity as T; + } + return { ...adapter.getInitialState(), ids, entities }; + } + if (!isRecord(value) || !Array.isArray(value['ids']) || !isRecord(value['entities'])) - return false; - return value['ids'].every((id: unknown) => typeof id === 'string'); + return undefined; + const sourceIds = value['ids']; + const sourceEntities = value['entities']; + const ids: string[] = []; + const entities = Object.create(null) as Record; + const seenIds = new Set(); + for (const id of sourceIds) { + if (typeof id !== 'string' || !id.trim() || seenIds.has(id)) return undefined; + if (!hasOwn(sourceEntities, id)) return undefined; + const entity = sourceEntities[id]; + if (!isRecord(entity) || entity['id'] !== id) return undefined; + seenIds.add(id); + ids.push(id); + entities[id] = entity as T; + } + for (const key of Reflect.ownKeys(sourceEntities)) { + if (typeof key !== 'string' || !seenIds.has(key)) return undefined; + } + return { ...adapter.getInitialState(), ids, entities }; } -// QNBS-v3: structural project evidence prevents malformed envelopes from suppressing fresh seeding while preserving partial metadata repair. +// QNBS-v3: canonical desktop collections prevent valid filesystem projects from being discarded while malformed envelopes remain non-authoritative. export function getPersistedProjectPayload( project: PersistedRootState['project'] | undefined, ): ProjectData | undefined { const payload = project?.present?.data ?? project?.data; if (!isRecord(payload)) return undefined; + const characters = normalizeEntityCollection(payload['characters'], charactersAdapter); + const worlds = normalizeEntityCollection(payload['worlds'], worldsAdapter); + if (!characters || !worlds || !Array.isArray(payload['manuscript'])) return undefined; + const outline = payload['outline']; + if (outline !== undefined && !Array.isArray(outline)) return undefined; + return { + ...payload, + characters, + worlds, + outline: outline ?? [], + } as unknown as ProjectData; +} + +// QNBS-v3: the active payload is normalized before Redux-Undo sees it, preventing desktop array data from bypassing the canonical Redux state boundary. +export function normalizePersistedProjectForStore( + project: PersistedRootState['project'] | undefined, +): PersistedRootState['project'] | undefined { + const payload = getPersistedProjectPayload(project); + if (!payload) return undefined; + if ( - !hasEntityStateShape(payload.characters) || - !hasEntityStateShape(payload.worlds) || - !Array.isArray(payload.outline) || - !Array.isArray(payload.manuscript) - ) - return undefined; - return payload as ProjectData; + project && + isRecord(project.present) && + Array.isArray(project.past) && + Array.isArray(project.future) + ) { + const present = { ...project.present, data: payload }; + return { + ...project, + present, + _latestUnfiltered: present, + }; + } + + const present = { data: payload }; + return { + past: [], + present, + future: [], + _latestUnfiltered: present, + }; } // QNBS-v3: seed authority follows hydrated project presence, so settings-only state can still initialize the synthetic project without overwriting real user intent. diff --git a/tests/unit/services/appBootstrap.test.ts b/tests/unit/services/appBootstrap.test.ts index e13c8f8fe..8f4864a3f 100644 --- a/tests/unit/services/appBootstrap.test.ts +++ b/tests/unit/services/appBootstrap.test.ts @@ -9,6 +9,7 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { charactersAdapter } from '../../../features/project/adapters'; import type { PersistedRootState } from '../../../types'; const createPersistedProject = (overrides: Record = {}) => ({ @@ -21,6 +22,15 @@ const createPersistedProject = (overrides: Record = {}) => ({ ...overrides, }); +const createDesktopProject = (overrides: Record = {}) => ({ + title: '', + logline: '', + characters: [], + worlds: [], + manuscript: [], + ...overrides, +}); + const h = vi.hoisted(() => ({ isTauri: { value: false }, dbLoadState: vi.fn(), @@ -50,6 +60,7 @@ vi.mock('../../../services/storageService', () => ({ import { getPersistedProjectPayload, loadPersistedRootState, + normalizePersistedProjectForStore, shouldAllowInitialMetadataSeed, } from '../../../services/appBootstrap'; @@ -210,4 +221,203 @@ describe('shouldAllowInitialMetadataSeed', () => { expect(getPersistedProjectPayload(state.project)).toEqual(project); expect(shouldAllowInitialMetadataSeed(state)).toBe(false); }); + + // QNBS-v3: canonicalizes supported desktop persistence shapes before hydration authority can suppress fresh-project seeding. + it('normalizes desktop arrays and an omitted outline into the Redux shape', () => { + const project = createDesktopProject(); + const state = { project: { data: project } } as unknown as PersistedRootState; + const payload = getPersistedProjectPayload(state.project); + + expect(payload).toMatchObject({ + outline: [], + manuscript: [], + }); + expect(payload?.characters).toEqual({ ids: [], entities: {} }); + expect(payload?.worlds).toEqual({ ids: [], entities: {} }); + expect(shouldAllowInitialMetadataSeed(state)).toBe(false); + }); + + it('preserves all entities when normalizing desktop character and world arrays', () => { + const character = { id: 'character-1', name: 'Ada' }; + const world = { id: 'world-1', name: 'Arcadia' }; + const project = createDesktopProject({ characters: [character], worlds: [world] }); + const state = { project: { data: project } } as unknown as PersistedRootState; + const payload = getPersistedProjectPayload(state.project); + + expect(payload?.characters).toEqual({ + ids: ['character-1'], + entities: { 'character-1': character }, + }); + expect(payload?.worlds).toEqual({ ids: ['world-1'], entities: { 'world-1': world } }); + }); + + it('preserves prototype-named array IDs in an adapter-compatible entity state', () => { + const ids = ['__proto__', 'constructor', 'toString']; + const project = createDesktopProject({ + characters: ids.map((id) => ({ id, name: `Character ${id}` })), + worlds: ids.map((id) => ({ id, name: `World ${id}` })), + }); + const state = { project: { data: project } } as unknown as PersistedRootState; + const payload = getPersistedProjectPayload(state.project); + + expect(payload).toBeDefined(); + if (!payload) return; + expect(Object.getPrototypeOf(payload.characters.entities)).toBeNull(); + expect(Object.getPrototypeOf(payload.worlds.entities)).toBeNull(); + for (const id of ids) { + expect(payload.characters.ids).toContain(id); + expect(Object.hasOwn(payload.characters.entities, id)).toBe(true); + expect(payload.characters.entities[id]?.id).toBe(id); + expect(payload.worlds.ids).toContain(id); + expect(Object.hasOwn(payload.worlds.entities, id)).toBe(true); + expect(payload.worlds.entities[id]?.id).toBe(id); + } + + const updated = charactersAdapter.updateOne(payload.characters, { + id: '__proto__', + changes: { name: 'Updated' }, + }); + const updatedPrototypeCharacter = Object.getOwnPropertyDescriptor(updated.entities, '__proto__') + ?.value as { name: string } | undefined; + expect(updatedPrototypeCharacter?.name).toBe('Updated'); + const selectCharacter = charactersAdapter.getSelectors().selectById; + expect(selectCharacter(payload.characters, '__proto__')?.name).toBe('Character __proto__'); + }); + + it('rebuilds an EntityState input into a prototype-safe map without losing IDs', () => { + const sourceEntities = Object.create(null) as Record; + sourceEntities['constructor'] = { id: 'constructor', name: 'Constructor' }; + sourceEntities['toString'] = { id: 'toString', name: 'To String' }; + const project = createDesktopProject({ + characters: { ids: ['constructor', 'toString'], entities: sourceEntities }, + }); + const state = { project: { data: project } } as unknown as PersistedRootState; + const payload = getPersistedProjectPayload(state.project); + + expect(payload).toBeDefined(); + if (!payload) return; + expect(payload?.characters.ids).toEqual(['constructor', 'toString']); + expect(Object.getPrototypeOf(payload.characters.entities)).toBeNull(); + expect(payload.characters.entities['constructor']?.name).toBe('Constructor'); + expect(payload.characters.entities['toString']?.name).toBe('To String'); + }); + + // QNBS-v3: orphaned entity entries must not masquerade as canonical state and suppress fresh-project fallback. + it('rejects EntityState entries that are not represented by ids', () => { + const project = createDesktopProject({ + characters: { + ids: [], + entities: { orphan: { id: 'orphan', name: 'Orphan' } }, + }, + }); + const state = { project: { data: project } } as unknown as PersistedRootState; + + expect(getPersistedProjectPayload(state.project)).toBeUndefined(); + expect(shouldAllowInitialMetadataSeed(state)).toBe(true); + }); + + it('normalizes mixed array and EntityState desktop collections', () => { + const world = { id: 'world-1', name: 'Arcadia' }; + const project = createDesktopProject({ + characters: [{ id: 'character-1', name: 'Ada' }], + worlds: { ids: ['world-1'], entities: { 'world-1': world } }, + }); + const state = { project: { data: project } } as unknown as PersistedRootState; + const payload = getPersistedProjectPayload(state.project); + + expect(payload?.characters.ids).toEqual(['character-1']); + expect(payload?.worlds).toEqual({ ids: ['world-1'], entities: { 'world-1': world } }); + }); + + it('accepts the inverse mixed EntityState and array representation', () => { + const character = { id: 'character-1', name: 'Ada' }; + const project = createDesktopProject({ + characters: { ids: ['character-1'], entities: { 'character-1': character } }, + worlds: [{ id: 'world-1', name: 'Arcadia' }], + }); + const state = { project: { data: project } } as unknown as PersistedRootState; + const payload = getPersistedProjectPayload(state.project); + + expect(payload?.characters).toEqual({ + ids: ['character-1'], + entities: { 'character-1': character }, + }); + expect(payload?.worlds.ids).toEqual(['world-1']); + }); + + it('rejects array entities without stable IDs instead of dropping their content', () => { + const project = createDesktopProject({ characters: [{ name: 'Missing ID' }] }); + const state = { project: { data: project } } as unknown as PersistedRootState; + + expect(getPersistedProjectPayload(state.project)).toBeUndefined(); + expect(shouldAllowInitialMetadataSeed(state)).toBe(true); + }); + + it('preserves an already canonical Redux project without changing its content', () => { + const project = createPersistedProject({ + title: 'A story', + logline: 'A premise', + manuscript: [{ id: 'section-1', title: 'Chapter 1', content: 'Existing work' }], + }); + const state = { project: { data: project } } as unknown as PersistedRootState; + + expect(getPersistedProjectPayload(state.project)).toEqual(project); + }); + + it('keeps a structurally genuine project authoritative when title is absent', () => { + const project = createDesktopProject({ + manuscript: [{ id: 'section-1', title: 'Existing', content: 'Work' }], + }); + Reflect.deleteProperty(project, 'title'); + const state = { project: { data: project } } as unknown as PersistedRootState; + + expect(getPersistedProjectPayload(state.project)).toBeDefined(); + expect(shouldAllowInitialMetadataSeed(state)).toBe(false); + }); + + it('keeps a structurally genuine project authoritative when logline is absent', () => { + const project = createDesktopProject({ + manuscript: [{ id: 'section-1', title: 'Existing', content: 'Work' }], + }); + Reflect.deleteProperty(project, 'logline'); + const state = { project: { data: project } } as unknown as PersistedRootState; + + expect(getPersistedProjectPayload(state.project)).toBeDefined(); + expect(shouldAllowInitialMetadataSeed(state)).toBe(false); + }); + + it('rejects a present non-array outline instead of hiding malformed structure', () => { + const project = createDesktopProject({ outline: {} }); + const state = { project: { data: project } } as unknown as PersistedRootState; + + expect(getPersistedProjectPayload(state.project)).toBeUndefined(); + expect(shouldAllowInitialMetadataSeed(state)).toBe(true); + }); + + it('writes the normalized active payload back into an existing undo envelope', () => { + const project = createDesktopProject({ + characters: [{ id: 'character-1', name: 'Ada' }], + worlds: [{ id: 'world-1', name: 'Arcadia' }], + }); + const past = [{ data: createPersistedProject({ title: 'Past' }) }]; + const future = [{ data: createPersistedProject({ title: 'Future' }) }]; + const envelope = { + past, + present: { data: project }, + future, + group: 'project-edit', + _latestUnfiltered: { data: project }, + } as unknown as PersistedRootState['project']; + + const normalized = normalizePersistedProjectForStore(envelope); + + expect(normalized?.past).toBe(past); + expect(normalized?.future).toBe(future); + expect(normalized?.present?.data.characters).toEqual({ + ids: ['character-1'], + entities: { 'character-1': { id: 'character-1', name: 'Ada' } }, + }); + expect(normalized?.present?.data.outline).toEqual([]); + expect(normalized?._latestUnfiltered).toEqual({ data: normalized?.present?.data }); + }); }); diff --git a/tests/unit/store.test.ts b/tests/unit/store.test.ts index fd0ad090c..b14301b5e 100644 --- a/tests/unit/store.test.ts +++ b/tests/unit/store.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; +import { normalizePersistedProjectForStore } from '../../services/appBootstrap'; +import type { PersistedRootState } from '../../types'; // --------------------------------------------------------------------------- // Mocks @@ -74,6 +76,49 @@ describe('setupStore', () => { expect(Array.isArray(state['project']?.future)).toBe(true); expect(state['project']?.present).toBeTruthy(); }); + + // QNBS-v3: the store must receive the canonical active payload so desktop arrays cannot bypass EntityState selectors after undo hydration. + it('hydrates normalized active data inside an existing undo envelope', async () => { + const { setupStore } = await import('../../app/store'); + const project = { + title: '', + logline: '', + characters: [{ id: '__proto__', name: 'Ada' }], + worlds: [{ id: 'constructor', name: 'Arcadia' }], + manuscript: [], + }; + const normalizedProject = normalizePersistedProjectForStore({ + past: [], + present: { data: project }, + future: [], + } as unknown as PersistedRootState['project']); + + expect(normalizedProject).toBeDefined(); + if (!normalizedProject) return; + const store = setupStore({ project: normalizedProject } as PersistedRootState); + const state = store.getState() as unknown as { + project: { + present: { + data: { + characters: { entities: Record }; + worlds: { entities: Record }; + }; + }; + }; + }; + + const prototypeCharacter = Object.getOwnPropertyDescriptor( + state.project.present.data.characters.entities, + '__proto__', + )?.value as { id: string } | undefined; + const constructorWorld = Object.getOwnPropertyDescriptor( + state.project.present.data.worlds.entities, + 'constructor', + )?.value as { id: string } | undefined; + expect(prototypeCharacter?.id).toBe('__proto__'); + expect(constructorWorld?.id).toBe('constructor'); + expect(Object.getPrototypeOf(state.project.present.data.characters.entities)).toBeNull(); + }); }); describe('rootReducer', () => {