diff --git a/App.tsx b/App.tsx index 10ea818f..f5cf3679 100644 --- a/App.tsx +++ b/App.tsx @@ -187,11 +187,14 @@ const ViewLoader: FC = () => { interface AppProps { isNewUser: boolean; + allowInitialMetadataSeed: boolean; } -const App: FC = ({ isNewUser }) => { - const appState = useApp({ isNewUser }); - const { currentView, handleNavigate, isPortalActive, isInitialLoad } = appState; +// QNBS-v3: keep boot project hydration authority separate from first-run portal semantics. +const App: FC = ({ isNewUser, allowInitialMetadataSeed: initialSeedAuthority }) => { + const appState = useApp({ isNewUser, allowInitialMetadataSeed: initialSeedAuthority }); + const { currentView, handleNavigate, isPortalActive, isInitialLoad, allowInitialMetadataSeed } = + appState; const settings = useAppSelector((state) => state.settings); const project = useAppSelector(selectProjectData); const featureFlags = useAppSelector(selectFeatureFlags); @@ -445,7 +448,14 @@ const App: FC = ({ isNewUser }) => { }, [currentView, announce, t, isInitialLoad, isPortalActive]); // QNBS-v3: gates on isInitialLoad too (not just isPortalActive) so a same-commit stale read can't auto-seed a project before the welcome portal shows. - useProjectBootstrapEffect({ project, isInitialLoad, isPortalActive, isI18nReady, t }); + useProjectBootstrapEffect({ + project, + allowInitialMetadataSeed, + isInitialLoad, + isPortalActive, + isI18nReady, + t, + }); // QNBS-v3: PR3 — auto-launch the product tour once for first-run installs, after the welcome // portal closes and the nav has rendered. Returning users (or anyone who already finished/closed diff --git a/README.md b/README.md index 51988a5e..ecd7090c 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2937 keys - 7317+ tests / 594 files + 7356+ 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 (7317+ tests / 594 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (7356+ 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 (7317+ tests, 594 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (7356+ 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):** -- **7317+ unit tests** across **594 test files** — CI is authoritative for pass/fail +- **7356+ 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/components/WelcomePortal.tsx b/components/WelcomePortal.tsx index 9fcd47af..c758d65e 100644 --- a/components/WelcomePortal.tsx +++ b/components/WelcomePortal.tsx @@ -5,6 +5,7 @@ import { ICONS } from '../constants'; import { projectActions } from '../features/project/projectSlice'; import { importProjectThunk } from '../features/project/thunks/projectManagementThunks'; import { statusActions } from '../features/status/statusSlice'; +import type { PortalExitOptions } from '../hooks/useApp'; import { useTranslation } from '../hooks/useTranslation'; import { storageService } from '../services/storageService'; import type { View } from '../types'; @@ -12,8 +13,9 @@ import { Button } from './ui/Button'; import { CustomIcon } from './ui/Icon'; import { LanguageSelector } from './ui/LanguageSelector'; +// QNBS-v3: imported/demo portal exits revoke boot seed authority so external content keeps intentional blank metadata. interface WelcomePortalProps { - onExit: (view?: View) => void; + onExit: (view?: View, options?: PortalExitOptions) => void; } type PortalView = 'main' | 'new_project' | 'open_project'; @@ -118,7 +120,8 @@ export const WelcomePortal: React.FC = ({ onExit }) => { title: t('settings.data.importSuccess'), }), ); - onExit('manuscript'); + // QNBS-v3: imported project content must revoke fresh-project metadata seeding before bootstrap runs. + onExit('manuscript', { allowInitialMetadataSeed: false }); } else { dispatch( statusActions.addNotification({ @@ -173,7 +176,8 @@ export const WelcomePortal: React.FC = ({ onExit }) => { title: t('settings.data.importSuccess'), }), ); - onExit('manuscript'); + // QNBS-v3: demo content is imported content, so it must not be overwritten by fresh-project seeding. + onExit('manuscript', { allowInitialMetadataSeed: false }); } else { dispatch( statusActions.addNotification({ diff --git a/features/project/adapters.ts b/features/project/adapters.ts index 7095471e..bb78c0d6 100644 --- a/features/project/adapters.ts +++ b/features/project/adapters.ts @@ -1,5 +1,26 @@ +import type { EntityState } from '@reduxjs/toolkit'; 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; + +// QNBS-v3: imported string IDs must survive EntityState construction even when they collide with Object.prototype. +/** Builds a JSON-safe EntityState without treating prototype names as inherited properties. */ +export function createPrototypeSafeEntityState( + items: readonly T[], +): EntityState | undefined { + const ids: string[] = []; + const entities = Object.create(null) as Record; + for (const item of items) { + if (Object.hasOwn(entities, item.id)) return undefined; + ids.push(item.id); + entities[item.id] = item; + } + return { ids, entities }; +} + +export const charactersAdapter = createEntityAdapter({ + sortComparer: preserveEntityOrder, +}); +export const worldsAdapter = createEntityAdapter({ sortComparer: preserveEntityOrder }); diff --git a/features/project/thunks/projectManagementThunks.ts b/features/project/thunks/projectManagementThunks.ts index a27c5c45..baa3d912 100644 --- a/features/project/thunks/projectManagementThunks.ts +++ b/features/project/thunks/projectManagementThunks.ts @@ -3,11 +3,49 @@ import type { RootState } from '../../../app/store'; import { parseImportedProjectJson } from '../../../services/projectImportSchema'; import { storageService } from '../../../services/storageService'; import type { Character, World } from '../../../types'; -import { charactersAdapter, worldsAdapter } from '../adapters'; +import { createPrototypeSafeEntityState } from '../adapters'; import type { ProjectData } from '../projectSlice'; const LEGACY_PROJECT_DIRECTORY_METADATA_KEY = '__worldscriptLegacyProjectDirectory'; +type ImportedEntityCollection = + | readonly T[] + | { ids: readonly string[]; entities: Record }; + +// QNBS-v3: validate normalized import correspondence before image I/O so malformed collections cannot create partial imports. +/** Extracts imported entities while requiring exact ids-to-own-entities correspondence. */ +function extractImportedEntities( + collection: ImportedEntityCollection | undefined, +): T[] | undefined { + if (collection === undefined) return []; + if (!('ids' in collection)) return [...collection]; + if ( + !Array.isArray(collection.ids) || + typeof collection.entities !== 'object' || + collection.entities === null + ) { + return undefined; + } + + const seenIds = new Set(); + const importedEntities: T[] = []; + for (const id of collection.ids) { + if (typeof id !== 'string' || seenIds.has(id) || !Object.hasOwn(collection.entities, id)) { + return undefined; + } + const entity = collection.entities[id]; + if (!entity || typeof entity !== 'object' || entity.id !== id) return undefined; + seenIds.add(id); + importedEntities.push(entity); + } + + const entityKeys = Object.keys(collection.entities); + if (entityKeys.length !== seenIds.size || entityKeys.some((id) => !seenIds.has(id))) { + return undefined; + } + return importedEntities; +} + // QNBS-v3: compare only storage-owned target identity so mutable snapshot content cannot hide a project switch. function restoreTargetIdentity(project: unknown): string | null { if (typeof project !== 'object' || project === null) return null; @@ -23,20 +61,28 @@ export const importProjectThunk = createAsyncThunk('project/importProject', asyn const text = await file.text(); const projectDataJson = parseImportedProjectJson(text); - // QNBS-v3: setAll returns a new Immer-produced state — capture the return value, do not rely on in-place mutation - let charactersState = charactersAdapter.getInitialState(); - let worldsState = worldsAdapter.getInitialState(); const charactersToSet: Character[] = []; const worldsToSet: World[] = []; - let characterArray: (Character & { avatarBase64?: string })[] = []; - if (Array.isArray(projectDataJson.characters)) { - characterArray = projectDataJson.characters as (Character & { avatarBase64?: string })[]; - } else if (projectDataJson.characters && 'ids' in projectDataJson.characters) { - const { ids, entities } = projectDataJson.characters; - characterArray = ids - .map((id: string) => entities[id]) - .filter((item): item is Character & { avatarBase64?: string } => Boolean(item)); + const characterArray = extractImportedEntities( + projectDataJson.characters as + | ImportedEntityCollection + | undefined, + ); + const worldArray = extractImportedEntities( + projectDataJson.worlds as + | ImportedEntityCollection + | undefined, + ); + if (!characterArray || !worldArray) { + throw new Error('Invalid project file: entity IDs do not match their collection entries.'); + } + + if ( + !createPrototypeSafeEntityState(characterArray) || + !createPrototypeSafeEntityState(worldArray) + ) { + throw new Error('Invalid project file: duplicate character or world entity ID.'); } for (const char of characterArray) { @@ -48,17 +94,6 @@ export const importProjectThunk = createAsyncThunk('project/importProject', asyn } charactersToSet.push(newChar); } - charactersState = charactersAdapter.setAll(charactersState, charactersToSet); - - let worldArray: (World & { ambianceImageBase64?: string })[] = []; - if (Array.isArray(projectDataJson.worlds)) { - worldArray = projectDataJson.worlds as (World & { ambianceImageBase64?: string })[]; - } else if (projectDataJson.worlds && 'ids' in projectDataJson.worlds) { - const { ids, entities } = projectDataJson.worlds; - worldArray = ids - .map((id: string) => entities[id]) - .filter((item): item is World & { ambianceImageBase64?: string } => Boolean(item)); - } for (const world of worldArray) { const newWorld = { ...world }; @@ -69,7 +104,11 @@ export const importProjectThunk = createAsyncThunk('project/importProject', asyn } worldsToSet.push(newWorld); } - worldsState = worldsAdapter.setAll(worldsState, worldsToSet); + const charactersState = createPrototypeSafeEntityState(charactersToSet); + const worldsState = createPrototypeSafeEntityState(worldsToSet); + if (!charactersState || !worldsState) { + throw new Error('Invalid project file: duplicate character or world entity ID.'); + } const manuscript = projectDataJson.manuscript ?? []; diff --git a/hooks/useApp.ts b/hooks/useApp.ts index 3fa1895d..d9817fae 100644 --- a/hooks/useApp.ts +++ b/hooks/useApp.ts @@ -61,13 +61,25 @@ function readInitialView(): View { return 'dashboard'; } -export const useApp = ({ isNewUser }: { isNewUser: boolean }) => { +// QNBS-v3: portal exit context distinguishes imported content from a project created in this app. +export interface PortalExitOptions { + allowInitialMetadataSeed?: boolean; +} + +export const useApp = ({ + isNewUser, + allowInitialMetadataSeed: initialSeedAuthority = isNewUser, +}: { + isNewUser: boolean; + allowInitialMetadataSeed?: boolean; +}) => { const [currentView, setCurrentView] = useState(() => readInitialView()); // QNBS-v3: remember the view navigated away from, so view-aware Help can open to the matching // category (once inside Help, currentView is 'help' and no longer tells us where the user was). const previousViewRef = useRef('dashboard'); const [isSidebarOpen, setIsSidebarOpen] = useState(false); - // QNBS-v3: initialize from isNewUser (already stable pre-mount) instead of a hardcoded false, so no transient first commit exposes a stale value to a sibling effect. + const [allowInitialMetadataSeed, setAllowInitialMetadataSeed] = useState(initialSeedAuthority); + // QNBS-v3: initialize from boot project authority, with the legacy first-run fallback retained for direct hook consumers. const [isPortalActive, setIsPortalActive] = useState(isNewUser); const [isInitialLoad, setIsInitialLoad] = useState(true); @@ -131,7 +143,9 @@ export const useApp = ({ isNewUser }: { isNewUser: boolean }) => { }, [currentView]); const handlePortalExit = useCallback( - (view?: View) => { + (view?: View, options?: PortalExitOptions) => { + // QNBS-v3: imported/demo content revokes seed authority before bootstrap can treat it as fresh project data. + if (options?.allowInitialMetadataSeed === false) setAllowInitialMetadataSeed(false); if (view) { switchView(view); pushHash(view); @@ -156,10 +170,12 @@ export const useApp = ({ isNewUser }: { isNewUser: boolean }) => { isSidebarOpen, isPortalActive, isInitialLoad, + // QNBS-v3: expose transient boot/import authority without persisting a new project-state field. + allowInitialMetadataSeed, handlePortalExit, handleNavigate, setIsSidebarOpen, }; }; -export type UseAppReturnType = ReturnType; \ No newline at end of file +export type UseAppReturnType = ReturnType; diff --git a/hooks/useProjectBootstrapEffect.ts b/hooks/useProjectBootstrapEffect.ts index 5386e759..19725ffc 100644 --- a/hooks/useProjectBootstrapEffect.ts +++ b/hooks/useProjectBootstrapEffect.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useAppDispatch } from '../app/hooks'; import { projectActions } from '../features/project/projectSlice'; import type { ProjectMetaSlice, TranslateFn } from '../services/projectI18nRepair'; @@ -22,31 +22,46 @@ export function shouldRunProjectBootstrap({ } export interface UseProjectBootstrapEffectParams extends ProjectBootstrapGateState { + // QNBS-v3: explicit portal intent controls one-time metadata seeding without persisting schema state. + allowInitialMetadataSeed: boolean; t: TranslateFn; } /** Repairs raw-i18n-key project fields, or seeds a fresh blank project, once bootstrap has settled. */ export function useProjectBootstrapEffect({ project, + allowInitialMetadataSeed, isInitialLoad, isPortalActive, isI18nReady, t, }: UseProjectBootstrapEffectParams): void { const dispatch = useAppDispatch(); + const hasCompletedFreshUserBootstrap = useRef(false); useEffect(() => { // QNBS-v3: narrows project directly (not via the predicate's own return type) so the redundant post-check codecov flagged as dead code isn't needed. - if (!project || !shouldRunProjectBootstrap({ project, isInitialLoad, isPortalActive, isI18nReady })) + if ( + !project || + !shouldRunProjectBootstrap({ + project, + isInitialLoad, + isPortalActive, + isI18nReady, + }) + ) return; - const repair = repairProjectI18nFields(project, t); + const repair = repairProjectI18nFields(project, t, { + seedInitialMetadata: allowInitialMetadataSeed && !hasCompletedFreshUserBootstrap.current, + }); + if (allowInitialMetadataSeed) hasCompletedFreshUserBootstrap.current = true; if (repair) { if (repair.title !== undefined) dispatch(projectActions.updateTitle(repair.title)); if (repair.logline !== undefined) dispatch(projectActions.updateLogline(repair.logline)); if (repair.manuscript !== undefined) dispatch(projectActions.setManuscript(repair.manuscript)); } - // QNBS-v3: no further branch here — repairProjectI18nFields already treats any blank title/logline/manuscript as needing repair, so it always returns non-null for a blank project; a separate resetProject dispatch for that same condition was unreachable dead code, removed rather than tested around. - }, [project, isInitialLoad, isPortalActive, isI18nReady, dispatch, t]); + // QNBS-v3: blank metadata is seeded only once for a fresh user; later empty strings remain user intent. + }, [project, allowInitialMetadataSeed, isInitialLoad, isPortalActive, isI18nReady, dispatch, t]); } diff --git a/index.tsx b/index.tsx index b2e0d634..e1efc73e 100644 --- a/index.tsx +++ b/index.tsx @@ -7,7 +7,11 @@ import { type AppDispatch, appStoreRef, type RootState, setupStore } from './app import { IdbUnlockModal } from './components/settings/IdbUnlockModal'; import { I18nProvider } from './contexts/I18nContext'; import { versionControlActions } from './features/versionControl/versionControlSlice'; -import { loadPersistedRootState } from './services/appBootstrap'; +import { + loadPersistedRootState, + normalizePersistedProjectForStore, + shouldAllowInitialMetadataSeed, +} from './services/appBootstrap'; import { initializeStorage } from './services/dbInitialization'; import { logger } from './services/logger'; import { @@ -128,19 +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 normalizedProject = normalizePersistedProjectForStore(projectPart); - // Check if the loaded project is "flat" (i.e., it doesn't have a 'present' key, but HAS 'data') - const isFlatData = !projectPart.present && projectPart.data; - - if (isFlatData && projectPart.data) { - logger.debug('Hydrating flat project state into Redux-Undo envelope.'); - preloadedState.project = { - past: [], - present: { data: projectPart.data }, // Reconstruct the slice structure - future: [], - _latestUnfiltered: projectPart.data, // Helper for redux-undo if needed - }; - } else if (!projectPart.present && !projectPart.data) { + 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']; @@ -148,6 +145,9 @@ async function bootApp(): Promise { } // -------------------------------- + // QNBS-v3: derive metadata seeding from the normalized persisted-project boundary, not the broader first-run flag. + const allowInitialMetadataSeed = shouldAllowInitialMetadataSeed(preloadedState); + const store = setupStore(preloadedState); appStoreRef.current = store as unknown as { getState(): RootState; dispatch: AppDispatch }; @@ -184,7 +184,7 @@ async function bootApp(): Promise { root.render( - + , ); diff --git a/services/appBootstrap.ts b/services/appBootstrap.ts index bc4648b2..c3276099 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'; @@ -36,3 +38,107 @@ export async function loadPersistedRootState(): Promise { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +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') 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 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' || 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: 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 ( + 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. +export function shouldAllowInitialMetadataSeed( + preloadedState: PersistedRootState | undefined, +): boolean { + return getPersistedProjectPayload(preloadedState?.project) === undefined; +} diff --git a/services/projectI18nRepair.ts b/services/projectI18nRepair.ts index 6c94e3d2..939f9680 100644 --- a/services/projectI18nRepair.ts +++ b/services/projectI18nRepair.ts @@ -15,19 +15,27 @@ export type ProjectI18nRepair = { manuscript?: StorySection[]; }; -/** Repair project fields that were saved as raw i18n keys during cold start. */ +// QNBS-v3: missing metadata is repairable while empty strings remain valid user intent. +function shouldRepairMetadata(value: unknown, seedInitialMetadata: boolean): boolean { + if (typeof value !== 'string') return true; + return (seedInitialMetadata && value === '') || isKnownPersistedTranslationKey(value); +} + +/** Repair raw i18n keys, optionally seeding metadata only for the first fresh-user bootstrap. */ export function repairProjectI18nFields( project: ProjectMetaSlice, t: TranslateFn, + { seedInitialMetadata = false }: { seedInitialMetadata?: boolean } = {}, ): ProjectI18nRepair | null { const repair: ProjectI18nRepair = {}; let changed = false; - if (!project.title || isKnownPersistedTranslationKey(project.title)) { + // QNBS-v3: only explicit fresh-project authority may fill blanks; raw persisted keys remain independently repairable. + if (shouldRepairMetadata(project.title, seedInitialMetadata)) { repair.title = t('initialProject.title'); changed = true; } - if (!project.logline || isKnownPersistedTranslationKey(project.logline)) { + if (shouldRepairMetadata(project.logline, seedInitialMetadata)) { repair.logline = t('initialProject.logline'); changed = true; } diff --git a/services/projectImportSchema.ts b/services/projectImportSchema.ts index 32135601..ded09875 100644 --- a/services/projectImportSchema.ts +++ b/services/projectImportSchema.ts @@ -135,10 +135,23 @@ const outlineSectionSchema = z.object({ isTwist: z.boolean().optional(), }); +// QNBS-v3: entry-based parsing preserves legal prototype-named entity IDs as own keys for hydration. +/** Parses record entries without letting special property names alter object prototypes. */ +function prototypeSafeEntityRecordSchema(item: T) { + return z.preprocess( + (value) => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return value; + const record = value as Record; + return Object.keys(record).map((key) => [key, record[key]]); + }, + z.array(z.tuple([z.string(), item])).transform((entries) => Object.fromEntries(entries)), + ); +} + const entityStateSchema = (item: T) => z.object({ ids: z.array(z.string()), - entities: z.record(z.string(), item), + entities: prototypeSafeEntityRecordSchema(item), }); const charactersFieldSchema = z.union([ diff --git a/tests/e2e/onboarding-entry-precondition.spec.ts b/tests/e2e/onboarding-entry-precondition.spec.ts index 1b161aea..6d6ad87e 100644 --- a/tests/e2e/onboarding-entry-precondition.spec.ts +++ b/tests/e2e/onboarding-entry-precondition.spec.ts @@ -21,7 +21,6 @@ test.describe('WelcomePortal entry precondition (CI-only)', () => { // QNBS-v3: the helper's contract is locale-independent portal-reached, not English — assert the stable testid, not the translated button label. await page.addInitScript(() => localStorage.setItem('worldscript-language', 'es')); await page.goto('/'); - await expect(page.getByTestId('welcome-portal')).toBeVisible(); await ensureWelcomePortalEntry(page); await expect(page.getByTestId('welcome-portal')).toBeVisible(); }); diff --git a/tests/unit/App.test.tsx b/tests/unit/App.test.tsx new file mode 100644 index 00000000..cace354a --- /dev/null +++ b/tests/unit/App.test.tsx @@ -0,0 +1,178 @@ +import { render } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockUseApp, mockProjectBootstrapEffect, mockDispatch, mockStore, selectorState, project } = + vi.hoisted(() => ({ + mockUseApp: vi.fn(), + mockProjectBootstrapEffect: vi.fn(), + mockDispatch: vi.fn(), + mockStore: { + getState: vi.fn(() => ({})), + }, + selectorState: { + settings: { + theme: 'light', + appearancePreset: 'default', + writingSurfaceStyle: 'default', + keyboardShortcuts: [], + privacy: { analyticsEnabled: false }, + aiMode: 'hybrid', + openRouter: { enabled: false }, + advancedEditor: { + distractionFree: false, + typewriterMode: false, + zenMode: false, + focusMode: false, + }, + accessibility: { + highContrast: false, + reducedMotion: false, + reducedTransparency: false, + largeText: false, + screenReader: false, + focusIndicators: false, + comfortableTargets: false, + colorBlindMode: 'none', + }, + desktop: { minimizeToTray: false }, + }, + }, + project: { id: 'test-project', title: 'Test project' }, + })); + +vi.mock('react-redux', () => ({ + Provider: ({ children }: { children: ReactNode }) => children, + useStore: () => mockStore, +})); + +vi.mock('../../app/hooks', () => ({ + useAppDispatch: () => mockDispatch, + useAppSelector: vi.fn((selector: (state: unknown) => unknown) => selector(selectorState)), +})); + +vi.mock('../../features/project/projectSelectors', () => ({ + selectProjectData: () => project, + selectAllCharacters: () => [], + selectAllWorlds: () => [], +})); + +vi.mock('../../features/featureFlags/featureFlagsSlice', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + selectFeatureFlags: () => ({ + ...actual.defaultFeatureFlagsState, + enableRtlLayout: false, + enablePluginSystem: false, + enableDuckDbAnalytics: false, + enableWorkerBusV2: false, + enableLocalFirstSync: false, + enableIdbAtRestEncryption: false, + enableObjectsGroups: false, + enableMindMaps: false, + enableCharacterInterviews: false, + enableLoraAdapters: false, + enableGlobalCopilot: false, + enableVoiceSupport: false, + }), + }; +}); + +vi.mock('../../hooks/useApp', () => ({ useApp: mockUseApp })); +vi.mock('../../hooks/useProjectBootstrapEffect', () => ({ + useProjectBootstrapEffect: mockProjectBootstrapEffect, +})); +vi.mock('../../hooks/useGlobalKeyboardShortcuts', () => ({ + useGlobalKeyboardShortcuts: vi.fn(), +})); +vi.mock('../../hooks/useIdbUnlockStartupGuard', () => ({ + useIdbUnlockStartupGuard: vi.fn(), +})); +vi.mock('../../hooks/useNativeNotifications', () => ({ useNativeNotifications: vi.fn() })); +vi.mock('../../hooks/usePushToTalk', () => ({ usePushToTalk: vi.fn() })); +vi.mock('../../hooks/useTranslation', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + language: 'en', + setLanguage: vi.fn(), + isReady: true, + }), +})); + +vi.mock('../../contexts/I18nContext', () => ({ + I18nProvider: ({ children }: { children: ReactNode }) => children, + RTL_LOCALES: new Set(), +})); +vi.mock('../../contexts/LiveRegionContext', () => ({ + LiveRegionProvider: ({ children }: { children: ReactNode }) => children, + useAnnounce: () => vi.fn(), +})); + +vi.mock('../../app/listenerMiddleware', () => ({ + initAdaptiveAiOnStartup: vi.fn(), + initLocalFirstSyncOnStartup: vi.fn(), + initWorkerBusOnStartup: vi.fn(), +})); +vi.mock('../../services/storage/encryptionMigrationJournal', () => ({ + readEncryptionMigrationJournal: vi.fn().mockResolvedValue(null), +})); +vi.mock('../../services/storage/storageEncryptionService', () => ({ + isIdbEncryptionReady: vi.fn(() => false), +})); +vi.mock('../../services/desktop/desktopMenu', () => ({ + installDesktopMenu: vi.fn().mockResolvedValue(false), +})); +vi.mock('../../services/desktop/desktopTray', () => ({ + installCloseToTray: vi.fn().mockResolvedValue(undefined), + installDesktopTray: vi.fn().mockResolvedValue(undefined), +})); +vi.mock('../../services/tauriDeepLink', () => ({ + initTauriDeepLink: vi.fn().mockResolvedValue(undefined), +})); +vi.mock('../../services/tauriRuntime', () => ({ + applyDesktopRuntimeFlags: vi.fn(), + isTauriRuntime: vi.fn(() => false), +})); +vi.mock('../../services/desktopPlatform', () => ({ + desktopPlatform: { + runtime: { isDesktop: false }, + lifecycle: { quit: vi.fn().mockResolvedValue(undefined) }, + }, +})); + +import App from '../../App'; + +beforeEach(() => { + vi.clearAllMocks(); + mockUseApp.mockReturnValue({ + currentView: 'dashboard', + previousView: 'dashboard', + handleNavigate: vi.fn(), + handlePortalExit: vi.fn(), + isPortalActive: false, + isInitialLoad: true, + allowInitialMetadataSeed: true, + isSidebarOpen: false, + setIsSidebarOpen: vi.fn(), + }); +}); + +describe('App seed-authority wiring', () => { + // QNBS-v3: protects the boot authority boundary so synthetic-project seeding cannot drift from the hydrated runtime decision. + it('forwards boot authority into useApp and runtime authority into project bootstrap', () => { + render(); + + expect(mockUseApp).toHaveBeenCalledWith({ + isNewUser: false, + allowInitialMetadataSeed: false, + }); + expect(mockProjectBootstrapEffect).toHaveBeenCalledWith( + expect.objectContaining({ + project, + allowInitialMetadataSeed: true, + }), + ); + }); +}); diff --git a/tests/unit/WelcomePortal.test.tsx b/tests/unit/WelcomePortal.test.tsx index 6bd58bcd..52b46b67 100644 --- a/tests/unit/WelcomePortal.test.tsx +++ b/tests/unit/WelcomePortal.test.tsx @@ -1,4 +1,5 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { WelcomePortal } from '../../components/WelcomePortal'; @@ -49,7 +50,17 @@ vi.mock('../../features/status/statusSlice', () => ({ })); vi.mock('../../features/project/thunks/projectManagementThunks', () => ({ - importProjectThunk: vi.fn(() => async () => undefined), + importProjectThunk: Object.assign( + vi.fn(() => ({ type: 'project/importProject/pending' })), + { + fulfilled: { + match: (action: unknown) => + typeof action === 'object' && + action !== null && + (action as { type?: string }).type === 'project/importProject/fulfilled', + }, + }, + ), })); vi.mock('../../services/storageService', () => ({ @@ -105,15 +116,47 @@ describe('WelcomePortal', () => { expect(screen.getByText('portal.welcome.privacyBadge')).toBeTruthy(); }); - it('navigates to new project view on newProject button click', () => { + it('navigates to new project view on newProject button click', async () => { + const user = userEvent.setup(); render(); - fireEvent.click(screen.getByRole('button', { name: 'portal.welcome.newProject' })); + await user.click(screen.getByRole('button', { name: 'portal.welcome.newProject' })); expect(screen.getByText('portal.new.title')).toBeTruthy(); }); - it('navigates to open project view on openProject button click', () => { + it('navigates to open project view on openProject button click', async () => { + const user = userEvent.setup(); render(); - fireEvent.click(screen.getByRole('button', { name: 'portal.welcome.openProject' })); + await user.click(screen.getByRole('button', { name: 'portal.welcome.openProject' })); expect(screen.getByText('portal.open.title')).toBeTruthy(); }); + + // QNBS-v3: locks the portal transition that revokes fresh-metadata seed authority after import. + it('marks a welcome-portal import as ineligible for fresh metadata seeding', async () => { + mockDispatch.mockResolvedValue({ type: 'project/importProject/fulfilled' }); + const onExit = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'portal.welcome.tryDemo' })); + + await waitFor(() => + expect(onExit).toHaveBeenCalledWith('manuscript', { allowInitialMetadataSeed: false }), + ); + }); + + it('revokes fresh metadata seeding after importing a project file', async () => { + mockDispatch.mockResolvedValue({ type: 'project/importProject/fulfilled' }); + const onExit = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'portal.welcome.openProject' })); + const input = document.querySelector('input[type="file"]'); + expect(input).not.toBeNull(); + await user.upload(input as HTMLInputElement, new File(['{}'], 'project.json')); + + await waitFor(() => + expect(onExit).toHaveBeenCalledWith('manuscript', { allowInitialMetadataSeed: false }), + ); + }); }); diff --git a/tests/unit/projectI18nRepair.test.ts b/tests/unit/projectI18nRepair.test.ts index e39a0859..6702f46b 100644 --- a/tests/unit/projectI18nRepair.test.ts +++ b/tests/unit/projectI18nRepair.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { repairProjectI18nFields } from '../../services/projectI18nRepair'; +import { type ProjectMetaSlice, repairProjectI18nFields } from '../../services/projectI18nRepair'; describe('repairProjectI18nFields', () => { const t = vi.fn((key: string) => { @@ -25,12 +25,58 @@ describe('repairProjectI18nFields', () => { expect(repair?.manuscript?.[0]?.title).toBe('Chapter 1'); }); - it('seeds manuscript when empty', () => { + // QNBS-v3: separates user-intent preservation from explicit fresh-project metadata seeding. + it('preserves intentionally empty title and logline while still seeding an empty manuscript', () => { const repair = repairProjectI18nFields({ title: '', logline: '', manuscript: [] }, t); + expect(repair?.title).toBeUndefined(); + expect(repair?.logline).toBeUndefined(); + expect(repair?.manuscript).toHaveLength(1); + }); + + it('seeds blank metadata only when fresh-user bootstrap explicitly authorizes it', () => { + const repair = repairProjectI18nFields({ title: '', logline: '', manuscript: [] }, t, { + seedInitialMetadata: true, + }); expect(repair?.title).toBe('My Untitled Story'); + expect(repair?.logline).toBe('A journey...'); expect(repair?.manuscript).toHaveLength(1); }); + it('leaves intentionally empty metadata untouched when the project has real content', () => { + const repair = repairProjectI18nFields( + { + title: '', + logline: '', + manuscript: [{ id: '1', title: 'Chapter 1', content: 'Existing work' }], + }, + t, + ); + expect(repair).toBeNull(); + }); + + it('repairs missing title without replacing the persisted project content', () => { + const repair = repairProjectI18nFields( + { + logline: 'Existing logline', + manuscript: [{ id: '1', title: 'Chapter 1', content: 'Existing work' }], + } as unknown as ProjectMetaSlice, + t, + ); + expect(repair).toEqual({ title: 'My Untitled Story' }); + }); + + // QNBS-v3: missing presentation metadata is repaired without discarding the genuine persisted project. + it('repairs missing logline without replacing the persisted project content', () => { + const repair = repairProjectI18nFields( + { + title: 'Existing title', + manuscript: [{ id: '1', title: 'Chapter 1', content: 'Existing work' }], + } as unknown as ProjectMetaSlice, + t, + ); + expect(repair).toEqual({ logline: 'A journey...' }); + }); + it('returns null when project metadata is already human-readable', () => { const repair = repairProjectI18nFields( { diff --git a/tests/unit/projectImportSchema.test.ts b/tests/unit/projectImportSchema.test.ts index 0343a17d..8b13725c 100644 --- a/tests/unit/projectImportSchema.test.ts +++ b/tests/unit/projectImportSchema.test.ts @@ -30,6 +30,49 @@ describe('projectImportSchema', () => { expect(parsed.binderNodes?.[0]?.title).toBe('Research'); }); + // QNBS-v3: the bootstrap boundary must preserve string IDs already accepted by project imports. + it('accepts empty and whitespace entity IDs in imported projects', () => { + const raw = JSON.stringify({ + title: 'T', + logline: 'L', + characters: [{ id: '', name: 'Unnamed' }], + worlds: [{ id: ' ', name: 'Whitespace world' }], + manuscript: [], + }); + const parsed = parseImportedProjectJson(raw); + + expect(parsed.characters).toMatchObject([{ id: '', name: 'Unnamed' }]); + expect(parsed.worlds).toMatchObject([{ id: ' ', name: 'Whitespace world' }]); + }); + + // QNBS-v3: import parsing must retain prototype-named entity keys before hydration correspondence checks. + it('preserves prototype-named IDs in normalized entity records', () => { + const raw = JSON.stringify({ + title: 'T', + logline: 'L', + characters: { + ids: ['__proto__'], + entities: Object.fromEntries([['__proto__', { id: '__proto__', name: 'Prototype' }]]), + }, + worlds: { + ids: ['constructor'], + entities: Object.fromEntries([['constructor', { id: 'constructor', name: 'Constructor' }]]), + }, + manuscript: [], + }); + const parsed = parseImportedProjectJson(raw); + const characters = parsed.characters; + const worlds = parsed.worlds; + + expect(Array.isArray(characters)).toBe(false); + expect(Array.isArray(worlds)).toBe(false); + if (Array.isArray(characters) || Array.isArray(worlds) || !characters || !worlds) return; + expect(Object.hasOwn(characters.entities, '__proto__')).toBe(true); + expect(Reflect.get(characters.entities, '__proto__')?.id).toBe('__proto__'); + expect(Object.hasOwn(worlds.entities, 'constructor')).toBe(true); + expect(worlds.entities.constructor?.id).toBe('constructor'); + }); + it('rejects invalid JSON shape', () => { expect(() => parseImportedProjectJson(JSON.stringify([]))).toThrow(/Invalid project file/); }); diff --git a/tests/unit/services/appBootstrap.test.ts b/tests/unit/services/appBootstrap.test.ts index 539ab4bd..b6bfec50 100644 --- a/tests/unit/services/appBootstrap.test.ts +++ b/tests/unit/services/appBootstrap.test.ts @@ -9,6 +9,27 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { charactersAdapter } from '../../../features/project/adapters'; +import type { PersistedRootState } from '../../../types'; + +const createPersistedProject = (overrides: Record = {}) => ({ + title: '', + logline: '', + characters: { ids: [], entities: {} }, + worlds: { ids: [], entities: {} }, + outline: [], + manuscript: [], + ...overrides, +}); + +const createDesktopProject = (overrides: Record = {}) => ({ + title: '', + logline: '', + characters: [], + worlds: [], + manuscript: [], + ...overrides, +}); const h = vi.hoisted(() => ({ isTauri: { value: false }, @@ -36,7 +57,12 @@ vi.mock('../../../services/storageService', () => ({ }, })); -import { loadPersistedRootState } from '../../../services/appBootstrap'; +import { + getPersistedProjectPayload, + loadPersistedRootState, + normalizePersistedProjectForStore, + shouldAllowInitialMetadataSeed, +} from '../../../services/appBootstrap'; describe('loadPersistedRootState', () => { beforeEach(() => { @@ -122,3 +148,304 @@ describe('loadPersistedRootState', () => { expect(h.loadProject).toHaveBeenCalledWith('proj-1'); }); }); + +describe('shouldAllowInitialMetadataSeed', () => { + // QNBS-v3: verifies metadata seeding follows hydrated project presence instead of any persisted root state. + it('allows seeding when no persisted root state exists', () => { + expect(shouldAllowInitialMetadataSeed(undefined)).toBe(true); + }); + + it('allows seeding when settings were restored without a project', () => { + const settingsOnlyState = { settings: {} } as unknown as PersistedRootState; + expect(shouldAllowInitialMetadataSeed(settingsOnlyState)).toBe(true); + }); + + // QNBS-v3: payload absence must keep malformed envelopes from suppressing fresh-project initialization. + it('allows seeding when a persisted project envelope has no actual payload', () => { + const malformedState = { + project: { present: { data: {} } }, + settings: {}, + } as unknown as PersistedRootState; + expect(getPersistedProjectPayload(malformedState.project)).toBeUndefined(); + expect(shouldAllowInitialMetadataSeed(malformedState)).toBe(true); + }); + + it('allows seeding when flat persisted project data is an empty object', () => { + const malformedState = { + project: { data: {} }, + } as unknown as PersistedRootState; + expect(getPersistedProjectPayload(malformedState.project)).toBeUndefined(); + expect(shouldAllowInitialMetadataSeed(malformedState)).toBe(true); + }); + + it('rejects flat arbitrary object-shaped project data as non-hydratable', () => { + const malformedState = { + project: { data: { foo: 'bar' } }, + } as unknown as PersistedRootState; + expect(getPersistedProjectPayload(malformedState.project)).toBeUndefined(); + expect(shouldAllowInitialMetadataSeed(malformedState)).toBe(true); + }); + + it('accepts a structurally genuine empty project without requiring metadata content', () => { + const project = createPersistedProject(); + const state = { project: { data: project } } as unknown as PersistedRootState; + expect(getPersistedProjectPayload(state.project)).toEqual(project); + expect(shouldAllowInitialMetadataSeed(state)).toBe(false); + }); + + it('does not allow seeding after a persisted project was hydrated', () => { + const project = createPersistedProject({ + manuscript: [{ id: 'section-1', title: 'Existing', content: 'Valuable work' }], + }); + const hydratedProjectState = { + project: { present: { data: project } }, + } as unknown as PersistedRootState; + expect(shouldAllowInitialMetadataSeed(hydratedProjectState)).toBe(false); + }); + + it('keeps a genuine project authoritative when title is missing', () => { + const project = createPersistedProject({ + title: undefined, + manuscript: [{ id: 'section-1', title: 'Existing', content: 'Valuable work' }], + }); + const state = { project: { data: project } } as unknown as PersistedRootState; + expect(getPersistedProjectPayload(state.project)).toEqual(project); + expect(shouldAllowInitialMetadataSeed(state)).toBe(false); + }); + + it('keeps a genuine project authoritative when logline is missing', () => { + const project = createPersistedProject({ + logline: undefined, + manuscript: [{ id: 'section-1', title: 'Existing', content: 'Valuable work' }], + }); + const state = { project: { data: project } } as unknown as PersistedRootState; + 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: imported string IDs remain hydratable so valid project content cannot be discarded at cold boot. + it('preserves empty and whitespace entity IDs accepted by project imports', () => { + const characters = [ + { id: '', name: 'Empty ID' }, + { id: ' ', name: 'Whitespace ID' }, + ]; + const worlds = [ + { id: '', name: 'Empty world ID' }, + { id: ' ', name: 'Whitespace world ID' }, + ]; + const project = createDesktopProject({ characters, worlds }); + const state = { project: { data: project } } as unknown as PersistedRootState; + const payload = getPersistedProjectPayload(state.project); + + expect(payload?.characters?.ids).toEqual(['', ' ']); + expect(payload?.worlds?.ids).toEqual(['', ' ']); + expect(Object.hasOwn(payload?.characters?.entities ?? {}, '')).toBe(true); + expect(Object.hasOwn(payload?.characters?.entities ?? {}, ' ')).toBe(true); + expect(Object.hasOwn(payload?.worlds?.entities ?? {}, '')).toBe(true); + expect(Object.hasOwn(payload?.worlds?.entities ?? {}, ' ')).toBe(true); + expect(payload?.characters?.entities['']?.name).toBe('Empty ID'); + expect(payload?.characters?.entities[' ']?.name).toBe('Whitespace ID'); + expect(payload?.worlds?.entities['']?.name).toBe('Empty world ID'); + expect(payload?.worlds?.entities[' ']?.name).toBe('Whitespace world ID'); + expect(shouldAllowInitialMetadataSeed(state)).toBe(false); + }); + + // 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 fd0ad090..b14301b5 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', () => { diff --git a/tests/unit/thunks/binderAndManagementThunks.test.ts b/tests/unit/thunks/binderAndManagementThunks.test.ts index e1c695e6..66d6528a 100644 --- a/tests/unit/thunks/binderAndManagementThunks.test.ts +++ b/tests/unit/thunks/binderAndManagementThunks.test.ts @@ -18,6 +18,7 @@ vi.mock('../../../services/projectImportSchema', () => ({ })); import featureFlagsReducer from '../../../features/featureFlags/featureFlagsSlice'; +import { charactersAdapter, worldsAdapter } from '../../../features/project/adapters'; import projectReducer, { projectActions } from '../../../features/project/projectSlice'; import { importBinderFileThunk, @@ -354,6 +355,146 @@ describe('importProjectThunk', () => { expect(action.type).toBe('project/importProject/fulfilled'); }); + + // QNBS-v3: imported prototype-named IDs must remain own properties through canonicalization and persistence. + it('preserves prototype-named imported character and world IDs', async () => { + const projectWithPrototypeIds = { + ...minimalProject, + characters: [ + { id: '__proto__', name: 'Prototype Character' }, + { id: 'constructor', name: 'Constructor Character' }, + { id: 'toString', name: 'ToString Character' }, + ], + worlds: [ + { id: '__proto__', name: 'Prototype World' }, + { id: 'constructor', name: 'Constructor World' }, + { id: 'toString', name: 'ToString World' }, + ], + }; + vi.mocked(parseImportedProjectJson).mockReturnValue(projectWithPrototypeIds as never); + + const store = makeStore(); + const file = new File([JSON.stringify(projectWithPrototypeIds)], 'novel.json', { + type: 'application/json', + }); + const action = await store.dispatch(importProjectThunk(file)); + const payload = ( + action as { + payload: { + characters: { ids: string[]; entities: Record }; + worlds: { ids: string[]; entities: Record }; + }; + } + ).payload; + + for (const id of ['__proto__', 'constructor', 'toString']) { + expect(payload.characters.ids).toContain(id); + expect(Object.hasOwn(payload.characters.entities, id)).toBe(true); + expect(Object.hasOwn(payload.worlds.entities, id)).toBe(true); + } + expect(JSON.stringify(payload.characters.entities)).toContain('Prototype Character'); + expect(JSON.stringify(payload.worlds.entities)).toContain('Prototype World'); + + const state = store.getState().project.present.data; + expect(Object.getPrototypeOf(state.characters.entities)).toBeNull(); + expect(Object.getPrototypeOf(state.worlds.entities)).toBeNull(); + expect(charactersAdapter.getSelectors().selectById(state.characters, '__proto__')?.name).toBe( + 'Prototype Character', + ); + expect(worldsAdapter.getSelectors().selectById(state.worlds, 'constructor')?.name).toBe( + 'Constructor World', + ); + + store.dispatch( + projectActions.updateCharacter({ id: '__proto__', changes: { name: 'Updated Character' } }), + ); + const updatedCharacter = Reflect.get( + store.getState().project.present.data.characters.entities, + '__proto__', + ) as { name?: string }; + expect(updatedCharacter.name).toBe('Updated Character'); + }); + + // QNBS-v3: normalized imports must preserve own-ID correspondence instead of filtering malformed entries silently. + it('preserves prototype-named IDs in normalized imported collections', async () => { + const projectWithNormalizedPrototypeIds = { + ...minimalProject, + characters: { + ids: ['__proto__', 'constructor'], + entities: Object.fromEntries([ + ['__proto__', { id: '__proto__', name: 'Prototype Character' }], + ['constructor', { id: 'constructor', name: 'Constructor Character' }], + ]), + }, + worlds: { + ids: ['toString'], + entities: Object.fromEntries([['toString', { id: 'toString', name: 'ToString World' }]]), + }, + }; + const actualSchema = await vi.importActual< + typeof import('../../../services/projectImportSchema') + >('../../../services/projectImportSchema'); + vi.mocked(parseImportedProjectJson).mockImplementation(actualSchema.parseImportedProjectJson); + + const store = makeStore(); + const file = new File([JSON.stringify(projectWithNormalizedPrototypeIds)], 'novel.json', { + type: 'application/json', + }); + const action = await store.dispatch(importProjectThunk(file)); + + expect(action.type).toBe('project/importProject/fulfilled'); + expect(store.getState().project.present.data.characters.ids).toEqual([ + '__proto__', + 'constructor', + ]); + expect(store.getState().project.present.data.worlds.ids).toEqual(['toString']); + }); + + // QNBS-v3: malformed normalized collections must fail before import side effects can create partial state. + it.each([ + { + name: 'missing entity', + characters: { ids: ['missing'], entities: {} }, + }, + { + name: 'orphan entity', + characters: { ids: ['c1'], entities: { c1: { id: 'c1' }, orphan: { id: 'orphan' } } }, + }, + ])('rejects normalized collections with $name', async ({ characters }) => { + const malformedProject = { ...minimalProject, characters }; + vi.mocked(parseImportedProjectJson).mockReturnValue(malformedProject as never); + + const store = makeStore(); + const file = new File([JSON.stringify(malformedProject)], 'novel.json', { + type: 'application/json', + }); + const action = await store.dispatch(importProjectThunk(file)); + + expect(action.type).toBe('project/importProject/rejected'); + expect(store.getState().project.present.data.title).toBe(''); + expect(storageService.saveImage).not.toHaveBeenCalled(); + }); + + // QNBS-v3: duplicate imported IDs are rejected instead of silently discarding project entities. + it('rejects duplicate imported entity IDs without creating a partial project', async () => { + const projectWithDuplicateIds = { + ...minimalProject, + characters: [ + { id: 'duplicate', name: 'First Character' }, + { id: 'duplicate', name: 'Second Character' }, + ], + }; + vi.mocked(parseImportedProjectJson).mockReturnValue(projectWithDuplicateIds as never); + + const store = makeStore(); + const file = new File([JSON.stringify(projectWithDuplicateIds)], 'novel.json', { + type: 'application/json', + }); + const action = await store.dispatch(importProjectThunk(file)); + + expect(action.type).toBe('project/importProject/rejected'); + expect(store.getState().project.present.data.title).toBe(''); + }); }); // --------------------------------------------------------------------------- diff --git a/tests/unit/useApp.test.ts b/tests/unit/useApp.test.ts index ac7615bc..e07e47f9 100644 --- a/tests/unit/useApp.test.ts +++ b/tests/unit/useApp.test.ts @@ -115,6 +115,25 @@ describe('useApp', () => { expect(result.current.isPortalActive).toBe(false); }); + it('disables fresh metadata seeding when the portal exits after an import', () => { + const { result } = renderHook(() => useApp({ isNewUser: true })); + expect(result.current.allowInitialMetadataSeed).toBe(true); + + act(() => { + result.current.handlePortalExit('manuscript', { allowInitialMetadataSeed: false }); + }); + + expect(result.current.allowInitialMetadataSeed).toBe(false); + }); + + // QNBS-v3: proves boot-derived seed authority can differ from first-run portal state. + it('uses explicit boot seed authority independently from isNewUser', () => { + const { result } = renderHook(() => + useApp({ isNewUser: false, allowInitialMetadataSeed: true }), + ); + expect(result.current.allowInitialMetadataSeed).toBe(true); + }); + it('activates portal for new users', () => { const { result } = renderHook(() => useApp({ isNewUser: true })); expect(result.current.isPortalActive).toBe(true); diff --git a/tests/unit/useProjectBootstrapEffect.test.ts b/tests/unit/useProjectBootstrapEffect.test.ts index 0bf78006..6e1d7d50 100644 --- a/tests/unit/useProjectBootstrapEffect.test.ts +++ b/tests/unit/useProjectBootstrapEffect.test.ts @@ -80,11 +80,13 @@ describe('shouldRunProjectBootstrap', () => { }); }); +// QNBS-v3: verifies bootstrap applies explicit seed authority without changing raw-key repair behavior. describe('useProjectBootstrapEffect', () => { it('never dispatches during the race window (isInitialLoad still true)', () => { renderHook(() => useProjectBootstrapEffect({ project: blankProject, + allowInitialMetadataSeed: false, isInitialLoad: true, isPortalActive: false, isI18nReady: true, @@ -94,19 +96,19 @@ describe('useProjectBootstrapEffect', () => { expect(dispatch).not.toHaveBeenCalled(); }); - it('seeds a blank project via the repair path once bootstrap has settled', () => { - // QNBS-v3: repairProjectI18nFields treats a blank title/logline/manuscript as "needs repair" too, so it — not the resetProject branch — is what actually seeds a brand-new blank project in practice. + it('preserves blank metadata for a returning project while still seeding its empty manuscript', () => { renderHook(() => useProjectBootstrapEffect({ project: blankProject, + allowInitialMetadataSeed: false, isInitialLoad: false, isPortalActive: false, isI18nReady: true, t, }), ); - expect(dispatch).toHaveBeenCalledWith(projectActions.updateTitle('initialProject.title')); - expect(dispatch).toHaveBeenCalledWith(projectActions.updateLogline('initialProject.logline')); + expect(dispatch).not.toHaveBeenCalledWith(projectActions.updateTitle(expect.anything())); + expect(dispatch).not.toHaveBeenCalledWith(projectActions.updateLogline(expect.anything())); expect(dispatch).toHaveBeenCalledWith( projectActions.setManuscript([ expect.objectContaining({ title: 'initialProject.chapter1', content: '' }), @@ -114,6 +116,28 @@ describe('useProjectBootstrapEffect', () => { ); }); + it('seeds blank metadata only once for a fresh user', () => { + const { rerender } = renderHook( + ({ project }) => + useProjectBootstrapEffect({ + project, + allowInitialMetadataSeed: true, + isInitialLoad: false, + isPortalActive: false, + isI18nReady: true, + t, + }), + { initialProps: { project: blankProject } }, + ); + expect(dispatch).toHaveBeenCalledWith(projectActions.updateTitle('initialProject.title')); + expect(dispatch).toHaveBeenCalledWith(projectActions.updateLogline('initialProject.logline')); + + dispatch.mockClear(); + rerender({ project: { ...blankProject } }); + expect(dispatch).not.toHaveBeenCalledWith(projectActions.updateTitle(expect.anything())); + expect(dispatch).not.toHaveBeenCalledWith(projectActions.updateLogline(expect.anything())); + }); + it('dispatches nothing for a project that already has real content', () => { renderHook(() => useProjectBootstrapEffect({ @@ -122,6 +146,7 @@ describe('useProjectBootstrapEffect', () => { logline: 'A real logline', manuscript: [{ id: 'sec-1', title: 'Ch1', content: 'Real content' }], }, + allowInitialMetadataSeed: false, isInitialLoad: false, isPortalActive: false, isI18nReady: true, @@ -135,6 +160,7 @@ describe('useProjectBootstrapEffect', () => { renderHook(() => useProjectBootstrapEffect({ project: blankProject, + allowInitialMetadataSeed: false, isInitialLoad: false, isPortalActive: true, isI18nReady: true, @@ -148,6 +174,7 @@ describe('useProjectBootstrapEffect', () => { renderHook(() => useProjectBootstrapEffect({ project: null, + allowInitialMetadataSeed: false, isInitialLoad: false, isPortalActive: false, isI18nReady: true, @@ -161,10 +188,11 @@ describe('useProjectBootstrapEffect', () => { renderHook(() => useProjectBootstrapEffect({ project: { - title: '', + title: 'initialProject.title', logline: 'A real logline', manuscript: [{ id: 'sec-1', title: 'Real Chapter', content: 'real' }], }, + allowInitialMetadataSeed: false, isInitialLoad: false, isPortalActive: false, isI18nReady: true, @@ -182,9 +210,10 @@ describe('useProjectBootstrapEffect', () => { useProjectBootstrapEffect({ project: { title: 'A Real Title', - logline: '', + logline: 'initialProject.logline', manuscript: [{ id: 'sec-1', title: 'Real Chapter', content: 'real' }], }, + allowInitialMetadataSeed: false, isInitialLoad: false, isPortalActive: false, isI18nReady: true,