Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<img src="https://img.shields.io/badge/Storage-IndexedDB_v8-F59E0B" alt="IndexedDB v8">
<img src="https://img.shields.io/badge/PWA-v3.0-5BB974?logo=pwa" alt="PWA v3.0">
<img src="https://img.shields.io/badge/i18n-19_locales-2937_keys-0EA5E9" alt="i18n 19 locales — 2937 keys">
<img src="https://img.shields.io/badge/Tests-7336%2B_%2F_595_files-22C55E" alt="7336+ tests / 595 files">
<img src="https://img.shields.io/badge/Tests-7350%2B_%2F_595_files-22C55E" alt="7350+ tests / 595 files">
<img src="https://img.shields.io/codecov/c/github/qnbs/WorldScript-Studio?logo=codecov&label=Coverage" alt="Codecov Coverage">
<img src="https://img.shields.io/badge/License-MIT-22C55E" alt="License MIT">
<img src="https://img.shields.io/github/actions/workflow/status/qnbs/WorldScript-Studio/.github/workflows/ci.yml?branch=main&logo=github" alt="CI Status">
Expand Down Expand Up @@ -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` |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
9 changes: 7 additions & 2 deletions features/project/adapters.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { createEntityAdapter } from '@reduxjs/toolkit';
import type { Character, World } from '../../types';

export const charactersAdapter = createEntityAdapter<Character>();
export const worldsAdapter = createEntityAdapter<World>();
// 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<Character>({
sortComparer: preserveEntityOrder,
});
export const worldsAdapter = createEntityAdapter<World>({ sortComparer: preserveEntityOrder });
22 changes: 7 additions & 15 deletions index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -132,20 +132,12 @@ async function bootApp(): Promise<void> {
// 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<string, unknown>)['project'];
Expand Down
96 changes: 85 additions & 11 deletions services/appBootstrap.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -41,26 +43,98 @@ function isRecord(value: unknown): value is Record<string, unknown> {
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<T>(
value: unknown,
adapter: EntityAdapter<T, string>,
): EntityState<T, string> | undefined {
if (Array.isArray(value)) {
const ids: string[] = [];
const entities = Object.create(null) as Record<string, T>;
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<string, T>;
const seenIds = new Set<string>();
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;
Comment thread
qnbs marked this conversation as resolved.
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.
Expand Down
Loading
Loading