mirror of
https://github.com/lllin000/PaperForge.git
synced 2026-07-22 06:50:53 +00:00
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:
parent
647227c5db
commit
341941ab09
5 changed files with 638 additions and 0 deletions
87
docs/ux-contract.md
Normal file
87
docs/ux-contract.md
Normal 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','')!=''
|
||||
"
|
||||
```
|
||||
1
tests/journey/__init__.py
Normal file
1
tests/journey/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Journey tests (Level 5) — full user workflow verification in disposable temp vaults."""
|
||||
209
tests/journey/conftest.py
Normal file
209
tests/journey/conftest.py
Normal 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
|
||||
212
tests/journey/test_daily_workflow.py
Normal file
212
tests/journey/test_daily_workflow.py
Normal 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}"
|
||||
)
|
||||
129
tests/journey/test_onboarding.py
Normal file
129
tests/journey/test_onboarding.py
Normal 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]}"
|
||||
)
|
||||
Loading…
Reference in a new issue