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
This commit is contained in:
Research Assistant 2026-05-09 00:52:55 +08:00
parent 647227c5db
commit 341941ab09
5 changed files with 638 additions and 0 deletions

87
docs/ux-contract.md Normal file
View file

@ -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=<non-empty>` | 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','')!=''
"
```

View file

@ -0,0 +1 @@
"""Journey tests (Level 5) — full user workflow verification in disposable temp vaults."""

209
tests/journey/conftest.py Normal file
View file

@ -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

View file

@ -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}"
)

View file

@ -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]}"
)