diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ef8a4c97..a4849952 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -53,6 +53,16 @@ updates: - 'wry' - 'tao' + # Cargo / Rust (renderer-neutral Core, independent workspace โ€” see docs/native/CORE-MIGRATION-LEDGER.md) + - package-ecosystem: cargo + directory: /crates + schedule: + interval: weekly + day: monday + cooldown: + default-days: 7 + open-pull-requests-limit: 5 + # GitHub Actions - package-ecosystem: github-actions directory: / diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b683b1a..e617a53c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,8 +65,6 @@ jobs: - name: Vendor fork invariant guard run: pnpm run verify:vendor - # QNBS-v3: osv-scanner.toml (in src-tauri/) suppresses accepted RUSTSEC advisories for Cargo.lock; - # scan both lockfiles so npm + Rust advisories are caught on every push - name: OSV vulnerability scan uses: google/osv-scanner-action/osv-scanner-action@8deb546fdb875b9996d27d4950be7312dac076a1 # v2.5.0 with: @@ -74,6 +72,7 @@ jobs: --config=src-tauri/osv-scanner.toml --lockfile=pnpm-lock.yaml --lockfile=src-tauri/Cargo.lock + --lockfile=crates/Cargo.lock # QNBS-v3: gitleaks scans git history for secrets; GITHUB_TOKEN is enough for PR annotations. - name: Scan for leaked secrets (gitleaks) @@ -97,13 +96,14 @@ jobs: timeout-minutes: 5 outputs: tauri: ${{ steps.filter.outputs.tauri }} + crates: ${{ steps.filter.outputs.crates }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - - name: Detect src-tauri changes + - name: Detect src-tauri / crates changes id: filter run: | if [ "${{ github.event_name }}" = "pull_request" ]; then @@ -112,15 +112,22 @@ jobs: BASE="${{ github.event.before }}" fi if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ] || ! git cat-file -e "$BASE" 2>/dev/null; then - echo "::notice::No usable base SHA to diff against โ€” defaulting to tauri=true (fail open)" + echo "::notice::No usable base SHA to diff against โ€” defaulting to tauri=true, crates=true (fail open)" echo "tauri=true" >> "$GITHUB_OUTPUT" + echo "crates=true" >> "$GITHUB_OUTPUT" exit 0 fi - if git diff --name-only "$BASE" "${{ github.sha }}" | grep -qE '^(src-tauri/|\.github/workflows/ci\.yml$)'; then + CHANGED=$(git diff --name-only "$BASE" "${{ github.sha }}") + if grep -qE '^(src-tauri/|\.github/workflows/ci\.yml$)' <<< "$CHANGED"; then echo "tauri=true" >> "$GITHUB_OUTPUT" else echo "tauri=false" >> "$GITHUB_OUTPUT" fi + if grep -qE '^(crates/|tests/fixtures/project-golden-masters/|\.github/workflows/ci\.yml$)' <<< "$CHANGED"; then + echo "crates=true" >> "$GITHUB_OUTPUT" + else + echo "crates=false" >> "$GITHUB_OUTPUT" + fi # ---------------------------------------------------------- # 1. QUALITY GATE: Lint + Typecheck + Tests (parallel matrix) @@ -267,6 +274,39 @@ jobs: working-directory: src-tauri run: cargo test --locked + # ---------------------------------------------------------- + # 1c. CORE-RUST: worldscript-project crate Gate (fmt/check/clippy/test), path-scoped via `changes` + # ---------------------------------------------------------- + core-rust: + name: ๐Ÿงฉ Core Rust Gate + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: [security, changes] + if: needs.changes.outputs.crates == 'true' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + toolchain: stable + components: rustfmt, clippy + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + workspaces: crates -> target + - name: Rust format check + working-directory: crates + run: cargo fmt --check + - name: Rust compile check + working-directory: crates + run: cargo check --locked + - name: Rust clippy + working-directory: crates + run: cargo clippy --locked --all-targets -- -D warnings + - name: Rust tests + working-directory: crates + run: cargo test --locked + # ---------------------------------------------------------- # 2. BUILD: Production build + Pages artifact upload # QNBS-v3: Stryker mutation removed from the PR/CI pipeline (2026-06-02) โ€” it added noise as a @@ -368,16 +408,16 @@ jobs: path: ./dist # ---------------------------------------------------------- - # 3. CI SUCCESS: single required-status aggregator (security + quality + changes + rust-tauri + build + e2e + vrt) + # 3. CI SUCCESS: single required-status aggregator + # (security + quality + changes + rust-tauri + core-rust + build + e2e + vrt) # ---------------------------------------------------------- ci-success: name: โœ… CI Success runs-on: ubuntu-latest timeout-minutes: 5 - needs: [security, quality, changes, rust-tauri, build, e2e, vrt] + needs: [security, quality, changes, rust-tauri, core-rust, build, e2e, vrt] if: always() steps: - # QNBS-v3: rust-tauri may legitimately be 'skipped' (changes.outputs.tauri == 'false') โ€” that's a pass, not a failure - name: Verify all required jobs succeeded run: | FAIL=0 @@ -387,6 +427,9 @@ jobs: if [ "${{ needs.rust-tauri.result }}" != "success" ] && [ "${{ needs.rust-tauri.result }}" != "skipped" ]; then FAIL=1 fi + if [ "${{ needs.core-rust.result }}" != "success" ] && [ "${{ needs.core-rust.result }}" != "skipped" ]; then + FAIL=1 + fi [ "${{ needs.build.result }}" = "success" ] || FAIL=1 [ "${{ needs.e2e.result }}" = "success" ] || FAIL=1 [ "${{ needs.vrt.result }}" = "success" ] || FAIL=1 @@ -396,6 +439,7 @@ jobs: echo " quality: ${{ needs.quality.result }}" echo " changes: ${{ needs.changes.result }}" echo " rust-tauri: ${{ needs.rust-tauri.result }} (skipped = OK, src-tauri untouched)" + echo " core-rust: ${{ needs.core-rust.result }} (skipped = OK, crates/ untouched)" echo " build: ${{ needs.build.result }}" echo " e2e: ${{ needs.e2e.result }}" echo " vrt: ${{ needs.vrt.result }}" diff --git a/.gitignore b/.gitignore index c22f837c..2f916738 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,9 @@ tests/bench/baseline/ # Tauri build output src-tauri/target/ + +# crates/ (Wave 2 Rust Core workspace) build output +crates/target/ .env test-results/ diff --git a/CLAUDE.md b/CLAUDE.md index c4c06dab..67be10df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -282,7 +282,9 @@ On any non-trivial code change add a single-line comment explaining **why**, not | CMake (`CMakeLists.txt`) | `# QNBS-v3: ` | | Pure config (JSON, YAML, TOML โ€” e.g. `package.json`, workflow `.yml`, `Cargo.toml`) | No inline comment โ€” explain in the commit message | -**Hard rule โ€” one physical line, never wrapped, in every syntax above:** a `QNBS-v3: โ€ฆ` comment MUST fit on a single physical line, however long, regardless of which comment syntax (`//`, `#`, `/* */`) the language uses. Never split it across two lines (`// QNBS-v3: foo\n// bar`) โ€” CodeRabbit and Qodo both flag this as a nitpick on every PR that does it, and it has recurred across TS/JS, YAML, and C++ files in this repo's history. If the reason doesn't fit on one line, shorten it; don't wrap it. This applies the first time a new language/file type is touched too โ€” don't wait for a review bot to point out that the language wasn't in the table yet before applying the same one-line discipline. +**Hard rule โ€” one physical line, never wrapped, in every syntax above:** a `QNBS-v3: โ€ฆ` comment MUST fit on a single physical line, however long, regardless of which comment syntax (`//`, `#`, `/* */`) the language uses. Never split it across two lines (`// QNBS-v3: foo\n// bar`) โ€” CodeRabbit, Qodo, and chatgpt-codex-connector all flag this as a nitpick on every PR that does it, and it has recurred across TS/JS, YAML, Rust, and C++ files in this repo's history โ€” including cases where the agent wrote the violation itself in the same PR that documents the rule. If the reason doesn't fit on one line, shorten it; don't wrap it. This applies the first time a new language/file type is touched too โ€” don't wait for a review bot to point out that the language wasn't in the table yet before applying the same one-line discipline. + +**Mandatory self-check before every commit that adds or edits a `QNBS-v3` comment:** run `git diff --cached -- '*.ts' '*.tsx' '*.js' '*.mjs' '*.css' '*.rs' '*.cpp' '*.yml' '*.yaml' | grep -A1 "QNBS-v3"` (or equivalent for the touched paths) and confirm every matched `QNBS-v3:` line is followed by a line that does NOT start with the same comment token continuing the sentence (i.e., the next line is blank, unrelated code, or a new comment). Do this even when the comment "looks short enough" โ€” the violations in this repo's history were all cases the author believed fit, not deliberate multi-line comments. Treat a caught violation here as a required fix before committing, not an optional cleanup. Skip for pure formatting, lockfile updates, or generated artefacts. diff --git a/biome.json b/biome.json index 6c8879d4..145ad8db 100644 --- a/biome.json +++ b/biome.json @@ -14,6 +14,8 @@ "!!**/.storybook", "!!**/backend", "!!**/src-tauri", + "!!**/crates", + "!!**/tests/fixtures/project-golden-masters", "!!**/public/sw.js", "!!**/coverage", "!!**/playwright-report", diff --git a/crates/Cargo.lock b/crates/Cargo.lock new file mode 100644 index 00000000..36cce799 --- /dev/null +++ b/crates/Cargo.lock @@ -0,0 +1,107 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "worldscript-project" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/Cargo.toml b/crates/Cargo.toml new file mode 100644 index 00000000..def8cac4 --- /dev/null +++ b/crates/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +resolver = "2" +members = ["worldscript-project"] diff --git a/crates/worldscript-project/Cargo.toml b/crates/worldscript-project/Cargo.toml new file mode 100644 index 00000000..4541af90 --- /dev/null +++ b/crates/worldscript-project/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "worldscript-project" +version = "0.1.0" +edition = "2021" +rust-version = "1.77.2" +description = "Renderer-neutral WorldScript Studio project schema, validation, migration, and I/O (Wave 2)" +publish = false + +[lib] +name = "worldscript_project" + +[[bin]] +name = "wsproj" +path = "src/bin/wsproj.rs" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/crates/worldscript-project/src/bin/wsproj.rs b/crates/worldscript-project/src/bin/wsproj.rs new file mode 100644 index 00000000..65c76572 --- /dev/null +++ b/crates/worldscript-project/src/bin/wsproj.rs @@ -0,0 +1,146 @@ +//! Headless CLI proving the Wave 2 lifecycle scenario runs with no GUI/Tauri runtime present. +//! `cargo run --bin wsproj -- demo-lifecycle` + +use std::collections::hash_map::RandomState; +use std::env; +use std::fs::OpenOptions; +use std::hash::{BuildHasher, Hasher}; +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +use worldscript_project::schema::{Character, StorySection}; +use worldscript_project::{ + io, migrate_to_latest, parse_envelope, validate, ProjectEnvelope, StoryProject, +}; + +/// A scratch-file path under the OS temp dir with an unpredictable suffix, so a symlink +/// pre-planted at a guessable pid-only location can't be targeted ahead of time. +fn scratch_path(label: &str) -> PathBuf { + let random = RandomState::new().build_hasher().finish(); + let mut path = env::temp_dir(); + path.push(format!( + "wsproj-demo-{label}-{}-{random:016x}.json", + std::process::id() + )); + path +} + +/// Writes `contents` to a brand-new file at `path`, refusing to follow an existing symlink or +/// overwrite an existing file (`O_EXCL` semantics via `create_new`) โ€” unlike `fs::write`, which +/// would silently follow a pre-existing symlink and truncate whatever it points to. +fn write_new_file(path: &Path, contents: &str) -> std::io::Result<()> { + OpenOptions::new() + .write(true) + .create_new(true) + .open(path)? + .write_all(contents.as_bytes()) +} + +fn demo_lifecycle() -> Result<(), Box> { + let mut project = StoryProject::new("Demo Project", "A logline for the demo."); + project.characters.push(Character { + id: "char-1".to_string(), + name: "Ada".to_string(), + backstory: String::new(), + motivation: String::new(), + appearance: String::new(), + personality_traits: String::new(), + flaws: String::new(), + notes: String::new(), + has_avatar: None, + character_arc: String::new(), + relationships: String::new(), + }); + project.manuscript.push(StorySection { + id: "sec-1".to_string(), + title: "Chapter One".to_string(), + content: "It was a dark and stormy night.".to_string(), + summary: None, + notes: None, + prompt: None, + color: None, + position: None, + character_ids: None, + world_ids: None, + word_count: None, + status: None, + act: None, + scene_start: None, + scene_duration: None, + scene_location_id: None, + pov_character_id: None, + }); + + validate(&project)?; + println!( + "validated: {} character(s), {} section(s)", + project.characters.len(), + project.manuscript.len() + ); + + // Save-then-reload proves byte-for-byte round-trip fidelity of a current-schema project. + let envelope = ProjectEnvelope::current(project); + let path = scratch_path("lifecycle"); + write_new_file(&path, &serde_json::to_string_pretty(&envelope)?)?; + println!("saved to {}", path.display()); + let reloaded = io::load_project(&path)?; + assert_eq!(reloaded, envelope, "reload must round-trip exactly"); + std::fs::remove_file(&path)?; + + // Migration proof: hand-author a real v1 envelope (no `revisionNote` field), save it, reload + // it from disk, then migrate โ€” this exercises an actual v1 -> v2 step, not a same-version + // no-op, and asserts the backfilled field survives re-validation. + let v1_json = r#"{ + "schemaVersion": 1, + "project": { + "title": "Pre-Migration Demo Project", + "logline": "Written before schema v2 existed.", + "characters": [], + "worlds": [], + "manuscript": [] + } + }"#; + let v1_envelope = parse_envelope(v1_json)?; + let migration_path = scratch_path("migration"); + write_new_file( + &migration_path, + &serde_json::to_string_pretty(&v1_envelope)?, + )?; + let reloaded_v1 = io::load_project(&migration_path)?; + assert_eq!( + reloaded_v1.schema_version, 1, + "fixture must start at schema v1" + ); + let migrated = migrate_to_latest(reloaded_v1)?; + assert_eq!(migrated.schema_version, 2, "migration must reach schema v2"); + assert_eq!( + migrated.project.revision_note.as_deref(), + Some("migrated from schema v1"), + "migration must backfill revision_note" + ); + validate(&migrated.project)?; + println!( + "reloaded v1 project, migrated to schema v{}, revision_note={:?}, re-validated OK", + migrated.schema_version, migrated.project.revision_note + ); + std::fs::remove_file(&migration_path)?; + + Ok(()) +} + +fn main() -> ExitCode { + let args: Vec = env::args().collect(); + match args.get(1).map(String::as_str) { + Some("demo-lifecycle") => match demo_lifecycle() { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("demo-lifecycle failed: {e}"); + ExitCode::FAILURE + } + }, + _ => { + eprintln!("usage: wsproj demo-lifecycle"); + ExitCode::FAILURE + } + } +} diff --git a/crates/worldscript-project/src/envelope.rs b/crates/worldscript-project/src/envelope.rs new file mode 100644 index 00000000..efcd0ba5 --- /dev/null +++ b/crates/worldscript-project/src/envelope.rs @@ -0,0 +1,56 @@ +//! Versioned wrapper around `StoryProject`. +//! +//! No `schemaVersion` concept exists anywhere in `types.ts` or `services/projectImportSchema.ts` +//! today โ€” this is invented from scratch for Wave 2, not ported from an existing field. + +use crate::schema::StoryProject; +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// Current schema version this crate writes. Bump when `StoryProject`'s shape changes in a way +/// that requires a `migrate::Migration` entry to upgrade older files. v2 added `revision_note` +/// (see `migrate::v1_to_v2`). +pub const CURRENT_SCHEMA_VERSION: u32 = 2; + +/// The on-disk/on-wire shape: a schema version alongside the project payload. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectEnvelope { + pub schema_version: u32, + pub project: StoryProject, +} + +impl ProjectEnvelope { + /// Wraps a project at the current schema version. + pub fn current(project: StoryProject) -> Self { + Self { + schema_version: CURRENT_SCHEMA_VERSION, + project, + } + } +} + +/// A structured, non-panicking parse failure โ€” always names the offending field when `serde_json` +/// provides one, matching `services/projectImportSchema.ts#parseImportedProjectJson`'s +/// `Invalid project file: : ` convention. +#[derive(Debug)] +pub struct ParseError(serde_json::Error); + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "invalid project file: {}", self.0) + } +} + +impl std::error::Error for ParseError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.0) + } +} + +/// Parses raw JSON text into a `ProjectEnvelope`. Never panics on malformed/truncated input โ€” +/// `serde_json`'s `Error` already carries a line/column and, for a missing-field failure, the +/// field name. +pub fn parse_envelope(text: &str) -> Result { + serde_json::from_str(text).map_err(ParseError) +} diff --git a/crates/worldscript-project/src/io.rs b/crates/worldscript-project/src/io.rs new file mode 100644 index 00000000..09c0cf65 --- /dev/null +++ b/crates/worldscript-project/src/io.rs @@ -0,0 +1,45 @@ +//! Plain JSON load/save. +//! +//! Deliberately no compression and no atomic-write guarantees โ€” those are Wave 3 scope +//! (`docs/native/ROADMAP-QT-GPUI-DESKTOP.md` ยง15 Wave 3, "storage correctness and R-15 design"), +//! not this crate's job. This module proves the schema/validation/migration pattern round-trips +//! through real file I/O; it is not the eventual production storage path. + +use crate::envelope::{parse_envelope, ParseError, ProjectEnvelope}; +use std::fmt; +use std::fs; +use std::path::Path; + +#[derive(Debug)] +pub enum IoError { + Read(std::io::Error), + Write(std::io::Error), + Parse(ParseError), + Serialize(serde_json::Error), +} + +impl fmt::Display for IoError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + IoError::Read(e) => write!(f, "failed to read project file: {e}"), + IoError::Write(e) => write!(f, "failed to write project file: {e}"), + IoError::Parse(e) => write!(f, "{e}"), + IoError::Serialize(e) => write!(f, "failed to serialize project: {e}"), + } + } +} + +impl std::error::Error for IoError {} + +/// Loads and parses a `ProjectEnvelope` from `path`. Does not migrate โ€” callers that need the +/// latest shape should pass the result through `migrate::migrate_to_latest`. +pub fn load_project(path: impl AsRef) -> Result { + let text = fs::read_to_string(path).map_err(IoError::Read)?; + parse_envelope(&text).map_err(IoError::Parse) +} + +/// Serializes and writes a `ProjectEnvelope` to `path` as pretty-printed JSON. +pub fn save_project(path: impl AsRef, envelope: &ProjectEnvelope) -> Result<(), IoError> { + let text = serde_json::to_string_pretty(envelope).map_err(IoError::Serialize)?; + fs::write(path, text).map_err(IoError::Write) +} diff --git a/crates/worldscript-project/src/lib.rs b/crates/worldscript-project/src/lib.rs new file mode 100644 index 00000000..010df13b --- /dev/null +++ b/crates/worldscript-project/src/lib.rs @@ -0,0 +1,19 @@ +//! `worldscript-project` โ€” renderer-neutral project schema, validation, migration, and I/O. +//! +//! First Wave 2 slice ("Rust Core extraction and headless harness", +//! `docs/native/ROADMAP-QT-GPUI-DESKTOP.md` ยง15). Proves the pattern with the highest-priority +//! capability from `docs/native/CORE-MIGRATION-LEDGER.md`: the canonical project schema plus +//! enough validation/migration/I/O to round-trip a representative project lifecycle without any +//! GUI runtime. Deliberately does not include encryption, task orchestration, or AI request +//! logic โ€” see the ledger for what's deferred and why. + +pub mod envelope; +pub mod io; +pub mod migrate; +pub mod schema; +pub mod validate; + +pub use envelope::{parse_envelope, ParseError, ProjectEnvelope, CURRENT_SCHEMA_VERSION}; +pub use migrate::migrate_to_latest; +pub use schema::{Character, StoryProject, StorySection, World}; +pub use validate::{validate, ValidationError}; diff --git a/crates/worldscript-project/src/migrate.rs b/crates/worldscript-project/src/migrate.rs new file mode 100644 index 00000000..0960873c --- /dev/null +++ b/crates/worldscript-project/src/migrate.rs @@ -0,0 +1,104 @@ +//! Minimal versioned-migration mechanism. +//! +//! No unified "project schema version N -> N+1" migration framework exists on the TS side today โ€” +//! `services/dbMigration.ts` (one-time IDB topology copy), `idbProjectStore.ts#validateAndFixState` +//! (hand-written field-level repair-on-load), and the encryption-state journal/checkpoint saga each +//! approximate a narrower piece of this. This module formalizes the general concept for the Rust +//! Core, starting with one synthetic migration proving the mechanism. + +use crate::envelope::{ProjectEnvelope, CURRENT_SCHEMA_VERSION}; +use std::fmt; + +/// A migration step that upgrades an envelope from exactly `source_version` to +/// `source_version + 1`. +pub trait Migration { + fn source_version(&self) -> u32; + fn apply(&self, envelope: ProjectEnvelope) -> ProjectEnvelope; +} + +/// v1 -> v2: backfill `revision_note` for files written before that field existed. +pub struct V1ToV2; + +impl Migration for V1ToV2 { + fn source_version(&self) -> u32 { + 1 + } + + fn apply(&self, mut envelope: ProjectEnvelope) -> ProjectEnvelope { + if envelope.project.revision_note.is_none() { + envelope.project.revision_note = Some("migrated from schema v1".to_string()); + } + envelope.schema_version = 2; + envelope + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum MigrationError { + /// No migration is registered starting from this (older) version โ€” a genuine gap in the + /// registry. + NoMigrationFrom { version: u32 }, + /// The envelope's `schema_version` is *newer* than [`CURRENT_SCHEMA_VERSION`] โ€” this build + /// doesn't understand it. Without this check the migration loop's `while version < CURRENT` + /// condition is simply false for a future version, so it would silently return the envelope + /// unchanged; if that envelope is later re-saved, any fields this crate doesn't know about + /// are dropped (serde ignores unknown fields on deserialize). Reject instead of round-tripping + /// destructively. + FutureVersion { version: u32, current: u32 }, + /// A registered [`Migration::apply`] returned an envelope whose `schema_version` did not + /// increase โ€” the trait contract requires exactly +1 per step, but nothing else enforces it. + /// Without this check, a buggy future migration would make `migrate_to_latest`'s loop match + /// the same step forever (a hang, not an error) instead of failing loudly. + NonAdvancing { version: u32 }, +} + +impl fmt::Display for MigrationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + MigrationError::NoMigrationFrom { version } => write!( + f, + "no migration registered starting from schema version {version}" + ), + MigrationError::FutureVersion { version, current } => write!( + f, + "schema version {version} is newer than this build understands (current: {current}); refusing to load to avoid silently discarding unknown fields on re-save" + ), + MigrationError::NonAdvancing { version } => write!( + f, + "migration registered for schema version {version} did not advance schema_version; refusing to loop forever" + ), + } + } +} + +impl std::error::Error for MigrationError {} + +fn registry() -> Vec> { + vec![Box::new(V1ToV2)] +} + +/// Applies registered migrations sequentially until `envelope.schema_version` reaches +/// [`CURRENT_SCHEMA_VERSION`]. A no-op if the envelope is already current. Rejects a +/// `schema_version` newer than [`CURRENT_SCHEMA_VERSION`] rather than passing it through +/// unchanged โ€” see [`MigrationError::FutureVersion`]. +pub fn migrate_to_latest(mut envelope: ProjectEnvelope) -> Result { + if envelope.schema_version > CURRENT_SCHEMA_VERSION { + return Err(MigrationError::FutureVersion { + version: envelope.schema_version, + current: CURRENT_SCHEMA_VERSION, + }); + } + let migrations = registry(); + while envelope.schema_version < CURRENT_SCHEMA_VERSION { + let before = envelope.schema_version; + let step = migrations + .iter() + .find(|m| m.source_version() == before) + .ok_or(MigrationError::NoMigrationFrom { version: before })?; + envelope = step.apply(envelope); + if envelope.schema_version <= before { + return Err(MigrationError::NonAdvancing { version: before }); + } + } + Ok(envelope) +} diff --git a/crates/worldscript-project/src/schema.rs b/crates/worldscript-project/src/schema.rs new file mode 100644 index 00000000..4b1baf7f --- /dev/null +++ b/crates/worldscript-project/src/schema.rs @@ -0,0 +1,218 @@ +//! Renderer-neutral project schema, mirroring the domain shapes in `types.ts`. +//! +//! `characters`/`worlds` are plain `Vec` here, not the `Character[] | EntityState` union `types.ts` uses today โ€” that union exists only because the TS side normalizes +//! into Redux Toolkit's `EntityState` shape at the type level. This struct is the renderer-neutral +//! shape a future adapter converts to/from; it does not itself know about Redux. + +use serde::{Deserialize, Serialize}; + +/// Mirrors `types.ts`'s `Character` interface. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Character { + pub id: String, + pub name: String, + #[serde(default)] + pub backstory: String, + #[serde(default)] + pub motivation: String, + #[serde(default)] + pub appearance: String, + #[serde(default)] + pub personality_traits: String, + #[serde(default)] + pub flaws: String, + #[serde(default)] + pub notes: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub has_avatar: Option, + #[serde(default)] + pub character_arc: String, + #[serde(default)] + pub relationships: String, +} + +/// Mirrors `types.ts`'s `WorldLocation` interface. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorldLocation { + pub id: String, + pub name: String, + pub description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub coordinates: Option, + /// `worldLocationSchema.type` is a required enum in `services/projectImportSchema.ts`, not an + /// optional free-form string โ€” mirrored precisely so an unmodeled value is rejected, not dropped. + #[serde(rename = "type")] + pub location_type: LocationType, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub population: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub significance: Option, +} + +/// Mirrors `worldLocationSchema.coordinates` (`{ lat: number, lng: number }`). +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct Coordinates { + pub lat: f64, + pub lng: f64, +} + +/// Mirrors `worldLocationSchema.type`'s enum exactly โ€” `city | village | forest | mountain | +/// castle | temple | other`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LocationType { + City, + Village, + Forest, + Mountain, + Castle, + Temple, + Other, +} + +/// Mirrors `types.ts`'s `WorldTimelineEvent` interface. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorldTimelineEvent { + pub id: String, + pub era: String, + pub title: String, + pub description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub date: Option, +} + +/// Mirrors `types.ts`'s `World` interface. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct World { + pub id: String, + pub name: String, + /// `services/projectImportSchema.ts`'s `worldSchema` has `description: z.string().optional() + /// .default('')`, unlike `WorldLocation.description` (required there) โ€” mirrored precisely. + #[serde(default)] + pub description: String, + #[serde(default)] + pub geography: String, + #[serde(default)] + pub magic_system: String, + #[serde(default)] + pub culture: String, + #[serde(default)] + pub notes: String, + #[serde(default)] + pub timeline: Vec, + #[serde(default)] + pub locations: Vec, +} + +/// Mirrors `types.ts`'s `StorySection` interface (manuscript scene/chapter). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StorySection { + pub id: String, + pub title: String, + /// `services/projectImportSchema.ts`'s `storySectionSchema` has `content: z.string().optional() + /// .default('')` โ€” mirrored here so a section JSON that omits `content` (valid to the TS + /// importer) is accepted here too, not rejected as a missing required field. + #[serde(default)] + pub content: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub summary: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub notes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub position: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub character_ids: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub world_ids: Option>, + /// `storySectionSchema.wordCount` is an unrestricted `z.number()` (accepts negative/fractional + /// values); `u32` is a deliberate Rust-side tightening, matching the intentional-narrowing + /// precedent in `validate.rs` โ€” negative/fractional word counts aren't meaningful data, so this + /// crate rejects them rather than preserving nonsensical values byte-for-byte. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub word_count: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + /// `storySectionSchema.act` is `z.union([z.literal(1), z.literal(2), z.literal(3)])`; modeled + /// as `u8` to preserve the value on round-trip without re-implementing Zod's literal-union + /// restriction (a value outside 1-3 is preserved, not rejected โ€” no test currently exercises + /// that edge, unlike `status`/`type` which do have closed-enum coverage). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub act: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scene_start: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scene_duration: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scene_location_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pov_character_id: Option, +} + +/// Mirrors `storySectionSchema.position` (`{ x: number, y: number }`). +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct Position { + pub x: f64, + pub y: f64, +} + +/// Mirrors `storySectionSchema.status`'s enum exactly โ€” `draft | outline | first-draft | revised | +/// final`. Modeled as a closed enum (not `Option`) so an unsupported value like +/// `"published"` is rejected at parse time instead of silently accepted, matching Zod's rejection. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SectionStatus { + Draft, + Outline, + FirstDraft, + Revised, + Final, +} + +/// Mirrors `types.ts`'s `StoryProject` interface. Deliberately narrower than the full TS shape โ€” +/// `outline`/`binderNodes`/`compileProfile`/`projectGoals`/`writingHistory` are out of scope for +/// this first slice (see `docs/native/CORE-MIGRATION-LEDGER.md`); only the fields exercised by the +/// Wave 2 headless lifecycle scenario are modeled. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StoryProject { + pub title: String, + pub logline: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub author: Option, + #[serde(default)] + pub characters: Vec, + #[serde(default)] + pub worlds: Vec, + #[serde(default)] + pub manuscript: Vec, + /// Added at schema v2. Absent in v1 files; `migrate::migrate_to_latest` backfills it. Exists + /// purely to give the Wave 2 headless harness a real migration to prove, not a production field. + #[serde(default)] + pub revision_note: Option, +} + +impl StoryProject { + /// A new, empty project at the current schema version โ€” mirrors the "create new project" + /// entry point on the TS side. + pub fn new(title: impl Into, logline: impl Into) -> Self { + Self { + title: title.into(), + logline: logline.into(), + author: None, + characters: Vec::new(), + worlds: Vec::new(), + manuscript: Vec::new(), + revision_note: None, + } + } +} diff --git a/crates/worldscript-project/src/validate.rs b/crates/worldscript-project/src/validate.rs new file mode 100644 index 00000000..04a31ed8 --- /dev/null +++ b/crates/worldscript-project/src/validate.rs @@ -0,0 +1,64 @@ +//! Post-deserialization structural validation. +//! +//! `title`/`logline` presence-and-type checks are already enforced by `serde` at the JSON-parse +//! boundary (see `envelope::parse_envelope`) โ€” that mirrors `services/projectImportSchema.ts`'s +//! `title: z.string()` / `logline: z.string()` exactly (Zod does not require non-empty strings +//! either, so this crate does not add that check, to keep golden-master parity honest). What's +//! left here is a genuinely new invariant Zod does not currently enforce at all: IDs unique +//! *within* each record type (characters, worlds, manuscript sections checked independently โ€” a +//! character and a section are allowed to share an id; only two characters, or two sections, +//! colliding is an error). This is an intentional Rust-side tightening, not a ported rule โ€” +//! flagged explicitly so it doesn't get mistaken for existing TS behavior. + +use crate::schema::StoryProject; +use std::collections::HashSet; +use std::fmt; + +#[derive(Debug, PartialEq, Eq)] +pub enum ValidationError { + DuplicateCharacterId(String), + DuplicateWorldId(String), + DuplicateSectionId(String), +} + +impl fmt::Display for ValidationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ValidationError::DuplicateCharacterId(id) => { + write!(f, "duplicate character id: {id}") + } + ValidationError::DuplicateWorldId(id) => write!(f, "duplicate world id: {id}"), + ValidationError::DuplicateSectionId(id) => { + write!(f, "duplicate manuscript section id: {id}") + } + } + } +} + +impl std::error::Error for ValidationError {} + +/// Validates structural invariants on an already-deserialized project. +pub fn validate(project: &StoryProject) -> Result<(), ValidationError> { + let mut seen_characters = HashSet::new(); + for c in &project.characters { + if !seen_characters.insert(&c.id) { + return Err(ValidationError::DuplicateCharacterId(c.id.clone())); + } + } + + let mut seen_worlds = HashSet::new(); + for w in &project.worlds { + if !seen_worlds.insert(&w.id) { + return Err(ValidationError::DuplicateWorldId(w.id.clone())); + } + } + + let mut seen_sections = HashSet::new(); + for s in &project.manuscript { + if !seen_sections.insert(&s.id) { + return Err(ValidationError::DuplicateSectionId(s.id.clone())); + } + } + + Ok(()) +} diff --git a/crates/worldscript-project/tests/fixtures_test.rs b/crates/worldscript-project/tests/fixtures_test.rs new file mode 100644 index 00000000..6070553a --- /dev/null +++ b/crates/worldscript-project/tests/fixtures_test.rs @@ -0,0 +1,104 @@ +//! Golden-master fixture parity test. Same fixture files as +//! `tests/unit/projectGoldenMasters.test.ts` (repo root `tests/fixtures/project-golden-masters/`) +//! โ€” this asserts the Rust side's accept/reject verdict on each fixture; the TS test asserts +//! `services/projectImportSchema.ts`'s current verdict independently. Neither side reads the +//! other's code; the shared byte-identical fixture is the oracle. +//! +//! Fixtures hold the raw project shape (title/logline/characters/worlds/manuscript), matching +//! `services/projectImportSchema.ts#importedProjectJsonSchema` โ€” not the Rust-only +//! `ProjectEnvelope{schemaVersion,project}` wrapper, since Zod has no such wrapper. + +use std::fs; +use std::path::PathBuf; +use worldscript_project::schema::{Coordinates, LocationType, StoryProject}; +use worldscript_project::validate; + +fn fixtures_dir() -> PathBuf { + // crates/worldscript-project/tests/ -> crates/worldscript-project -> crates -> + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("crates/ parent") + .parent() + .expect("repo root") + .join("tests/fixtures/project-golden-masters") +} + +fn read_fixture(name: &str) -> String { + let path = fixtures_dir().join(name); + fs::read_to_string(&path).unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())) +} + +#[test] +fn empty_project_is_accepted() { + let text = read_fixture("empty-project.json"); + let project: StoryProject = + serde_json::from_str(&text).expect("empty-project.json should be accepted"); + validate(&project).expect("empty-project.json must also pass structural validation"); +} + +#[test] +fn typical_project_is_accepted() { + let text = read_fixture("typical-project.json"); + let project: StoryProject = + serde_json::from_str(&text).expect("typical-project.json should be accepted"); + assert_eq!(project.characters.len(), 2); + assert_eq!(project.manuscript.len(), 2); + validate(&project).expect("typical-project.json must also pass structural validation"); +} + +#[test] +fn typical_project_round_trip_preserves_location_fields() { + // A prior schema draft left `WorldLocation.type`/`coordinates`/`population` unmodeled, so serde + // silently dropped them on save even though `services/projectImportSchema.ts` requires `type` โ€” + // this locks in that a save/reload round trip keeps them intact. + let text = read_fixture("typical-project.json"); + let project: StoryProject = + serde_json::from_str(&text).expect("typical-project.json should be accepted"); + let location = &project.worlds[0].locations[0]; + assert_eq!(location.location_type, LocationType::Other); + assert_eq!(location.population, Some(1200)); + assert_eq!( + location.coordinates, + Some(Coordinates { + lat: 12.5, + lng: -3.25 + }) + ); + + let serialized = serde_json::to_string(&project).expect("serialize round-trip"); + let reloaded: StoryProject = serde_json::from_str(&serialized).expect("reparse round-trip"); + assert_eq!( + reloaded, project, + "save/reload must not lose location fields" + ); +} + +#[test] +fn large_project_is_accepted_and_preserves_counts() { + let text = read_fixture("large-project.json"); + let project: StoryProject = + serde_json::from_str(&text).expect("large-project.json should be accepted"); + assert_eq!(project.manuscript.len(), 250); + assert_eq!(project.characters.len(), 30); + validate(&project).expect("large-project.json must also pass structural validation"); +} + +#[test] +fn missing_title_is_rejected() { + let text = read_fixture("missing-title.json"); + let project: Result = serde_json::from_str(&text); + assert!( + project.is_err(), + "missing-title.json should be rejected (title is required)" + ); +} + +#[test] +fn truncated_json_is_rejected_without_panicking() { + let text = read_fixture("truncated.json"); + let project: Result = serde_json::from_str(&text); + assert!( + project.is_err(), + "truncated.json should be rejected as invalid JSON" + ); +} diff --git a/crates/worldscript-project/tests/lifecycle_test.rs b/crates/worldscript-project/tests/lifecycle_test.rs new file mode 100644 index 00000000..293f1afc --- /dev/null +++ b/crates/worldscript-project/tests/lifecycle_test.rs @@ -0,0 +1,213 @@ +//! Representative project lifecycle, proven with no GUI/Tauri runtime present +//! (see `crates/worldscript-project/Cargo.toml` โ€” zero dependencies beyond serde/serde_json). + +use std::env; +use std::sync::atomic::{AtomicU32, Ordering}; +use worldscript_project::envelope::parse_envelope; +use worldscript_project::migrate::{migrate_to_latest, MigrationError}; +use worldscript_project::schema::{Character, StorySection}; +use worldscript_project::validate::{validate, ValidationError}; +use worldscript_project::{io, ProjectEnvelope, StoryProject}; + +static COUNTER: AtomicU32 = AtomicU32::new(0); + +/// A unique temp-file path, avoiding a `tempfile` dependency for this narrow slice. +fn temp_path(label: &str) -> std::path::PathBuf { + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let mut path = env::temp_dir(); + path.push(format!( + "wsproj-test-{}-{}-{}.json", + std::process::id(), + label, + n + )); + path +} + +fn sample_character() -> Character { + Character { + id: "char-1".to_string(), + name: "Ada Lovelace".to_string(), + backstory: "Mathematician.".to_string(), + motivation: String::new(), + appearance: String::new(), + personality_traits: String::new(), + flaws: String::new(), + notes: String::new(), + has_avatar: None, + character_arc: String::new(), + relationships: String::new(), + } +} + +fn sample_section() -> StorySection { + StorySection { + id: "sec-1".to_string(), + title: "Chapter One".to_string(), + content: "It was a dark and stormy night.".to_string(), + summary: None, + notes: None, + prompt: None, + color: None, + position: None, + character_ids: None, + world_ids: None, + word_count: None, + status: None, + act: None, + scene_start: None, + scene_duration: None, + scene_location_id: None, + pov_character_id: None, + } +} + +#[test] +fn full_lifecycle_round_trips_with_no_data_loss() { + // 1. Create a new project wrapped at the current schema version. + let mut project = StoryProject::new("Test Project", "A test logline."); + + // 2. Add a character, add a manuscript section. + project.characters.push(sample_character()); + project.manuscript.push(sample_section()); + + // 3. Validate โ€” must pass. + validate(&project).expect("freshly built project must validate"); + + let envelope = ProjectEnvelope::current(project); + let path = temp_path("lifecycle"); + + // 4. Save -> reload -> assert structural equality. + io::save_project(&path, &envelope).expect("save must succeed"); + let reloaded = io::load_project(&path).expect("reload must succeed"); + assert_eq!( + reloaded, envelope, + "reload must be byte-for-byte structurally identical" + ); + + // 5. Simulate a schema bump: hand-construct a v1 envelope (no `revisionNote` field) and + // migrate it. + let v1_json = r#"{ + "schemaVersion": 1, + "project": { + "title": "V1 Project", + "logline": "Written before schema v2 existed.", + "characters": [], + "worlds": [], + "manuscript": [] + } + }"#; + let v1_envelope = parse_envelope(v1_json).expect("v1 envelope must parse"); + assert_eq!(v1_envelope.schema_version, 1); + assert!(v1_envelope.project.revision_note.is_none()); + + let migrated = migrate_to_latest(v1_envelope).expect("migration must succeed"); + assert_eq!(migrated.schema_version, 2); + assert_eq!( + migrated.project.revision_note.as_deref(), + Some("migrated from schema v1") + ); + + // 6. Reload the migrated shape from a saved file, re-validate, assert zero data loss. + let migrated_path = temp_path("migrated"); + io::save_project(&migrated_path, &migrated).expect("save migrated envelope must succeed"); + let reloaded_migrated = io::load_project(&migrated_path).expect("reload migrated must succeed"); + validate(&reloaded_migrated.project).expect("migrated project must validate"); + assert_eq!(reloaded_migrated.project.title, "V1 Project"); + assert_eq!(reloaded_migrated.project.characters.len(), 0); + + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file(&migrated_path); +} + +#[test] +fn section_and_world_missing_optional_string_fields_default_to_empty() { + // Matches services/projectImportSchema.ts: storySectionSchema.content and worldSchema.description + // are both `z.string().optional().default('')` โ€” a section/world omitting them must still parse, + // not be rejected as missing a required field. + let json = r#"{ + "schemaVersion": 2, + "project": { + "title": "T", "logline": "L", + "characters": [], + "worlds": [{"id": "w1", "name": "World One"}], + "manuscript": [{"id": "sec-1", "title": "Untitled"}] + } + }"#; + let envelope = + parse_envelope(json).expect("omitted content/description must default, not fail"); + assert_eq!(envelope.project.manuscript[0].content, ""); + assert_eq!(envelope.project.worlds[0].description, ""); +} + +#[test] +fn corrupt_json_fails_without_panicking() { + let result = parse_envelope("{ this is not valid json"); + assert!( + result.is_err(), + "truncated/corrupt JSON must be a structured error, not a panic" + ); +} + +#[test] +fn missing_required_field_is_rejected_with_field_name() { + // Missing `title`. + let json = r#"{"schemaVersion": 2, "project": {"logline": "no title here"}}"#; + let err = parse_envelope(json).expect_err("missing required field must be rejected"); + let message = err.to_string(); + assert!( + message.contains("title"), + "error message should identify the missing field, got: {message}" + ); +} + +#[test] +fn duplicate_character_ids_are_rejected() { + let mut project = StoryProject::new("Dup Test", "logline"); + project.characters.push(sample_character()); + project.characters.push(sample_character()); // same id, on purpose + let err = validate(&project).expect_err("duplicate character ids must be rejected"); + assert_eq!( + err, + ValidationError::DuplicateCharacterId("char-1".to_string()) + ); +} + +#[test] +fn future_schema_version_is_rejected_not_silently_passed_through() { + let future_json = r#"{ + "schemaVersion": 99, + "project": { + "title": "From The Future", + "logline": "Written by a newer build.", + "characters": [], + "worlds": [], + "manuscript": [] + } + }"#; + let envelope = parse_envelope(future_json) + .expect("parsing itself succeeds โ€” unknown fields are just ignored"); + let err = migrate_to_latest(envelope) + .expect_err("a future schema version must be rejected, not silently treated as current"); + assert_eq!( + err, + MigrationError::FutureVersion { + version: 99, + current: worldscript_project::envelope::CURRENT_SCHEMA_VERSION, + } + ); +} + +#[test] +fn same_id_across_different_record_types_is_allowed() { + // Documents the intentional design: uniqueness is checked within each record type + // independently (characters/worlds/sections), not globally across the whole project. + let mut project = StoryProject::new("Cross-Type Test", "logline"); + let mut character = sample_character(); + character.id = "shared-id".to_string(); + let mut section = sample_section(); + section.id = "shared-id".to_string(); + project.characters.push(character); + project.manuscript.push(section); + validate(&project).expect("a character and a section may share an id"); +} diff --git a/docs/native/CORE-MIGRATION-LEDGER.md b/docs/native/CORE-MIGRATION-LEDGER.md new file mode 100644 index 00000000..73bfb799 --- /dev/null +++ b/docs/native/CORE-MIGRATION-LEDGER.md @@ -0,0 +1,43 @@ +# Core Migration Ledger + +Tracks, per capability, where its authoritative implementation currently lives and what Wave moves +it (if at all) into the renderer-neutral Rust Core. See `docs/native/ROADMAP-QT-GPUI-DESKTOP.md` ยง15 +for wave definitions and `docs/adr/0021-qt-gpui-native-desktop-strategy.md` for the strategic +decision this serves. First populated for Wave 2's first slice (2026-08-20); update this table as +scope shifts โ€” it is a living decision record, not a one-time snapshot. + +| # | Capability | Owner (lang/module) | Renderer coupling | Security sensitivity | Data-integrity sensitivity | Performance sensitivity | Native-reuse value | Migration priority | Migration strategy | Compatibility tests | +|---|---|---|---|---|---|---|---|---|---|---| +| 1 | Project schema | TS, `types.ts` (916 lines) โ€” `StoryProject`/`Character`/`World`/`StorySection`/`Settings` | Medium โ€” `characters`/`worlds` are typed `Character[] \| EntityState`, a Redux Toolkit coupling at the type level | Low | High โ€” canonical shape everything else depends on | Low | High โ€” every renderer needs the same shape | **1 โ€” Wave 2 first slice** | Port the array-only member of the union to Rust structs (serde, camelCase); TS type left unchanged | Golden-master JSON fixtures decode identically in Zod and Rust | +| 2 | Validation | TS, `services/projectImportSchema.ts` (352 lines, Zod, import-only) + `idbProjectStore.ts#validateAndFixState` (hand-written repair-on-load, separate path) | Low, but split across two divergent implementations today | Low-medium (first line of defense against malformed/malicious import files) | High | Low | High | **1 โ€” Wave 2 first slice** | Hand-rolled Rust validation mirroring the Zod ruleset for the modeled fields; does not reconcile the Zod-vs-hand-written TS divergence | Same golden-master fixtures; accept/reject parity asserted per fixture | +| 3 | Storage โ€” fs backend (plain load/save) | TS, `services/fs/` (7 files, 1,253 lines) | High today (Tauri-fs-plugin coupled) | Low today โ€” `fsCore.ts#encryptText`/`decryptText` are dead code, zero call sites | High (atomic-write semantics, compression) | Medium (large-project I/O) | High | **2 โ€” Wave 2 first slice, narrow** | New crate proves plain JSON load/save on its own format; does not replace `projectFsStore.ts` | Round-trip identity tests inside the new crate only, not yet byte-compared against `.wsproj` | +| 4 | Logger / diagnostics | TS, `services/logger.ts` (312 lines) โ€” dual-sink (IDB ring buffer + Tauri JSONL), single GDPR-redaction chokepoint | Medium (dual-sink dispatch is renderer-aware, but small) | Medium (redaction chokepoint) | Low | Low | Medium-high โ€” small, self-contained | 3 โ€” natural next fast-follow | Not started | Not started | +| 5 | Task-orchestration expansion | Rust, `src-tauri/src/commands/task_supervisor.rs` (310 lines, one task type) + TS `packages/worker-bus/` (5,130 lines, full orchestration surface) | worker-bus TS side is Web-Worker-pool coupled; Rust side already renderer-neutral in principle | Low-medium | Low (task results, not persisted project state) | Medium-high | High long-term, existing Rust is a narrow stub | 4 โ€” natural next fast-follow | Not started | Not started | +| 6 | Storage โ€” IDB backend (all encryption) | TS, `services/storage/` (18 files, 5,363 lines) โ€” mature AES-256-GCM, ADR-0018 "Accepted and implemented" | High โ€” IndexedDB is browser-only, not portable to Qt/GPUI as-is | High, mature | High | Medium | Low as literally written; high as a design reference | **Deferred to Wave 3-4** | None in Wave 2. ADR-0018's 6 invariants are required reading for Wave 3 crypto design | None in Wave 2 | +| 7 | `features/project/` domain logic | TS, `features/project/` (24 files, 2,114 lines) โ€” real logic concentrated in `thunks/` + `projectSelectors.ts` (~450-500 lines); `reducers/` (11 files) is CRUD bookkeeping | High โ€” Redux-store-shape/dispatch bound; `reducers/` stays TS-side permanently | Low | Medium (import/restore orchestration) | Low-medium | Medium (only the thunks/selectors subset) | Deferred | Candidate after the schema crate is proven; only thunks/selectors, never `reducers/` | Not started | +| 8 | AI services | TS, `services/ai/` (44 files, 5,401 lines), mixed portability (retry/routing/error-taxonomy renderer-neutral vs. `computeShaderFactory.ts`/`webGpuDetectorService.ts`/`.wgsl` inherently WebGPU-coupled) | Mixed | Medium-high (API keys) | Low-medium | Medium | Uncertain โ€” too large/mixed to assess narrowly | **Out of scope for all of Wave 2** | None proposed | None | + +## Decisions this table records + +- **Wave 2 first slice touches only rows 1-3**, narrowly: project schema, validation, and a + plaintext (no compression, no atomicity guarantee) fs load/save round-trip. This is deliberately + the smallest slice that satisfies Wave 2's stated exit criterion ("representative project + lifecycle executes without any GUI runtime") โ€” see `crates/worldscript-project/`. +- **Row 6 (IDB encryption) is the mature reference design for Wave 3-4**, not a Wave 2 target. It + stays fully TS-side and untouched; only its *design* (ADR-0018's invariants, the journal/ + checkpoint/lease saga) informs the future Rust crypto module. +- **Row 8 (AI services) is explicitly out of scope for all of Wave 2**, not just this slice โ€” it is + too large (5,401 lines) and too mixed in portability (WebGPU-coupled pieces have no Rust path) for + a clean narrow extraction. Revisit only once rows 1-5 are proven. +- Rows 4-5 (logger, task-orchestration expansion) are the natural next fast-follows after this + slice โ€” both small and self-contained, with row 5 already having real Rust code to build on. + +## What Wave 2's first slice does NOT do + +No encryption (design or implementation) โ€” Wave 3-4 only. No touching `services/storage/*` (IDB) or +reconciling it with fs storage. No `packages/worker-bus` subsumption or `task_supervisor.rs` task- +registry expansion. No `services/ai/*` work. No changes to `src-tauri/src/lora.rs` or `pandoc.rs`. No +Qt/GPUI code. No unifying `src-tauri/Cargo.toml` into one repo-root Cargo workspace (see +`crates/Cargo.toml`'s own scope note). No replacing `projectFsStore.ts`/`storageService.ts`'s +dispatch โ€” any future Tauri-command wiring is additive-and-unused-by-default only. No +compression-format parity with the existing `.wsproj` files (open question, not solved here). diff --git a/docs/native/ROADMAP-QT-GPUI-DESKTOP.md b/docs/native/ROADMAP-QT-GPUI-DESKTOP.md index 01538067..75031522 100644 --- a/docs/native/ROADMAP-QT-GPUI-DESKTOP.md +++ b/docs/native/ROADMAP-QT-GPUI-DESKTOP.md @@ -953,9 +953,10 @@ Required: ## G1 โ€” Core Native-Ready ```text -[ ] DesktopPlatform capability inventory complete (already true โ€” packages/desktop-contracts) -[ ] direct Tauri imports constrained (already true โ€” guardrail:desktop-imports, zero-tolerance) -[ ] project/storage/crypto/migration headless APIs exist +[x] DesktopPlatform capability inventory complete (packages/desktop-contracts) +[x] direct Tauri imports constrained (guardrail:desktop-imports, zero-tolerance) +[~] project headless API exists โ€” crates/worldscript-project (schema/validation/migration/plain I/O); + storage (fs/IDB parity) and crypto remain, see docs/native/CORE-MIGRATION-LEDGER.md [ ] R-15 architecture approved [ ] task supervision renderer-neutral [ ] diagnostics renderer-neutral @@ -1101,21 +1102,28 @@ Shared product code cannot casually import Tauri, Qt or GPUI. ## Wave 2 โ€” Rust Core extraction and headless harness -**Status: PLANNED โ€” not started.** +**Status: IN PROGRESS โ€” first slice shipped.** `crates/worldscript-project` (a new, independent +Cargo workspace โ€” see `docs/native/CORE-MIGRATION-LEDGER.md` for why it's not unified with +`src-tauri`) implements project schema, validation, a minimal versioned-migration mechanism, and +plain JSON load/save, proven by a headless `cargo test` suite and a `wsproj demo-lifecycle` CLI with +zero GUI/Tauri dependencies. This satisfies the wave's exit criterion below. Task orchestration, full +storage-backend parity, diagnostics, and AI request model extraction are explicitly deferred โ€” see +the ledger's priority order โ€” so this wave stays "in progress," not "complete," until at least the +next fast-follow slice (logger/diagnostics, per the ledger) lands. **Goal:** establish authoritative renderer-neutral execution. -Actions: +Actions (checked = this slice, per `docs/native/CORE-MIGRATION-LEDGER.md`): -- project load/save; -- validation; -- schema; -- migration; -- storage; -- task orchestration; -- diagnostics; -- AI request model; -- headless CLI/test harness. +- [x] project load/save (plain JSON, no compression/atomicity โ€” that's Wave 3); +- [x] validation; +- [x] schema; +- [x] migration (minimal versioned mechanism, one synthetic migration proving it); +- [ ] storage (fs backend parity, IDB backend โ€” deferred); +- [ ] task orchestration (deferred fast-follow); +- [ ] diagnostics (deferred fast-follow); +- [ ] AI request model (out of scope for all of Wave 2 โ€” see ledger); +- [x] headless CLI/test harness. Exit: @@ -1123,6 +1131,10 @@ Exit: Representative project lifecycle executes without any GUI runtime. ``` +Met by this slice: `cargo test` in `crates/` (9 tests: lifecycle + golden-master fixture parity) and +`cargo run --bin wsproj -- demo-lifecycle` both run with zero GUI/Tauri dependency in the crate's +`cargo tree`. + ## Wave 3 โ€” Storage correctness and R-15 design **Status: PLANNED โ€” not started.** diff --git a/tests/fixtures/project-golden-masters/empty-project.json b/tests/fixtures/project-golden-masters/empty-project.json new file mode 100644 index 00000000..4225bb54 --- /dev/null +++ b/tests/fixtures/project-golden-masters/empty-project.json @@ -0,0 +1,4 @@ +{ + "title": "Empty Project", + "logline": "A minimal project with no content yet." +} diff --git a/tests/fixtures/project-golden-masters/large-project.json b/tests/fixtures/project-golden-masters/large-project.json new file mode 100644 index 00000000..ca3de433 --- /dev/null +++ b/tests/fixtures/project-golden-masters/large-project.json @@ -0,0 +1,2119 @@ +{ + "title": "The Long Saga", + "logline": "A stress-test project with many sections and characters.", + "characters": [ + { + "id": "char-1", + "name": "Character 1", + "backstory": "A brief backstory.", + "motivation": "", + "appearance": "", + "personalityTraits": "", + "flaws": "", + "notes": "", + "characterArc": "", + "relationships": "" + }, + { + "id": "char-2", + "name": "Character 2", + "backstory": "A brief backstory.", + "motivation": "", + "appearance": "", + "personalityTraits": "", + "flaws": "", + "notes": "", + "characterArc": "", + "relationships": "" + }, + { + "id": "char-3", + "name": "Character 3", + "backstory": "A brief backstory.", + "motivation": "", + "appearance": "", + "personalityTraits": "", + "flaws": "", + "notes": "", + "characterArc": "", + "relationships": "" + }, + { + "id": "char-4", + "name": "Character 4", + "backstory": "A brief backstory.", + "motivation": "", + "appearance": "", + "personalityTraits": "", + "flaws": "", + "notes": "", + "characterArc": "", + "relationships": "" + }, + { + "id": "char-5", + "name": "Character 5", + "backstory": "A brief backstory.", + "motivation": "", + "appearance": "", + "personalityTraits": "", + "flaws": "", + "notes": "", + "characterArc": "", + "relationships": "" + }, + { + "id": "char-6", + "name": "Character 6", + "backstory": "A brief backstory.", + "motivation": "", + "appearance": "", + "personalityTraits": "", + "flaws": "", + "notes": "", + "characterArc": "", + "relationships": "" + }, + { + "id": "char-7", + "name": "Character 7", + "backstory": "A brief backstory.", + "motivation": "", + "appearance": "", + "personalityTraits": "", + "flaws": "", + "notes": "", + "characterArc": "", + "relationships": "" + }, + { + "id": "char-8", + "name": "Character 8", + "backstory": "A brief backstory.", + "motivation": "", + "appearance": "", + "personalityTraits": "", + "flaws": "", + "notes": "", + "characterArc": "", + "relationships": "" + }, + { + "id": "char-9", + "name": "Character 9", + "backstory": "A brief backstory.", + "motivation": "", + "appearance": "", + "personalityTraits": "", + "flaws": "", + "notes": "", + "characterArc": "", + "relationships": "" + }, + { + "id": "char-10", + "name": "Character 10", + "backstory": "A brief backstory.", + "motivation": "", + "appearance": "", + "personalityTraits": "", + "flaws": "", + "notes": "", + "characterArc": "", + "relationships": "" + }, + { + "id": "char-11", + "name": "Character 11", + "backstory": "A brief backstory.", + "motivation": "", + "appearance": "", + "personalityTraits": "", + "flaws": "", + "notes": "", + "characterArc": "", + "relationships": "" + }, + { + "id": "char-12", + "name": "Character 12", + "backstory": "A brief backstory.", + "motivation": "", + "appearance": "", + "personalityTraits": "", + "flaws": "", + "notes": "", + "characterArc": "", + "relationships": "" + }, + { + "id": "char-13", + "name": "Character 13", + "backstory": "A brief backstory.", + "motivation": "", + "appearance": "", + "personalityTraits": "", + "flaws": "", + "notes": "", + "characterArc": "", + "relationships": "" + }, + { + "id": "char-14", + "name": "Character 14", + "backstory": "A brief backstory.", + "motivation": "", + "appearance": "", + "personalityTraits": "", + "flaws": "", + "notes": "", + "characterArc": "", + "relationships": "" + }, + { + "id": "char-15", + "name": "Character 15", + "backstory": "A brief backstory.", + "motivation": "", + "appearance": "", + "personalityTraits": "", + "flaws": "", + "notes": "", + "characterArc": "", + "relationships": "" + }, + { + "id": "char-16", + "name": "Character 16", + "backstory": "A brief backstory.", + "motivation": "", + "appearance": "", + "personalityTraits": "", + "flaws": "", + "notes": "", + "characterArc": "", + "relationships": "" + }, + { + "id": "char-17", + "name": "Character 17", + "backstory": "A brief backstory.", + "motivation": "", + "appearance": "", + "personalityTraits": "", + "flaws": "", + "notes": "", + "characterArc": "", + "relationships": "" + }, + { + "id": "char-18", + "name": "Character 18", + "backstory": "A brief backstory.", + "motivation": "", + "appearance": "", + "personalityTraits": "", + "flaws": "", + "notes": "", + "characterArc": "", + "relationships": "" + }, + { + "id": "char-19", + "name": "Character 19", + "backstory": "A brief backstory.", + "motivation": "", + "appearance": "", + "personalityTraits": "", + "flaws": "", + "notes": "", + "characterArc": "", + "relationships": "" + }, + { + "id": "char-20", + "name": "Character 20", + "backstory": "A brief backstory.", + "motivation": "", + "appearance": "", + "personalityTraits": "", + "flaws": "", + "notes": "", + "characterArc": "", + "relationships": "" + }, + { + "id": "char-21", + "name": "Character 21", + "backstory": "A brief backstory.", + "motivation": "", + "appearance": "", + "personalityTraits": "", + "flaws": "", + "notes": "", + "characterArc": "", + "relationships": "" + }, + { + "id": "char-22", + "name": "Character 22", + "backstory": "A brief backstory.", + "motivation": "", + "appearance": "", + "personalityTraits": "", + "flaws": "", + "notes": "", + "characterArc": "", + "relationships": "" + }, + { + "id": "char-23", + "name": "Character 23", + "backstory": "A brief backstory.", + "motivation": "", + "appearance": "", + "personalityTraits": "", + "flaws": "", + "notes": "", + "characterArc": "", + "relationships": "" + }, + { + "id": "char-24", + "name": "Character 24", + "backstory": "A brief backstory.", + "motivation": "", + "appearance": "", + "personalityTraits": "", + "flaws": "", + "notes": "", + "characterArc": "", + "relationships": "" + }, + { + "id": "char-25", + "name": "Character 25", + "backstory": "A brief backstory.", + "motivation": "", + "appearance": "", + "personalityTraits": "", + "flaws": "", + "notes": "", + "characterArc": "", + "relationships": "" + }, + { + "id": "char-26", + "name": "Character 26", + "backstory": "A brief backstory.", + "motivation": "", + "appearance": "", + "personalityTraits": "", + "flaws": "", + "notes": "", + "characterArc": "", + "relationships": "" + }, + { + "id": "char-27", + "name": "Character 27", + "backstory": "A brief backstory.", + "motivation": "", + "appearance": "", + "personalityTraits": "", + "flaws": "", + "notes": "", + "characterArc": "", + "relationships": "" + }, + { + "id": "char-28", + "name": "Character 28", + "backstory": "A brief backstory.", + "motivation": "", + "appearance": "", + "personalityTraits": "", + "flaws": "", + "notes": "", + "characterArc": "", + "relationships": "" + }, + { + "id": "char-29", + "name": "Character 29", + "backstory": "A brief backstory.", + "motivation": "", + "appearance": "", + "personalityTraits": "", + "flaws": "", + "notes": "", + "characterArc": "", + "relationships": "" + }, + { + "id": "char-30", + "name": "Character 30", + "backstory": "A brief backstory.", + "motivation": "", + "appearance": "", + "personalityTraits": "", + "flaws": "", + "notes": "", + "characterArc": "", + "relationships": "" + } + ], + "worlds": [], + "manuscript": [ + { + "id": "sec-1", + "title": "Scene 1", + "content": "This is the content of scene 1. This is the content of scene 1. This is the content of scene 1. This is the content of scene 1. This is the content of scene 1. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-2", + "title": "Scene 2", + "content": "This is the content of scene 2. This is the content of scene 2. This is the content of scene 2. This is the content of scene 2. This is the content of scene 2. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-3", + "title": "Scene 3", + "content": "This is the content of scene 3. This is the content of scene 3. This is the content of scene 3. This is the content of scene 3. This is the content of scene 3. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-4", + "title": "Scene 4", + "content": "This is the content of scene 4. This is the content of scene 4. This is the content of scene 4. This is the content of scene 4. This is the content of scene 4. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-5", + "title": "Scene 5", + "content": "This is the content of scene 5. This is the content of scene 5. This is the content of scene 5. This is the content of scene 5. This is the content of scene 5. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-6", + "title": "Scene 6", + "content": "This is the content of scene 6. This is the content of scene 6. This is the content of scene 6. This is the content of scene 6. This is the content of scene 6. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-7", + "title": "Scene 7", + "content": "This is the content of scene 7. This is the content of scene 7. This is the content of scene 7. This is the content of scene 7. This is the content of scene 7. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-8", + "title": "Scene 8", + "content": "This is the content of scene 8. This is the content of scene 8. This is the content of scene 8. This is the content of scene 8. This is the content of scene 8. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-9", + "title": "Scene 9", + "content": "This is the content of scene 9. This is the content of scene 9. This is the content of scene 9. This is the content of scene 9. This is the content of scene 9. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-10", + "title": "Scene 10", + "content": "This is the content of scene 10. This is the content of scene 10. This is the content of scene 10. This is the content of scene 10. This is the content of scene 10. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-11", + "title": "Scene 11", + "content": "This is the content of scene 11. This is the content of scene 11. This is the content of scene 11. This is the content of scene 11. This is the content of scene 11. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-12", + "title": "Scene 12", + "content": "This is the content of scene 12. This is the content of scene 12. This is the content of scene 12. This is the content of scene 12. This is the content of scene 12. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-13", + "title": "Scene 13", + "content": "This is the content of scene 13. This is the content of scene 13. This is the content of scene 13. This is the content of scene 13. This is the content of scene 13. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-14", + "title": "Scene 14", + "content": "This is the content of scene 14. This is the content of scene 14. This is the content of scene 14. This is the content of scene 14. This is the content of scene 14. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-15", + "title": "Scene 15", + "content": "This is the content of scene 15. This is the content of scene 15. This is the content of scene 15. This is the content of scene 15. This is the content of scene 15. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-16", + "title": "Scene 16", + "content": "This is the content of scene 16. This is the content of scene 16. This is the content of scene 16. This is the content of scene 16. This is the content of scene 16. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-17", + "title": "Scene 17", + "content": "This is the content of scene 17. This is the content of scene 17. This is the content of scene 17. This is the content of scene 17. This is the content of scene 17. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-18", + "title": "Scene 18", + "content": "This is the content of scene 18. This is the content of scene 18. This is the content of scene 18. This is the content of scene 18. This is the content of scene 18. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-19", + "title": "Scene 19", + "content": "This is the content of scene 19. This is the content of scene 19. This is the content of scene 19. This is the content of scene 19. This is the content of scene 19. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-20", + "title": "Scene 20", + "content": "This is the content of scene 20. This is the content of scene 20. This is the content of scene 20. This is the content of scene 20. This is the content of scene 20. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-21", + "title": "Scene 21", + "content": "This is the content of scene 21. This is the content of scene 21. This is the content of scene 21. This is the content of scene 21. This is the content of scene 21. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-22", + "title": "Scene 22", + "content": "This is the content of scene 22. This is the content of scene 22. This is the content of scene 22. This is the content of scene 22. This is the content of scene 22. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-23", + "title": "Scene 23", + "content": "This is the content of scene 23. This is the content of scene 23. This is the content of scene 23. This is the content of scene 23. This is the content of scene 23. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-24", + "title": "Scene 24", + "content": "This is the content of scene 24. This is the content of scene 24. This is the content of scene 24. This is the content of scene 24. This is the content of scene 24. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-25", + "title": "Scene 25", + "content": "This is the content of scene 25. This is the content of scene 25. This is the content of scene 25. This is the content of scene 25. This is the content of scene 25. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-26", + "title": "Scene 26", + "content": "This is the content of scene 26. This is the content of scene 26. This is the content of scene 26. This is the content of scene 26. This is the content of scene 26. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-27", + "title": "Scene 27", + "content": "This is the content of scene 27. This is the content of scene 27. This is the content of scene 27. This is the content of scene 27. This is the content of scene 27. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-28", + "title": "Scene 28", + "content": "This is the content of scene 28. This is the content of scene 28. This is the content of scene 28. This is the content of scene 28. This is the content of scene 28. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-29", + "title": "Scene 29", + "content": "This is the content of scene 29. This is the content of scene 29. This is the content of scene 29. This is the content of scene 29. This is the content of scene 29. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-30", + "title": "Scene 30", + "content": "This is the content of scene 30. This is the content of scene 30. This is the content of scene 30. This is the content of scene 30. This is the content of scene 30. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-31", + "title": "Scene 31", + "content": "This is the content of scene 31. This is the content of scene 31. This is the content of scene 31. This is the content of scene 31. This is the content of scene 31. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-32", + "title": "Scene 32", + "content": "This is the content of scene 32. This is the content of scene 32. This is the content of scene 32. This is the content of scene 32. This is the content of scene 32. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-33", + "title": "Scene 33", + "content": "This is the content of scene 33. This is the content of scene 33. This is the content of scene 33. This is the content of scene 33. This is the content of scene 33. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-34", + "title": "Scene 34", + "content": "This is the content of scene 34. This is the content of scene 34. This is the content of scene 34. This is the content of scene 34. This is the content of scene 34. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-35", + "title": "Scene 35", + "content": "This is the content of scene 35. This is the content of scene 35. This is the content of scene 35. This is the content of scene 35. This is the content of scene 35. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-36", + "title": "Scene 36", + "content": "This is the content of scene 36. This is the content of scene 36. This is the content of scene 36. This is the content of scene 36. This is the content of scene 36. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-37", + "title": "Scene 37", + "content": "This is the content of scene 37. This is the content of scene 37. This is the content of scene 37. This is the content of scene 37. This is the content of scene 37. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-38", + "title": "Scene 38", + "content": "This is the content of scene 38. This is the content of scene 38. This is the content of scene 38. This is the content of scene 38. This is the content of scene 38. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-39", + "title": "Scene 39", + "content": "This is the content of scene 39. This is the content of scene 39. This is the content of scene 39. This is the content of scene 39. This is the content of scene 39. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-40", + "title": "Scene 40", + "content": "This is the content of scene 40. This is the content of scene 40. This is the content of scene 40. This is the content of scene 40. This is the content of scene 40. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-41", + "title": "Scene 41", + "content": "This is the content of scene 41. This is the content of scene 41. This is the content of scene 41. This is the content of scene 41. This is the content of scene 41. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-42", + "title": "Scene 42", + "content": "This is the content of scene 42. This is the content of scene 42. This is the content of scene 42. This is the content of scene 42. This is the content of scene 42. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-43", + "title": "Scene 43", + "content": "This is the content of scene 43. This is the content of scene 43. This is the content of scene 43. This is the content of scene 43. This is the content of scene 43. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-44", + "title": "Scene 44", + "content": "This is the content of scene 44. This is the content of scene 44. This is the content of scene 44. This is the content of scene 44. This is the content of scene 44. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-45", + "title": "Scene 45", + "content": "This is the content of scene 45. This is the content of scene 45. This is the content of scene 45. This is the content of scene 45. This is the content of scene 45. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-46", + "title": "Scene 46", + "content": "This is the content of scene 46. This is the content of scene 46. This is the content of scene 46. This is the content of scene 46. This is the content of scene 46. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-47", + "title": "Scene 47", + "content": "This is the content of scene 47. This is the content of scene 47. This is the content of scene 47. This is the content of scene 47. This is the content of scene 47. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-48", + "title": "Scene 48", + "content": "This is the content of scene 48. This is the content of scene 48. This is the content of scene 48. This is the content of scene 48. This is the content of scene 48. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-49", + "title": "Scene 49", + "content": "This is the content of scene 49. This is the content of scene 49. This is the content of scene 49. This is the content of scene 49. This is the content of scene 49. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-50", + "title": "Scene 50", + "content": "This is the content of scene 50. This is the content of scene 50. This is the content of scene 50. This is the content of scene 50. This is the content of scene 50. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-51", + "title": "Scene 51", + "content": "This is the content of scene 51. This is the content of scene 51. This is the content of scene 51. This is the content of scene 51. This is the content of scene 51. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-52", + "title": "Scene 52", + "content": "This is the content of scene 52. This is the content of scene 52. This is the content of scene 52. This is the content of scene 52. This is the content of scene 52. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-53", + "title": "Scene 53", + "content": "This is the content of scene 53. This is the content of scene 53. This is the content of scene 53. This is the content of scene 53. This is the content of scene 53. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-54", + "title": "Scene 54", + "content": "This is the content of scene 54. This is the content of scene 54. This is the content of scene 54. This is the content of scene 54. This is the content of scene 54. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-55", + "title": "Scene 55", + "content": "This is the content of scene 55. This is the content of scene 55. This is the content of scene 55. This is the content of scene 55. This is the content of scene 55. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-56", + "title": "Scene 56", + "content": "This is the content of scene 56. This is the content of scene 56. This is the content of scene 56. This is the content of scene 56. This is the content of scene 56. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-57", + "title": "Scene 57", + "content": "This is the content of scene 57. This is the content of scene 57. This is the content of scene 57. This is the content of scene 57. This is the content of scene 57. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-58", + "title": "Scene 58", + "content": "This is the content of scene 58. This is the content of scene 58. This is the content of scene 58. This is the content of scene 58. This is the content of scene 58. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-59", + "title": "Scene 59", + "content": "This is the content of scene 59. This is the content of scene 59. This is the content of scene 59. This is the content of scene 59. This is the content of scene 59. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-60", + "title": "Scene 60", + "content": "This is the content of scene 60. This is the content of scene 60. This is the content of scene 60. This is the content of scene 60. This is the content of scene 60. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-61", + "title": "Scene 61", + "content": "This is the content of scene 61. This is the content of scene 61. This is the content of scene 61. This is the content of scene 61. This is the content of scene 61. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-62", + "title": "Scene 62", + "content": "This is the content of scene 62. This is the content of scene 62. This is the content of scene 62. This is the content of scene 62. This is the content of scene 62. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-63", + "title": "Scene 63", + "content": "This is the content of scene 63. This is the content of scene 63. This is the content of scene 63. This is the content of scene 63. This is the content of scene 63. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-64", + "title": "Scene 64", + "content": "This is the content of scene 64. This is the content of scene 64. This is the content of scene 64. This is the content of scene 64. This is the content of scene 64. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-65", + "title": "Scene 65", + "content": "This is the content of scene 65. This is the content of scene 65. This is the content of scene 65. This is the content of scene 65. This is the content of scene 65. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-66", + "title": "Scene 66", + "content": "This is the content of scene 66. This is the content of scene 66. This is the content of scene 66. This is the content of scene 66. This is the content of scene 66. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-67", + "title": "Scene 67", + "content": "This is the content of scene 67. This is the content of scene 67. This is the content of scene 67. This is the content of scene 67. This is the content of scene 67. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-68", + "title": "Scene 68", + "content": "This is the content of scene 68. This is the content of scene 68. This is the content of scene 68. This is the content of scene 68. This is the content of scene 68. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-69", + "title": "Scene 69", + "content": "This is the content of scene 69. This is the content of scene 69. This is the content of scene 69. This is the content of scene 69. This is the content of scene 69. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-70", + "title": "Scene 70", + "content": "This is the content of scene 70. This is the content of scene 70. This is the content of scene 70. This is the content of scene 70. This is the content of scene 70. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-71", + "title": "Scene 71", + "content": "This is the content of scene 71. This is the content of scene 71. This is the content of scene 71. This is the content of scene 71. This is the content of scene 71. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-72", + "title": "Scene 72", + "content": "This is the content of scene 72. This is the content of scene 72. This is the content of scene 72. This is the content of scene 72. This is the content of scene 72. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-73", + "title": "Scene 73", + "content": "This is the content of scene 73. This is the content of scene 73. This is the content of scene 73. This is the content of scene 73. This is the content of scene 73. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-74", + "title": "Scene 74", + "content": "This is the content of scene 74. This is the content of scene 74. This is the content of scene 74. This is the content of scene 74. This is the content of scene 74. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-75", + "title": "Scene 75", + "content": "This is the content of scene 75. This is the content of scene 75. This is the content of scene 75. This is the content of scene 75. This is the content of scene 75. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-76", + "title": "Scene 76", + "content": "This is the content of scene 76. This is the content of scene 76. This is the content of scene 76. This is the content of scene 76. This is the content of scene 76. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-77", + "title": "Scene 77", + "content": "This is the content of scene 77. This is the content of scene 77. This is the content of scene 77. This is the content of scene 77. This is the content of scene 77. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-78", + "title": "Scene 78", + "content": "This is the content of scene 78. This is the content of scene 78. This is the content of scene 78. This is the content of scene 78. This is the content of scene 78. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-79", + "title": "Scene 79", + "content": "This is the content of scene 79. This is the content of scene 79. This is the content of scene 79. This is the content of scene 79. This is the content of scene 79. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-80", + "title": "Scene 80", + "content": "This is the content of scene 80. This is the content of scene 80. This is the content of scene 80. This is the content of scene 80. This is the content of scene 80. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-81", + "title": "Scene 81", + "content": "This is the content of scene 81. This is the content of scene 81. This is the content of scene 81. This is the content of scene 81. This is the content of scene 81. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-82", + "title": "Scene 82", + "content": "This is the content of scene 82. This is the content of scene 82. This is the content of scene 82. This is the content of scene 82. This is the content of scene 82. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-83", + "title": "Scene 83", + "content": "This is the content of scene 83. This is the content of scene 83. This is the content of scene 83. This is the content of scene 83. This is the content of scene 83. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-84", + "title": "Scene 84", + "content": "This is the content of scene 84. This is the content of scene 84. This is the content of scene 84. This is the content of scene 84. This is the content of scene 84. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-85", + "title": "Scene 85", + "content": "This is the content of scene 85. This is the content of scene 85. This is the content of scene 85. This is the content of scene 85. This is the content of scene 85. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-86", + "title": "Scene 86", + "content": "This is the content of scene 86. This is the content of scene 86. This is the content of scene 86. This is the content of scene 86. This is the content of scene 86. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-87", + "title": "Scene 87", + "content": "This is the content of scene 87. This is the content of scene 87. This is the content of scene 87. This is the content of scene 87. This is the content of scene 87. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-88", + "title": "Scene 88", + "content": "This is the content of scene 88. This is the content of scene 88. This is the content of scene 88. This is the content of scene 88. This is the content of scene 88. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-89", + "title": "Scene 89", + "content": "This is the content of scene 89. This is the content of scene 89. This is the content of scene 89. This is the content of scene 89. This is the content of scene 89. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-90", + "title": "Scene 90", + "content": "This is the content of scene 90. This is the content of scene 90. This is the content of scene 90. This is the content of scene 90. This is the content of scene 90. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-91", + "title": "Scene 91", + "content": "This is the content of scene 91. This is the content of scene 91. This is the content of scene 91. This is the content of scene 91. This is the content of scene 91. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-92", + "title": "Scene 92", + "content": "This is the content of scene 92. This is the content of scene 92. This is the content of scene 92. This is the content of scene 92. This is the content of scene 92. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-93", + "title": "Scene 93", + "content": "This is the content of scene 93. This is the content of scene 93. This is the content of scene 93. This is the content of scene 93. This is the content of scene 93. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-94", + "title": "Scene 94", + "content": "This is the content of scene 94. This is the content of scene 94. This is the content of scene 94. This is the content of scene 94. This is the content of scene 94. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-95", + "title": "Scene 95", + "content": "This is the content of scene 95. This is the content of scene 95. This is the content of scene 95. This is the content of scene 95. This is the content of scene 95. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-96", + "title": "Scene 96", + "content": "This is the content of scene 96. This is the content of scene 96. This is the content of scene 96. This is the content of scene 96. This is the content of scene 96. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-97", + "title": "Scene 97", + "content": "This is the content of scene 97. This is the content of scene 97. This is the content of scene 97. This is the content of scene 97. This is the content of scene 97. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-98", + "title": "Scene 98", + "content": "This is the content of scene 98. This is the content of scene 98. This is the content of scene 98. This is the content of scene 98. This is the content of scene 98. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-99", + "title": "Scene 99", + "content": "This is the content of scene 99. This is the content of scene 99. This is the content of scene 99. This is the content of scene 99. This is the content of scene 99. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-100", + "title": "Scene 100", + "content": "This is the content of scene 100. This is the content of scene 100. This is the content of scene 100. This is the content of scene 100. This is the content of scene 100. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-101", + "title": "Scene 101", + "content": "This is the content of scene 101. This is the content of scene 101. This is the content of scene 101. This is the content of scene 101. This is the content of scene 101. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-102", + "title": "Scene 102", + "content": "This is the content of scene 102. This is the content of scene 102. This is the content of scene 102. This is the content of scene 102. This is the content of scene 102. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-103", + "title": "Scene 103", + "content": "This is the content of scene 103. This is the content of scene 103. This is the content of scene 103. This is the content of scene 103. This is the content of scene 103. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-104", + "title": "Scene 104", + "content": "This is the content of scene 104. This is the content of scene 104. This is the content of scene 104. This is the content of scene 104. This is the content of scene 104. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-105", + "title": "Scene 105", + "content": "This is the content of scene 105. This is the content of scene 105. This is the content of scene 105. This is the content of scene 105. This is the content of scene 105. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-106", + "title": "Scene 106", + "content": "This is the content of scene 106. This is the content of scene 106. This is the content of scene 106. This is the content of scene 106. This is the content of scene 106. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-107", + "title": "Scene 107", + "content": "This is the content of scene 107. This is the content of scene 107. This is the content of scene 107. This is the content of scene 107. This is the content of scene 107. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-108", + "title": "Scene 108", + "content": "This is the content of scene 108. This is the content of scene 108. This is the content of scene 108. This is the content of scene 108. This is the content of scene 108. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-109", + "title": "Scene 109", + "content": "This is the content of scene 109. This is the content of scene 109. This is the content of scene 109. This is the content of scene 109. This is the content of scene 109. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-110", + "title": "Scene 110", + "content": "This is the content of scene 110. This is the content of scene 110. This is the content of scene 110. This is the content of scene 110. This is the content of scene 110. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-111", + "title": "Scene 111", + "content": "This is the content of scene 111. This is the content of scene 111. This is the content of scene 111. This is the content of scene 111. This is the content of scene 111. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-112", + "title": "Scene 112", + "content": "This is the content of scene 112. This is the content of scene 112. This is the content of scene 112. This is the content of scene 112. This is the content of scene 112. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-113", + "title": "Scene 113", + "content": "This is the content of scene 113. This is the content of scene 113. This is the content of scene 113. This is the content of scene 113. This is the content of scene 113. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-114", + "title": "Scene 114", + "content": "This is the content of scene 114. This is the content of scene 114. This is the content of scene 114. This is the content of scene 114. This is the content of scene 114. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-115", + "title": "Scene 115", + "content": "This is the content of scene 115. This is the content of scene 115. This is the content of scene 115. This is the content of scene 115. This is the content of scene 115. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-116", + "title": "Scene 116", + "content": "This is the content of scene 116. This is the content of scene 116. This is the content of scene 116. This is the content of scene 116. This is the content of scene 116. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-117", + "title": "Scene 117", + "content": "This is the content of scene 117. This is the content of scene 117. This is the content of scene 117. This is the content of scene 117. This is the content of scene 117. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-118", + "title": "Scene 118", + "content": "This is the content of scene 118. This is the content of scene 118. This is the content of scene 118. This is the content of scene 118. This is the content of scene 118. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-119", + "title": "Scene 119", + "content": "This is the content of scene 119. This is the content of scene 119. This is the content of scene 119. This is the content of scene 119. This is the content of scene 119. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-120", + "title": "Scene 120", + "content": "This is the content of scene 120. This is the content of scene 120. This is the content of scene 120. This is the content of scene 120. This is the content of scene 120. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-121", + "title": "Scene 121", + "content": "This is the content of scene 121. This is the content of scene 121. This is the content of scene 121. This is the content of scene 121. This is the content of scene 121. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-122", + "title": "Scene 122", + "content": "This is the content of scene 122. This is the content of scene 122. This is the content of scene 122. This is the content of scene 122. This is the content of scene 122. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-123", + "title": "Scene 123", + "content": "This is the content of scene 123. This is the content of scene 123. This is the content of scene 123. This is the content of scene 123. This is the content of scene 123. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-124", + "title": "Scene 124", + "content": "This is the content of scene 124. This is the content of scene 124. This is the content of scene 124. This is the content of scene 124. This is the content of scene 124. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-125", + "title": "Scene 125", + "content": "This is the content of scene 125. This is the content of scene 125. This is the content of scene 125. This is the content of scene 125. This is the content of scene 125. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-126", + "title": "Scene 126", + "content": "This is the content of scene 126. This is the content of scene 126. This is the content of scene 126. This is the content of scene 126. This is the content of scene 126. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-127", + "title": "Scene 127", + "content": "This is the content of scene 127. This is the content of scene 127. This is the content of scene 127. This is the content of scene 127. This is the content of scene 127. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-128", + "title": "Scene 128", + "content": "This is the content of scene 128. This is the content of scene 128. This is the content of scene 128. This is the content of scene 128. This is the content of scene 128. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-129", + "title": "Scene 129", + "content": "This is the content of scene 129. This is the content of scene 129. This is the content of scene 129. This is the content of scene 129. This is the content of scene 129. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-130", + "title": "Scene 130", + "content": "This is the content of scene 130. This is the content of scene 130. This is the content of scene 130. This is the content of scene 130. This is the content of scene 130. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-131", + "title": "Scene 131", + "content": "This is the content of scene 131. This is the content of scene 131. This is the content of scene 131. This is the content of scene 131. This is the content of scene 131. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-132", + "title": "Scene 132", + "content": "This is the content of scene 132. This is the content of scene 132. This is the content of scene 132. This is the content of scene 132. This is the content of scene 132. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-133", + "title": "Scene 133", + "content": "This is the content of scene 133. This is the content of scene 133. This is the content of scene 133. This is the content of scene 133. This is the content of scene 133. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-134", + "title": "Scene 134", + "content": "This is the content of scene 134. This is the content of scene 134. This is the content of scene 134. This is the content of scene 134. This is the content of scene 134. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-135", + "title": "Scene 135", + "content": "This is the content of scene 135. This is the content of scene 135. This is the content of scene 135. This is the content of scene 135. This is the content of scene 135. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-136", + "title": "Scene 136", + "content": "This is the content of scene 136. This is the content of scene 136. This is the content of scene 136. This is the content of scene 136. This is the content of scene 136. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-137", + "title": "Scene 137", + "content": "This is the content of scene 137. This is the content of scene 137. This is the content of scene 137. This is the content of scene 137. This is the content of scene 137. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-138", + "title": "Scene 138", + "content": "This is the content of scene 138. This is the content of scene 138. This is the content of scene 138. This is the content of scene 138. This is the content of scene 138. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-139", + "title": "Scene 139", + "content": "This is the content of scene 139. This is the content of scene 139. This is the content of scene 139. This is the content of scene 139. This is the content of scene 139. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-140", + "title": "Scene 140", + "content": "This is the content of scene 140. This is the content of scene 140. This is the content of scene 140. This is the content of scene 140. This is the content of scene 140. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-141", + "title": "Scene 141", + "content": "This is the content of scene 141. This is the content of scene 141. This is the content of scene 141. This is the content of scene 141. This is the content of scene 141. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-142", + "title": "Scene 142", + "content": "This is the content of scene 142. This is the content of scene 142. This is the content of scene 142. This is the content of scene 142. This is the content of scene 142. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-143", + "title": "Scene 143", + "content": "This is the content of scene 143. This is the content of scene 143. This is the content of scene 143. This is the content of scene 143. This is the content of scene 143. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-144", + "title": "Scene 144", + "content": "This is the content of scene 144. This is the content of scene 144. This is the content of scene 144. This is the content of scene 144. This is the content of scene 144. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-145", + "title": "Scene 145", + "content": "This is the content of scene 145. This is the content of scene 145. This is the content of scene 145. This is the content of scene 145. This is the content of scene 145. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-146", + "title": "Scene 146", + "content": "This is the content of scene 146. This is the content of scene 146. This is the content of scene 146. This is the content of scene 146. This is the content of scene 146. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-147", + "title": "Scene 147", + "content": "This is the content of scene 147. This is the content of scene 147. This is the content of scene 147. This is the content of scene 147. This is the content of scene 147. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-148", + "title": "Scene 148", + "content": "This is the content of scene 148. This is the content of scene 148. This is the content of scene 148. This is the content of scene 148. This is the content of scene 148. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-149", + "title": "Scene 149", + "content": "This is the content of scene 149. This is the content of scene 149. This is the content of scene 149. This is the content of scene 149. This is the content of scene 149. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-150", + "title": "Scene 150", + "content": "This is the content of scene 150. This is the content of scene 150. This is the content of scene 150. This is the content of scene 150. This is the content of scene 150. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-151", + "title": "Scene 151", + "content": "This is the content of scene 151. This is the content of scene 151. This is the content of scene 151. This is the content of scene 151. This is the content of scene 151. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-152", + "title": "Scene 152", + "content": "This is the content of scene 152. This is the content of scene 152. This is the content of scene 152. This is the content of scene 152. This is the content of scene 152. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-153", + "title": "Scene 153", + "content": "This is the content of scene 153. This is the content of scene 153. This is the content of scene 153. This is the content of scene 153. This is the content of scene 153. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-154", + "title": "Scene 154", + "content": "This is the content of scene 154. This is the content of scene 154. This is the content of scene 154. This is the content of scene 154. This is the content of scene 154. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-155", + "title": "Scene 155", + "content": "This is the content of scene 155. This is the content of scene 155. This is the content of scene 155. This is the content of scene 155. This is the content of scene 155. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-156", + "title": "Scene 156", + "content": "This is the content of scene 156. This is the content of scene 156. This is the content of scene 156. This is the content of scene 156. This is the content of scene 156. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-157", + "title": "Scene 157", + "content": "This is the content of scene 157. This is the content of scene 157. This is the content of scene 157. This is the content of scene 157. This is the content of scene 157. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-158", + "title": "Scene 158", + "content": "This is the content of scene 158. This is the content of scene 158. This is the content of scene 158. This is the content of scene 158. This is the content of scene 158. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-159", + "title": "Scene 159", + "content": "This is the content of scene 159. This is the content of scene 159. This is the content of scene 159. This is the content of scene 159. This is the content of scene 159. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-160", + "title": "Scene 160", + "content": "This is the content of scene 160. This is the content of scene 160. This is the content of scene 160. This is the content of scene 160. This is the content of scene 160. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-161", + "title": "Scene 161", + "content": "This is the content of scene 161. This is the content of scene 161. This is the content of scene 161. This is the content of scene 161. This is the content of scene 161. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-162", + "title": "Scene 162", + "content": "This is the content of scene 162. This is the content of scene 162. This is the content of scene 162. This is the content of scene 162. This is the content of scene 162. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-163", + "title": "Scene 163", + "content": "This is the content of scene 163. This is the content of scene 163. This is the content of scene 163. This is the content of scene 163. This is the content of scene 163. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-164", + "title": "Scene 164", + "content": "This is the content of scene 164. This is the content of scene 164. This is the content of scene 164. This is the content of scene 164. This is the content of scene 164. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-165", + "title": "Scene 165", + "content": "This is the content of scene 165. This is the content of scene 165. This is the content of scene 165. This is the content of scene 165. This is the content of scene 165. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-166", + "title": "Scene 166", + "content": "This is the content of scene 166. This is the content of scene 166. This is the content of scene 166. This is the content of scene 166. This is the content of scene 166. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-167", + "title": "Scene 167", + "content": "This is the content of scene 167. This is the content of scene 167. This is the content of scene 167. This is the content of scene 167. This is the content of scene 167. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-168", + "title": "Scene 168", + "content": "This is the content of scene 168. This is the content of scene 168. This is the content of scene 168. This is the content of scene 168. This is the content of scene 168. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-169", + "title": "Scene 169", + "content": "This is the content of scene 169. This is the content of scene 169. This is the content of scene 169. This is the content of scene 169. This is the content of scene 169. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-170", + "title": "Scene 170", + "content": "This is the content of scene 170. This is the content of scene 170. This is the content of scene 170. This is the content of scene 170. This is the content of scene 170. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-171", + "title": "Scene 171", + "content": "This is the content of scene 171. This is the content of scene 171. This is the content of scene 171. This is the content of scene 171. This is the content of scene 171. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-172", + "title": "Scene 172", + "content": "This is the content of scene 172. This is the content of scene 172. This is the content of scene 172. This is the content of scene 172. This is the content of scene 172. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-173", + "title": "Scene 173", + "content": "This is the content of scene 173. This is the content of scene 173. This is the content of scene 173. This is the content of scene 173. This is the content of scene 173. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-174", + "title": "Scene 174", + "content": "This is the content of scene 174. This is the content of scene 174. This is the content of scene 174. This is the content of scene 174. This is the content of scene 174. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-175", + "title": "Scene 175", + "content": "This is the content of scene 175. This is the content of scene 175. This is the content of scene 175. This is the content of scene 175. This is the content of scene 175. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-176", + "title": "Scene 176", + "content": "This is the content of scene 176. This is the content of scene 176. This is the content of scene 176. This is the content of scene 176. This is the content of scene 176. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-177", + "title": "Scene 177", + "content": "This is the content of scene 177. This is the content of scene 177. This is the content of scene 177. This is the content of scene 177. This is the content of scene 177. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-178", + "title": "Scene 178", + "content": "This is the content of scene 178. This is the content of scene 178. This is the content of scene 178. This is the content of scene 178. This is the content of scene 178. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-179", + "title": "Scene 179", + "content": "This is the content of scene 179. This is the content of scene 179. This is the content of scene 179. This is the content of scene 179. This is the content of scene 179. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-180", + "title": "Scene 180", + "content": "This is the content of scene 180. This is the content of scene 180. This is the content of scene 180. This is the content of scene 180. This is the content of scene 180. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-181", + "title": "Scene 181", + "content": "This is the content of scene 181. This is the content of scene 181. This is the content of scene 181. This is the content of scene 181. This is the content of scene 181. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-182", + "title": "Scene 182", + "content": "This is the content of scene 182. This is the content of scene 182. This is the content of scene 182. This is the content of scene 182. This is the content of scene 182. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-183", + "title": "Scene 183", + "content": "This is the content of scene 183. This is the content of scene 183. This is the content of scene 183. This is the content of scene 183. This is the content of scene 183. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-184", + "title": "Scene 184", + "content": "This is the content of scene 184. This is the content of scene 184. This is the content of scene 184. This is the content of scene 184. This is the content of scene 184. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-185", + "title": "Scene 185", + "content": "This is the content of scene 185. This is the content of scene 185. This is the content of scene 185. This is the content of scene 185. This is the content of scene 185. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-186", + "title": "Scene 186", + "content": "This is the content of scene 186. This is the content of scene 186. This is the content of scene 186. This is the content of scene 186. This is the content of scene 186. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-187", + "title": "Scene 187", + "content": "This is the content of scene 187. This is the content of scene 187. This is the content of scene 187. This is the content of scene 187. This is the content of scene 187. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-188", + "title": "Scene 188", + "content": "This is the content of scene 188. This is the content of scene 188. This is the content of scene 188. This is the content of scene 188. This is the content of scene 188. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-189", + "title": "Scene 189", + "content": "This is the content of scene 189. This is the content of scene 189. This is the content of scene 189. This is the content of scene 189. This is the content of scene 189. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-190", + "title": "Scene 190", + "content": "This is the content of scene 190. This is the content of scene 190. This is the content of scene 190. This is the content of scene 190. This is the content of scene 190. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-191", + "title": "Scene 191", + "content": "This is the content of scene 191. This is the content of scene 191. This is the content of scene 191. This is the content of scene 191. This is the content of scene 191. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-192", + "title": "Scene 192", + "content": "This is the content of scene 192. This is the content of scene 192. This is the content of scene 192. This is the content of scene 192. This is the content of scene 192. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-193", + "title": "Scene 193", + "content": "This is the content of scene 193. This is the content of scene 193. This is the content of scene 193. This is the content of scene 193. This is the content of scene 193. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-194", + "title": "Scene 194", + "content": "This is the content of scene 194. This is the content of scene 194. This is the content of scene 194. This is the content of scene 194. This is the content of scene 194. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-195", + "title": "Scene 195", + "content": "This is the content of scene 195. This is the content of scene 195. This is the content of scene 195. This is the content of scene 195. This is the content of scene 195. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-196", + "title": "Scene 196", + "content": "This is the content of scene 196. This is the content of scene 196. This is the content of scene 196. This is the content of scene 196. This is the content of scene 196. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-197", + "title": "Scene 197", + "content": "This is the content of scene 197. This is the content of scene 197. This is the content of scene 197. This is the content of scene 197. This is the content of scene 197. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-198", + "title": "Scene 198", + "content": "This is the content of scene 198. This is the content of scene 198. This is the content of scene 198. This is the content of scene 198. This is the content of scene 198. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-199", + "title": "Scene 199", + "content": "This is the content of scene 199. This is the content of scene 199. This is the content of scene 199. This is the content of scene 199. This is the content of scene 199. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-200", + "title": "Scene 200", + "content": "This is the content of scene 200. This is the content of scene 200. This is the content of scene 200. This is the content of scene 200. This is the content of scene 200. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-201", + "title": "Scene 201", + "content": "This is the content of scene 201. This is the content of scene 201. This is the content of scene 201. This is the content of scene 201. This is the content of scene 201. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-202", + "title": "Scene 202", + "content": "This is the content of scene 202. This is the content of scene 202. This is the content of scene 202. This is the content of scene 202. This is the content of scene 202. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-203", + "title": "Scene 203", + "content": "This is the content of scene 203. This is the content of scene 203. This is the content of scene 203. This is the content of scene 203. This is the content of scene 203. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-204", + "title": "Scene 204", + "content": "This is the content of scene 204. This is the content of scene 204. This is the content of scene 204. This is the content of scene 204. This is the content of scene 204. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-205", + "title": "Scene 205", + "content": "This is the content of scene 205. This is the content of scene 205. This is the content of scene 205. This is the content of scene 205. This is the content of scene 205. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-206", + "title": "Scene 206", + "content": "This is the content of scene 206. This is the content of scene 206. This is the content of scene 206. This is the content of scene 206. This is the content of scene 206. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-207", + "title": "Scene 207", + "content": "This is the content of scene 207. This is the content of scene 207. This is the content of scene 207. This is the content of scene 207. This is the content of scene 207. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-208", + "title": "Scene 208", + "content": "This is the content of scene 208. This is the content of scene 208. This is the content of scene 208. This is the content of scene 208. This is the content of scene 208. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-209", + "title": "Scene 209", + "content": "This is the content of scene 209. This is the content of scene 209. This is the content of scene 209. This is the content of scene 209. This is the content of scene 209. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-210", + "title": "Scene 210", + "content": "This is the content of scene 210. This is the content of scene 210. This is the content of scene 210. This is the content of scene 210. This is the content of scene 210. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-211", + "title": "Scene 211", + "content": "This is the content of scene 211. This is the content of scene 211. This is the content of scene 211. This is the content of scene 211. This is the content of scene 211. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-212", + "title": "Scene 212", + "content": "This is the content of scene 212. This is the content of scene 212. This is the content of scene 212. This is the content of scene 212. This is the content of scene 212. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-213", + "title": "Scene 213", + "content": "This is the content of scene 213. This is the content of scene 213. This is the content of scene 213. This is the content of scene 213. This is the content of scene 213. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-214", + "title": "Scene 214", + "content": "This is the content of scene 214. This is the content of scene 214. This is the content of scene 214. This is the content of scene 214. This is the content of scene 214. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-215", + "title": "Scene 215", + "content": "This is the content of scene 215. This is the content of scene 215. This is the content of scene 215. This is the content of scene 215. This is the content of scene 215. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-216", + "title": "Scene 216", + "content": "This is the content of scene 216. This is the content of scene 216. This is the content of scene 216. This is the content of scene 216. This is the content of scene 216. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-217", + "title": "Scene 217", + "content": "This is the content of scene 217. This is the content of scene 217. This is the content of scene 217. This is the content of scene 217. This is the content of scene 217. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-218", + "title": "Scene 218", + "content": "This is the content of scene 218. This is the content of scene 218. This is the content of scene 218. This is the content of scene 218. This is the content of scene 218. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-219", + "title": "Scene 219", + "content": "This is the content of scene 219. This is the content of scene 219. This is the content of scene 219. This is the content of scene 219. This is the content of scene 219. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-220", + "title": "Scene 220", + "content": "This is the content of scene 220. This is the content of scene 220. This is the content of scene 220. This is the content of scene 220. This is the content of scene 220. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-221", + "title": "Scene 221", + "content": "This is the content of scene 221. This is the content of scene 221. This is the content of scene 221. This is the content of scene 221. This is the content of scene 221. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-222", + "title": "Scene 222", + "content": "This is the content of scene 222. This is the content of scene 222. This is the content of scene 222. This is the content of scene 222. This is the content of scene 222. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-223", + "title": "Scene 223", + "content": "This is the content of scene 223. This is the content of scene 223. This is the content of scene 223. This is the content of scene 223. This is the content of scene 223. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-224", + "title": "Scene 224", + "content": "This is the content of scene 224. This is the content of scene 224. This is the content of scene 224. This is the content of scene 224. This is the content of scene 224. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-225", + "title": "Scene 225", + "content": "This is the content of scene 225. This is the content of scene 225. This is the content of scene 225. This is the content of scene 225. This is the content of scene 225. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-226", + "title": "Scene 226", + "content": "This is the content of scene 226. This is the content of scene 226. This is the content of scene 226. This is the content of scene 226. This is the content of scene 226. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-227", + "title": "Scene 227", + "content": "This is the content of scene 227. This is the content of scene 227. This is the content of scene 227. This is the content of scene 227. This is the content of scene 227. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-228", + "title": "Scene 228", + "content": "This is the content of scene 228. This is the content of scene 228. This is the content of scene 228. This is the content of scene 228. This is the content of scene 228. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-229", + "title": "Scene 229", + "content": "This is the content of scene 229. This is the content of scene 229. This is the content of scene 229. This is the content of scene 229. This is the content of scene 229. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-230", + "title": "Scene 230", + "content": "This is the content of scene 230. This is the content of scene 230. This is the content of scene 230. This is the content of scene 230. This is the content of scene 230. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-231", + "title": "Scene 231", + "content": "This is the content of scene 231. This is the content of scene 231. This is the content of scene 231. This is the content of scene 231. This is the content of scene 231. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-232", + "title": "Scene 232", + "content": "This is the content of scene 232. This is the content of scene 232. This is the content of scene 232. This is the content of scene 232. This is the content of scene 232. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-233", + "title": "Scene 233", + "content": "This is the content of scene 233. This is the content of scene 233. This is the content of scene 233. This is the content of scene 233. This is the content of scene 233. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-234", + "title": "Scene 234", + "content": "This is the content of scene 234. This is the content of scene 234. This is the content of scene 234. This is the content of scene 234. This is the content of scene 234. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-235", + "title": "Scene 235", + "content": "This is the content of scene 235. This is the content of scene 235. This is the content of scene 235. This is the content of scene 235. This is the content of scene 235. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-236", + "title": "Scene 236", + "content": "This is the content of scene 236. This is the content of scene 236. This is the content of scene 236. This is the content of scene 236. This is the content of scene 236. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-237", + "title": "Scene 237", + "content": "This is the content of scene 237. This is the content of scene 237. This is the content of scene 237. This is the content of scene 237. This is the content of scene 237. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-238", + "title": "Scene 238", + "content": "This is the content of scene 238. This is the content of scene 238. This is the content of scene 238. This is the content of scene 238. This is the content of scene 238. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-239", + "title": "Scene 239", + "content": "This is the content of scene 239. This is the content of scene 239. This is the content of scene 239. This is the content of scene 239. This is the content of scene 239. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-240", + "title": "Scene 240", + "content": "This is the content of scene 240. This is the content of scene 240. This is the content of scene 240. This is the content of scene 240. This is the content of scene 240. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-241", + "title": "Scene 241", + "content": "This is the content of scene 241. This is the content of scene 241. This is the content of scene 241. This is the content of scene 241. This is the content of scene 241. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-242", + "title": "Scene 242", + "content": "This is the content of scene 242. This is the content of scene 242. This is the content of scene 242. This is the content of scene 242. This is the content of scene 242. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-243", + "title": "Scene 243", + "content": "This is the content of scene 243. This is the content of scene 243. This is the content of scene 243. This is the content of scene 243. This is the content of scene 243. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-244", + "title": "Scene 244", + "content": "This is the content of scene 244. This is the content of scene 244. This is the content of scene 244. This is the content of scene 244. This is the content of scene 244. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-245", + "title": "Scene 245", + "content": "This is the content of scene 245. This is the content of scene 245. This is the content of scene 245. This is the content of scene 245. This is the content of scene 245. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-246", + "title": "Scene 246", + "content": "This is the content of scene 246. This is the content of scene 246. This is the content of scene 246. This is the content of scene 246. This is the content of scene 246. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-247", + "title": "Scene 247", + "content": "This is the content of scene 247. This is the content of scene 247. This is the content of scene 247. This is the content of scene 247. This is the content of scene 247. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-248", + "title": "Scene 248", + "content": "This is the content of scene 248. This is the content of scene 248. This is the content of scene 248. This is the content of scene 248. This is the content of scene 248. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-249", + "title": "Scene 249", + "content": "This is the content of scene 249. This is the content of scene 249. This is the content of scene 249. This is the content of scene 249. This is the content of scene 249. ", + "wordCount": 40, + "status": "draft" + }, + { + "id": "sec-250", + "title": "Scene 250", + "content": "This is the content of scene 250. This is the content of scene 250. This is the content of scene 250. This is the content of scene 250. This is the content of scene 250. ", + "wordCount": 40, + "status": "draft" + } + ] +} diff --git a/tests/fixtures/project-golden-masters/missing-title.json b/tests/fixtures/project-golden-masters/missing-title.json new file mode 100644 index 00000000..79882f32 --- /dev/null +++ b/tests/fixtures/project-golden-masters/missing-title.json @@ -0,0 +1,3 @@ +{ + "logline": "This project is missing its required title field." +} diff --git a/tests/fixtures/project-golden-masters/truncated.json b/tests/fixtures/project-golden-masters/truncated.json new file mode 100644 index 00000000..bfffaf96 --- /dev/null +++ b/tests/fixtures/project-golden-masters/truncated.json @@ -0,0 +1,8 @@ +{ + "title": "Truncated Project", + "logline": "Simulates an interrupted save.", + "manuscript": [ + { + "id": "sec-1", + "title": "Chapter One", + "content": "The save was cut off mid diff --git a/tests/fixtures/project-golden-masters/typical-project.json b/tests/fixtures/project-golden-masters/typical-project.json new file mode 100644 index 00000000..3609ee3f --- /dev/null +++ b/tests/fixtures/project-golden-masters/typical-project.json @@ -0,0 +1,69 @@ +{ + "title": "The Clockwork Garden", + "logline": "A gardener discovers her greenhouse runs on stolen time.", + "author": "Test Author", + "characters": [ + { + "id": "char-elin", + "name": "Elin Vasko", + "backstory": "Third-generation gardener.", + "motivation": "Wants to understand the clockwork roots.", + "appearance": "Soil-stained hands, silver hair.", + "personalityTraits": "Curious, stubborn.", + "flaws": "Ignores warnings from those who love her.", + "notes": "", + "characterArc": "Learns to let go of control.", + "relationships": "" + }, + { + "id": "char-tomas", + "name": "Tomas Reyk", + "backstory": "The previous gardener, now missing.", + "motivation": "", + "appearance": "", + "personalityTraits": "", + "flaws": "", + "notes": "", + "characterArc": "", + "relationships": "" + } + ], + "worlds": [ + { + "id": "world-greenhouse", + "name": "The Vasko Greenhouse", + "description": "A glass structure older than the town around it.", + "geography": "Built into a hillside.", + "magicSystem": "Time flows differently near the clockwork roots.", + "culture": "", + "notes": "", + "timeline": [], + "locations": [ + { + "id": "loc-roots", + "name": "The Root Cellar", + "description": "Where the clockwork mechanism is buried.", + "coordinates": { "lat": 12.5, "lng": -3.25 }, + "type": "other", + "population": 1200 + } + ] + } + ], + "manuscript": [ + { + "id": "sec-1", + "title": "Chapter One: Inheritance", + "content": "Elin found the letter tucked beneath a loose floorboard.", + "wordCount": 9, + "status": "draft" + }, + { + "id": "sec-2", + "title": "Chapter Two: The Root Cellar", + "content": "The door had not been opened in a decade.", + "wordCount": 9, + "status": "draft" + } + ] +} diff --git a/tests/unit/projectGoldenMasters.test.ts b/tests/unit/projectGoldenMasters.test.ts new file mode 100644 index 00000000..a8896dd3 --- /dev/null +++ b/tests/unit/projectGoldenMasters.test.ts @@ -0,0 +1,45 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { parseImportedProjectJson } from '../../services/projectImportSchema'; + +// QNBS-v3: path.join, not `new URL(relative, import.meta.url)` โ€” Vite rewrites that call shape into a dev-server asset URL for .json targets, breaking fileURLToPath. +const currentDir = path.dirname(fileURLToPath(import.meta.url)); +const fixturesDir = path.join(currentDir, '../fixtures/project-golden-masters'); + +// QNBS-v3: same fixture files as crates/worldscript-project/tests/fixtures_test.rs โ€” freezes current Zod accept/reject behavior as the Rust side's oracle. +function readFixture(name: string): string { + return readFileSync(path.join(fixturesDir, name), 'utf8'); +} + +describe('project golden masters (Rust parity oracle)', () => { + it('accepts empty-project.json', () => { + const parsed = parseImportedProjectJson(readFixture('empty-project.json')); + expect(parsed.title).toBe('Empty Project'); + }); + + it('accepts typical-project.json', () => { + const parsed = parseImportedProjectJson(readFixture('typical-project.json')); + expect(parsed.characters).toHaveLength(2); + expect(parsed.manuscript).toHaveLength(2); + }); + + it('accepts large-project.json and preserves counts', () => { + const parsed = parseImportedProjectJson(readFixture('large-project.json')); + expect(parsed.manuscript).toHaveLength(250); + expect(parsed.characters).toHaveLength(30); + }); + + it('rejects missing-title.json (title is required)', () => { + expect(() => parseImportedProjectJson(readFixture('missing-title.json'))).toThrow( + /Invalid project file/, + ); + }); + + it('rejects truncated.json without throwing an unstructured error', () => { + expect(() => parseImportedProjectJson(readFixture('truncated.json'))).toThrow( + /Invalid project file/, + ); + }); +});