From 341941ab09abf677265c728fce5238e7f2b9eca6 Mon Sep 17 00:00:00 2001 From: Research Assistant Date: Sat, 9 May 2026 00:52:55 +0800 Subject: [PATCH] feat(54-001): add UX contract and user journey tests - docs/ux-contract.md: verifiable step sequences for install, sync, OCR, dashboard - tests/journey/conftest.py: journey_fresh_vault, journey_established_vault, journey_cli_invoker fixtures with isolation guards - tests/journey/test_onboarding.py: JNY-02 new user onboarding (sync -> OCR -> analyze -> deep-read) - tests/journey/test_daily_workflow.py: JNY-03 existing user adds paper workflow --- docs/ux-contract.md | 87 +++++++++++ tests/journey/__init__.py | 1 + tests/journey/conftest.py | 209 ++++++++++++++++++++++++++ tests/journey/test_daily_workflow.py | 212 +++++++++++++++++++++++++++ tests/journey/test_onboarding.py | 129 ++++++++++++++++ 5 files changed, 638 insertions(+) create mode 100644 docs/ux-contract.md create mode 100644 tests/journey/__init__.py create mode 100644 tests/journey/conftest.py create mode 100644 tests/journey/test_daily_workflow.py create mode 100644 tests/journey/test_onboarding.py diff --git a/docs/ux-contract.md b/docs/ux-contract.md new file mode 100644 index 00000000..39c05ddd --- /dev/null +++ b/docs/ux-contract.md @@ -0,0 +1,87 @@ +# UX Contract — Verifiable Workflow Specifications + +> This document defines concrete, single-measurement step sequences for every PaperForge user workflow. +> Every step has a Trigger (what the user does), a Measurable Outcome (what we verify), and an Error Contract (how failure presents). +> Journey tests validate against this contract. + +--- + +## Workflow 1: First-Time Installation + +| Step ID | Trigger | Action | Measurable Outcome | Error Contract | +|---------|---------|--------|-------------------|----------------| +| W1-S1 | Run `paperforge setup --headless --vault /tmp/pf-test` | Vault selection / directory creation | `{vault}/paperforge.json` exists with valid JSON containing `system_dir`, `resources_dir`, `literature_dir` | Exit code != 0; stderr: "Failed to create vault directory" | +| W1-S2 | Setup writes config file | Config file generated | `{vault}/paperforge.json` keys: `version` (string), `system_dir` (string), `resources_dir` (string), `literature_dir` (string), `base_dir` (string), `control_dir` (string) | Exit code != 0; stderr: "Failed to write paperforge.json" | +| W1-S3 | Setup writes .env with API token | Environment file created | `{vault}\System\PaperForge\.env` exists and contains line `PADDLEOCR_API_TOKEN=` | Exit code != 0; stderr: "Failed to create .env" | +| W1-S4 | Setup creates directory structure | Directory tree generated | Directories exist: `System/PaperForge/exports/`, `System/PaperForge/ocr/`, `System/PaperForge/indexes/`, `Resources/Literature/`, `Bases/`, `.opencode/skills/` | Exit code != 0; stderr: "Failed to create directory: {path}" | +| W1-S5 | User runs `paperforge sync` | First sync from BBT JSON | `paperforge sync` exits code 0; stdout contains "Created" or "Found" to indicate new items | Exit code != 0; stderr: "Error reading exports directory" | +| W1-S6 | Verify formal note created | First paper appears | `{vault}/Resources/Literature/{domain}/{key} - {title}.md` exists with frontmatter containing `zotero_key`, `domain`, `has_pdf`, `pdf_path` | No note created; stderr: "Failed to create formal note for {key}" | + +--- + +## Workflow 2: Daily Sync + +| Step ID | Trigger | Action | Measurable Outcome | Error Contract | +|---------|---------|--------|-------------------|----------------| +| W2-S1 | User adds paper in Zotero | Zotero entry created | Better BibTeX auto-exports updated JSON to `{vault}/System/PaperForge/exports/{collection}.json` with new item entry | N/A — outside PaperForge scope | +| W2-S2 | Better BibTeX auto-exports | JSON appears in exports/ | File `{vault}/System/PaperForge/exports/{collection}.json` is valid JSON with `items` array containing the new paper's object | N/A — Zotero/BBT handles export | +| W2-S3 | User runs `paperforge sync` | Sync processes new export | Exit code 0; stdout contains the new paper's title or key; log shows "Formal note created" or "1 new items" | Exit code != 0; stderr: "Sync failed" | +| W2-S4 | Verify new formal note | Formal note exists with correct state | New `.md` file found at `{vault}/Resources/Literature/{domain}/{key} - {title}.md`; frontmatter has `has_pdf: true`, `do_ocr: false`, `ocr_status: "pending"`, `analyze: false` | File not created; stderr shows error about missing key or title | + +--- + +## Workflow 3: OCR Pipeline + +| Step ID | Trigger | Action | Measurable Outcome | Error Contract | +|---------|---------|--------|-------------------|----------------| +| W3-S1 | User sets `do_ocr: true` in formal note frontmatter | OCR trigger flag | Formal note frontmatter contains `do_ocr: true`, `ocr_status: "pending"` | N/A — manual file edit | +| W3-S2 | User runs `paperforge ocr` | OCR job submitted | Exit code 0; stdout contains "Submitting" or "Processing" or job reference; `meta.json` created at `{vault}/System/PaperForge/ocr/{key}/meta.json` with `ocr_status: "processing"` | Exit code != 0; stderr: "OCR API authentication failed" or "Network error" | +| W3-S3 | Wait for OCR job to complete | Polling OCR status | `meta.json` shows `ocr_status: "done"`; `fulltext.md` exists in OCR output directory with text content | `ocr_status: "failed"`; stderr: "OCR job {id} did not complete" | +| W3-S4 | OCR artifacts extracted | Full text and figures available | `{vault}/System/PaperForge/ocr/{key}/fulltext.md` exists with >= 1 page; `{vault}/System/PaperForge/ocr/{key}/images/` exists with at least 1 image or empty | Missing artifacts; ocr_status: "failed" with error detail | +| W3-S5 | Formal note updated with OCR status | Frontmatter reflects OCR done | Formal note frontmatter `ocr_status` updated to `"done"`; `fulltext_md_path` references OCR fulltext file | `ocr_status` remains "pending" or "processing"; no error logged | + +--- + +## Workflow 4: Dashboard View (Obsidian Plugin) + +| Step ID | Trigger | Action | Measurable Outcome | Error Contract | +|---------|---------|--------|-------------------|----------------| +| W4-S1 | User opens Obsidian vault | Obsidian loads vault | Plugin view is registered; user navigates to `Bases/` directory and sees `.base` files rendered as interactive tables with filterable columns | Plugin not loaded; stderr in dev console shows "Failed to register PaperForge view" | +| W4-S2 | Base view renders with workflow-gate columns | Lifecycle columns visible | Base view displays columns: `has_pdf`, `do_ocr`, `analyze`, `ocr_status`, `deep_reading_status`. Each paper appears as one row with status values. Filter UI present for `do_ocr`, `analyze`, `ocr_status` | Missing columns; all values appear as empty or "N/A" | +| W4-S3 | Per-paper dashboard shows lifecycle stepper | Detailed view renders | Clicking a paper row reveals: lifecycle stepper (PENDING -> PROCESSING -> DONE), health matrix (pdf, ocr, metadata status), next-step recommendation badge; all components render without console errors | Lifecycle stepper missing; health matrix shows all red; recommendation badge absent | + +--- + +## Assertion Library + +Use these shell commands in journey tests to verify each step: + +```bash +# W1-S1: Config file exists +test -f "{vault}/paperforge.json" && python -c "import json; json.load(open('{vault}/paperforge.json'))" + +# W1-S3: .env exists with token +grep -q "PADDLEOCR_API_TOKEN" "{vault}/System/PaperForge/.env" + +# W1-S4: Directory structure complete +test -d "{vault}/System/PaperForge/exports" && test -d "{vault}/Resources/Literature" + +# W1-S6: Formal note created +test -f "{vault}/Resources/Literature/"{domain}/*.md + +# W2-S4: Frontmatter fields present +python -c " +import yaml; m=open('{note_path}').read() +fm=yaml.safe_load(m.split('---')[1]) +assert fm.get('has_pdf')==True +assert fm.get('ocr_status') in ('pending','done') +" + +# W3-S5: OCR status updated in note frontmatter +python -c " +import yaml; m=open('{note_path}').read() +fm=yaml.safe_load(m.split('---')[1]) +assert fm.get('ocr_status')=='done' +assert fm.get('fulltext_md_path','')!='' +" +``` diff --git a/tests/journey/__init__.py b/tests/journey/__init__.py new file mode 100644 index 00000000..ada32ddb --- /dev/null +++ b/tests/journey/__init__.py @@ -0,0 +1 @@ +"""Journey tests (Level 5) — full user workflow verification in disposable temp vaults.""" diff --git a/tests/journey/conftest.py b/tests/journey/conftest.py new file mode 100644 index 00000000..847394af --- /dev/null +++ b/tests/journey/conftest.py @@ -0,0 +1,209 @@ +"""Journey test fixtures — temp vaults at specific onboarding stages with mock data. + +All vault fixtures MUST include an isolation assertion to prevent accidental real-vault use. +""" + +from __future__ import annotations + +import os +import shutil +import stat +import subprocess +import sys +from pathlib import Path + +import pytest + + +# --------------------------------------------------------------------------- +# Windows-safe recursive removal (reused from e2e/conftest.py) +# --------------------------------------------------------------------------- +def _force_rmtree(root: Path) -> None: + """Remove a directory tree, handling Windows file-locking issues.""" + for dirpath, dirnames, filenames in os.walk(str(root)): + for f in filenames: + try: + os.chmod(os.path.join(dirpath, f), stat.S_IWRITE) + except OSError: + pass + for d in dirnames: + try: + os.chmod(os.path.join(dirpath, d), stat.S_IWRITE) + except OSError: + pass + shutil.rmtree(str(root), ignore_errors=True) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +def _edit_frontmatter_field(note_path: Path, field: str, value: str) -> None: + """Edit a single frontmatter field in a formal note, preserving YAML structure.""" + content = note_path.read_text(encoding="utf-8") + parts = content.split("---", 2) + assert len(parts) >= 3, f"Invalid frontmatter in {note_path}" + lines = parts[1].splitlines(keepends=True) + found = False + for i, line in enumerate(lines): + if line.startswith(f"{field}:"): + lines[i] = f"{field}: {value}\n" + found = True + break + if not found: + # Append field + lines.append(f"{field}: {value}\n") + parts[1] = "".join(lines) + note_path.write_text("---".join(parts), encoding="utf-8") + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- +@pytest.fixture +def journey_fresh_vault(tmp_path: Path) -> tuple[Path, object]: + """A fresh vault at 'standard' level: config + dirs + env + exports + PDFs, NO formal notes yet. + + Returns: + (vault_path, vault_builder) tuple so tests can add more data. + """ + from fixtures.vault_builder import VaultBuilder + + builder = VaultBuilder() + vault = Path(builder.build("standard")) + + # Isolation guard — must never point at a real vault + vault_lower = str(vault).lower() + assert any(x in vault_lower for x in ("tmp", "temp")), ( + f"Isolation VIOLATION: vault path {vault} does not contain 'tmp' or 'Temp'" + ) + + yield (vault, builder) + + if vault.exists(): + _force_rmtree(vault) + + +@pytest.fixture +def journey_established_vault(tmp_path: Path) -> tuple[Path, object]: + """A fully-established vault at 'full' level: formal notes, canonical index, OCR fixtures, + plus a second domain with an additional formal note. + + Returns: + (vault_path, vault_builder) tuple so tests can inspect or extend. + """ + from fixtures.vault_builder import VaultBuilder + import json + + builder = VaultBuilder() + vault = Path(builder.build("full")) + + # Isolation guard — must never point at a real vault + vault_lower = str(vault).lower() + assert any(x in vault_lower for x in ("tmp", "temp")), ( + f"Isolation VIOLATION: vault path {vault} does not contain 'tmp' or 'Temp'" + ) + + # Add a second domain with an additional formal note + sports_dir = vault / "Resources" / "Literature" / "sports_medicine" + sports_dir.mkdir(parents=True, exist_ok=True) + + second_note_content = ( + "---\n" + 'zotero_key: "FIXT0002"\n' + 'domain: "sports_medicine"\n' + 'title: "ACL Reconstruction: A Meta-Analysis of Outcomes"\n' + 'year: "2024"\n' + 'doi: "10.1177/ajsm.2024.06.001"\n' + "has_pdf: true\n" + 'pdf_path: "[[System/Zotero/storage/FIXT0002/FIXT0002.pdf]]"\n' + "recommend_analyze: true\n" + "analyze: false\n" + "do_ocr: false\n" + 'ocr_status: "pending"\n' + 'deep_reading_status: "pending"\n' + 'path_error: ""\n' + 'analysis_note: ""\n' + "---\n\n" + "# ACL Reconstruction: A Meta-Analysis of Outcomes\n\n" + "Mock formal note for daily workflow testing.\n" + ) + (sports_dir / "FIXT0002 - ACL Reconstruction A Meta-Analysis of Outcomes.md").write_text( + second_note_content, encoding="utf-8" + ) + + # Also copy sports_medicine.json export to exports/ if not already there + from fixtures.vault_builder import FIXTURES_DIR as fixtures_root + + sports_export = fixtures_root / "zotero" / "sports_medicine.json" + if sports_export.exists(): + shutil.copy2(sports_export, vault / "System" / "PaperForge" / "exports" / "sports_medicine.json") + + # Update canonical index to reflect both domains + index_path = vault / "System" / "PaperForge" / "indexes" / "formal-library.json" + index = {"items": [], "collections": {}, "generated_at": "2026-05-09T00:00:00+00:00"} + index["items"] = [ + { + "zotero_key": "FIXT0001", + "domain": "orthopedic", + "title": "Test Article", + "note_path": "Resources/Literature/orthopedic/FIXT0001 - Test Article.md", + "has_pdf": True, + "ocr_status": "done", + "analyze": False, + }, + { + "zotero_key": "FIXT0002", + "domain": "sports_medicine", + "title": "ACL Reconstruction: A Meta-Analysis of Outcomes", + "note_path": "Resources/Literature/sports_medicine/FIXT0002 - ACL Reconstruction A Meta-Analysis of Outcomes.md", + "has_pdf": True, + "ocr_status": "pending", + "analyze": False, + }, + ] + index_path.write_text(json.dumps(index, indent=2, ensure_ascii=False), encoding="utf-8") + + yield (vault, builder) + + if vault.exists(): + _force_rmtree(vault) + + +@pytest.fixture +def journey_cli_invoker() -> callable: + """Returns a function: invoker(vault_path, args, env_override) -> subprocess.CompletedProcess. + + Runs ``python -m paperforge --vault {vault} {args}`` via subprocess. + Sets PAPERFORGE_VAULT in the subprocess environment. + """ + from fixtures.vault_builder import FIXTURES_DIR + + def _invoke( + vault: Path, + args: list[str], + input_text: str | None = None, + env: dict | None = None, + ) -> subprocess.CompletedProcess: + cmd = [sys.executable, "-m", "paperforge", "--vault", str(vault)] + list(args) + + base_env = os.environ.copy() + base_env["PAPERFORGE_VAULT"] = str(vault) + # Ensure PYTHONPATH includes the repo root for fixture imports + if "PYTHONPATH" in base_env: + base_env["PYTHONPATH"] = str(FIXTURES_DIR.parent) + os.pathsep + base_env["PYTHONPATH"] + else: + base_env["PYTHONPATH"] = str(FIXTURES_DIR.parent) + if env: + base_env.update(env) + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=120, + input=input_text, + env=base_env, + ) + return result + + return _invoke diff --git a/tests/journey/test_daily_workflow.py b/tests/journey/test_daily_workflow.py new file mode 100644 index 00000000..95411bbb --- /dev/null +++ b/tests/journey/test_daily_workflow.py @@ -0,0 +1,212 @@ +"""JNY-03: Daily workflow journey test. + +Simulates an existing user: established vault with papers -> add new paper -> sync -> verify. +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +import pytest +import yaml + + +pytestmark = pytest.mark.journey + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +def _count_formal_notes(vault: Path) -> int: + """Count formal note markdown files in Resources/Literature/.""" + lit_dir = vault / "Resources" / "Literature" + if not lit_dir.exists(): + return 0 + return len(list(lit_dir.rglob("*.md"))) + + +def _read_frontmatter(note_path: Path) -> dict: + """Parse YAML frontmatter from a formal note.""" + content = note_path.read_text(encoding="utf-8") + parts = content.split("---", 2) + if len(parts) < 3: + return {} + return yaml.safe_load(parts[1]) or {} + + +def _compute_file_hash(note_path: Path) -> int: + """Compute a simple content hash for a note file (frontmatter only).""" + content = note_path.read_text(encoding="utf-8") + parts = content.split("---", 2) + if len(parts) >= 3: + # Hash the frontmatter portion + return hash(parts[1]) + return hash(content) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- +def test_existing_user_adds_paper( + journey_established_vault: tuple[Path, object], + journey_cli_invoker: callable, +) -> None: + """Existing user journey: count existing notes -> add new export -> sync -> verify count increased.""" + vault, builder = journey_established_vault + + # Isolation guard + assert any(x in str(vault).lower() for x in ("tmp", "temp")) + + # ----------------------------------------------------------------------- + # Step 0: Sync first to normalize state (sync all existing exports) + # ----------------------------------------------------------------------- + result = journey_cli_invoker(vault, ["sync"]) + assert result.returncode == 0, ( + f"Initial sync failed (exit {result.returncode}):\n stdout: {result.stdout[:300]}\n stderr: {result.stderr[:300]}" + ) + + lit_dir = vault / "Resources" / "Literature" + + # ----------------------------------------------------------------------- + # Step 1: Count existing formal notes after initial sync and record hashes + # ----------------------------------------------------------------------- + initial_count = _count_formal_notes(vault) + assert initial_count >= 1, f"Expected at least 1 existing formal note, found {initial_count}" + + existing_notes = list(lit_dir.rglob("*.md")) + existing_hashes = {str(n.relative_to(vault)): _compute_file_hash(n) for n in existing_notes} + + # ----------------------------------------------------------------------- + # Step 2: Add a new BBT export with a unique key that won't conflict + # ----------------------------------------------------------------------- + exports_dir = vault / "System" / "PaperForge" / "exports" + exports_dir.mkdir(parents=True, exist_ok=True) + + new_key = "JOURNEY003" + new_export = { + "items": [ + { + "key": new_key, + "itemKey": new_key, + "itemType": "journalArticle", + "title": "Advanced Techniques in Arthroscopic Surgery", + "creators": [ + {"creatorType": "author", "firstName": "Carol", "lastName": "Davis"}, + {"creatorType": "author", "firstName": "Dan", "lastName": "Martinez"}, + ], + "publicationTitle": "Arthroscopy: The Journal of Arthroscopic Surgery", + "date": "2024-09-01", + "DOI": "10.1016/j.arthro.2024.09.001", + "attachments": [ + {"path": f"storage:{new_key}/{new_key}.pdf", "contentType": "application/pdf"} + ], + "collections": ["orthopedic"], + } + ], + "collections": { + "orthopedic": { + "name": "orthopedic", + "parent": "", + "items": [new_key], + } + }, + } + new_export_path = exports_dir / "new_paper.json" + new_export_path.write_text(json.dumps(new_export, indent=2), encoding="utf-8") + + # Create the Zotero storage directories + mock PDF for the new key + for subdir in ("storage", ""): + storage_dir = vault / "System" / "Zotero" / subdir / new_key + storage_dir.mkdir(parents=True, exist_ok=True) + (storage_dir / f"{new_key}.pdf").write_text("mock pdf content for new paper", encoding="utf-8") + + # ----------------------------------------------------------------------- + # Step 3: Run paperforge sync again + # ----------------------------------------------------------------------- + result = journey_cli_invoker(vault, ["sync"]) + assert result.returncode == 0, ( + f"Second sync failed (exit {result.returncode}):\n stdout: {result.stdout[:500]}\n stderr: {result.stderr[:500]}" + ) + + # ----------------------------------------------------------------------- + # Step 4: Verify count of formal notes increased by 1 + # ----------------------------------------------------------------------- + new_count = _count_formal_notes(vault) + assert new_count == initial_count + 1, ( + f"Expected {initial_count + 1} formal notes after second sync, found {new_count}" + ) + + # ----------------------------------------------------------------------- + # Step 5: Verify new note has correct frontmatter and existing notes unchanged + # ----------------------------------------------------------------------- + current_notes = list(lit_dir.rglob("*.md")) + + # Find the newly created note + new_note = None + for n in current_notes: + rel = str(n.relative_to(vault)) + if rel not in existing_hashes: + new_note = n + break + + assert new_note is not None, "Could not find newly created formal note" + + # Verify new note frontmatter + fm = _read_frontmatter(new_note) + assert fm.get("zotero_key") == new_key, f"Expected zotero_key {new_key}, got {fm.get('zotero_key')}" + assert "domain" in fm and fm.get("domain"), f"Missing or empty domain in new note" + assert "has_pdf" in fm, f"Missing has_pdf in new note" + + # Verify existing notes are unchanged (same frontmatter hash) + for n in current_notes: + rel = str(n.relative_to(vault)) + if rel in existing_hashes: + current_hash = _compute_file_hash(n) + assert current_hash == existing_hashes[rel], ( + f"Existing note {rel} was modified by sync!\n" + f" old hash: {existing_hashes[rel]}\n" + f" new hash: {current_hash}" + ) + + # ----------------------------------------------------------------------- + # Step 4: Verify count of formal notes increased by 1 + # ----------------------------------------------------------------------- + new_count = _count_formal_notes(vault) + assert new_count == initial_count + 1, ( + f"Expected {initial_count + 1} formal notes after sync, found {new_count}" + ) + + # ----------------------------------------------------------------------- + # Step 5: Verify new note has correct frontmatter and existing notes unchanged + # ----------------------------------------------------------------------- + new_existing_notes = list(lit_dir.rglob("*.md")) + + # Find the newly created note + new_note = None + for n in new_existing_notes: + rel = str(n.relative_to(vault)) + if rel not in existing_hashes: + new_note = n + break + + assert new_note is not None, "Could not find newly created formal note" + + # Verify new note frontmatter + fm = _read_frontmatter(new_note) + assert fm.get("zotero_key") == new_key, f"Expected zotero_key {new_key}, got {fm.get('zotero_key')}" + assert "domain" in fm and fm.get("domain"), f"Missing or empty domain in new note" + assert "has_pdf" in fm, f"Missing has_pdf in new note" + assert fm.get("ocr_status") in ("pending",), f"Expected pending ocr_status, got {fm.get('ocr_status')}" + + # Verify existing notes are unchanged (same frontmatter hash) + for n in new_existing_notes: + rel = str(n.relative_to(vault)) + if rel in existing_hashes: + current_hash = _compute_file_hash(n) + assert current_hash == existing_hashes[rel], ( + f"Existing note {rel} was modified by sync!\n" + f" old hash: {existing_hashes[rel]}\n" + f" new hash: {current_hash}" + ) diff --git a/tests/journey/test_onboarding.py b/tests/journey/test_onboarding.py new file mode 100644 index 00000000..6369c4a0 --- /dev/null +++ b/tests/journey/test_onboarding.py @@ -0,0 +1,129 @@ +"""JNY-02: New user onboarding journey test. + +Simulates a brand-new user: fresh vault -> sync -> OCR -> analyze -> deep-read ready. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +import yaml + +from fixtures.ocr.mock_ocr_backend import mock_ocr_success + + +pytestmark = pytest.mark.journey + + +# --------------------------------------------------------------------------- +# Helper +# --------------------------------------------------------------------------- +def _read_frontmatter(note_path: Path) -> dict[str, Any]: + """Parse YAML frontmatter from a formal note.""" + content = note_path.read_text(encoding="utf-8") + parts = content.split("---", 2) + if len(parts) < 3: + return {} + return yaml.safe_load(parts[1]) or {} + + +def _edit_frontmatter_field(note_path: Path, field: str, value: str) -> None: + """Edit a single frontmatter field in a formal note, preserving YAML structure.""" + content = note_path.read_text(encoding="utf-8") + parts = content.split("---", 2) + assert len(parts) >= 3, f"Invalid frontmatter in {note_path}" + lines = parts[1].splitlines(keepends=True) + found = False + for i, line in enumerate(lines): + if line.startswith(f"{field}:"): + lines[i] = f"{field}: {value}\n" + found = True + break + if not found: + lines.append(f"{field}: {value}\n") + parts[1] = "".join(lines) + note_path.write_text("---".join(parts), encoding="utf-8") + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- +def test_new_user_onboarding( + journey_fresh_vault: tuple[Path, object], + journey_cli_invoker: callable, +) -> None: + """Complete new user journey: sync -> verify notes -> enable OCR -> OCR -> enable analyze -> verify queue.""" + vault, builder = journey_fresh_vault + + # Isolation guard + assert any(x in str(vault).lower() for x in ("tmp", "temp")), f"Isolation VIOLATION: {vault}" + + # ----------------------------------------------------------------------- + # Step 1: Run paperforge sync + # ----------------------------------------------------------------------- + result = journey_cli_invoker(vault, ["sync"]) + assert result.returncode == 0, ( + f"Sync failed (exit {result.returncode}):\n stdout: {result.stdout[:500]}\n stderr: {result.stderr[:500]}" + ) + + # ----------------------------------------------------------------------- + # Step 2: Verify formal notes created with correct frontmatter + # ----------------------------------------------------------------------- + lit_dir = vault / "Resources" / "Literature" + note_files = list(lit_dir.rglob("*.md")) + assert len(note_files) >= 1, f"No formal notes created in {lit_dir}" + + for note_path in note_files: + fm = _read_frontmatter(note_path) + assert "zotero_key" in fm, f"Missing zotero_key in {note_path}" + assert fm.get("zotero_key"), f"Empty zotero_key in {note_path}" + assert "domain" in fm, f"Missing domain in {note_path}" + assert fm.get("domain"), f"Empty domain in {note_path}" + assert "has_pdf" in fm, f"Missing has_pdf in {note_path}" + assert "ocr_status" in fm, f"Missing ocr_status in {note_path}" + assert fm.get("ocr_status") in ("pending", "done"), ( + f"Unexpected ocr_status in {note_path}: {fm.get('ocr_status')}" + ) + + # Pick the first note for further steps + note_path = note_files[0] + fm = _read_frontmatter(note_path) + paper_key = fm["zotero_key"] + ocr_dir = vault / "System" / "PaperForge" / "ocr" / paper_key + + # ----------------------------------------------------------------------- + # Step 3: Enable OCR — set do_ocr: true + # ----------------------------------------------------------------------- + _edit_frontmatter_field(note_path, "do_ocr", "true") + fm = _read_frontmatter(note_path) + assert fm.get("do_ocr") is True or str(fm.get("do_ocr")) == "true", "do_ocr not set to true" + + # ----------------------------------------------------------------------- + # Step 4: Run OCR with mock backend — verify graceful handling + # ----------------------------------------------------------------------- + with mock_ocr_success(): + result = journey_cli_invoker(vault, ["ocr"]) + + # OCR may exit 0 on success or non-zero on some conditions; key is no unhandled crash + assert "Traceback" not in result.stderr, f"Unhandled crash in OCR:\n{result.stderr}" + + # ----------------------------------------------------------------------- + # Step 5: Enable analyze + # ----------------------------------------------------------------------- + _edit_frontmatter_field(note_path, "analyze", "true") + fm = _read_frontmatter(note_path) + assert fm.get("analyze") is True or str(fm.get("analyze")) == "true", "analyze not set to true" + + # ----------------------------------------------------------------------- + # Step 6: Verify deep-reading queue sees the paper + # ----------------------------------------------------------------------- + result = journey_cli_invoker(vault, ["deep-reading"]) + # The paper key or title should appear in the output + assert result.returncode == 0, f"deep-reading failed:\n{result.stderr[:500]}" + combined_output = (result.stdout + result.stderr) + assert paper_key in combined_output or fm.get("title", "") in combined_output, ( + f"Paper '{paper_key}' not found in deep-reading queue output:\n{combined_output[:500]}" + )