---
phase: 52-golden-datasets-cli-contracts
plan: 001
type: execute
wave: 1
depends_on: [51]
files_modified:
  - fixtures/__init__.py
  - fixtures/MANIFEST.json
  - fixtures/zotero/orthopedic.json
  - fixtures/zotero/sports_medicine.json
  - fixtures/zotero/multi_attachment.json
  - fixtures/zotero/no_pdf.json
  - fixtures/zotero/absolute_paths.json
  - fixtures/zotero/storage_prefix.json
  - fixtures/zotero/bare_relative.json
  - fixtures/zotero/empty.json
  - fixtures/zotero/malformed.json
  - fixtures/zotero/missing_keys.json
  - fixtures/zotero/cjk_content.json
  - fixtures/pdf/generate_fixtures.py
  - fixtures/ocr/paddleocr_submit.json
  - fixtures/ocr/paddleocr_poll_pending.json
  - fixtures/ocr/paddleocr_poll_done.json
  - fixtures/ocr/paddleocr_result.json
  - fixtures/ocr/paddleocr_error.json
  - fixtures/ocr/paddleocr_timeout.json
  - fixtures/ocr/extracted_fulltext.md
  - fixtures/ocr/figure_map.json
  - fixtures/snapshots/paths_json/default_config.json
  - fixtures/snapshots/status_json/empty_vault.json
  - fixtures/snapshots/formal_note_frontmatter/orthopedic_article.yaml
  - fixtures/snapshots/index_json/after_sync.json
  - fixtures/vault_builder.py
autonomous: true
requirements: [FIX-01, FIX-02, FIX-03, FIX-04, FIX-05]
user_setup: []

must_haves:
  truths:
    - "All 8+ Zotero JSON variants exist in fixtures/zotero/ with valid structure"
    - "4 minimal valid PDFs exist in fixtures/pdf/ including a CJK-named file"
    - "5 mock OCR response fixtures exist in fixtures/ocr/ covering all PaddleOCR states"
    - "Expected output snapshots exist in fixtures/snapshots/ for paths, status, frontmatter, and index"
    - "MANIFEST.json tracks every fixture with used_by, generated, desc fields"
    - "Vault builder factory creates configurable test vaults from fixture data"
  artifacts:
    - path: "fixtures/MANIFEST.json"
      provides: "Fixture registry tracking metadata"
      contains: "used_by"
    - path: "fixtures/zotero/orthopedic.json"
      provides: "Standard valid BBT JSON export (single paper, storage: path)"
      contains: "storage:"
    - path: "fixtures/zotero/empty.json"
      provides: "Empty export edge case"
      contains: "[]"
    - path: "fixtures/zotero/malformed.json"
      provides: "Corrupted JSON for error path testing"
      min_lines: 1
    - path: "fixtures/zotero/absolute_paths.json"
      provides: "Windows absolute D:\\Zotero\\storage path format"
      contains: "D:\\\\Zotero"
    - path: "fixtures/zotero/storage_prefix.json"
      provides: "storage: prefix path format"
      contains: "storage:"
    - path: "fixtures/zotero/bare_relative.json"
      provides: "Bare KEY/file.pdf path format"
      min_lines: 10
    - path: "fixtures/zotero/cjk_content.json"
      provides: "CJK titles, author names, and attachment filenames"
      contains: "中文"
    - path: "fixtures/ocr/paddleocr_submit.json"
      provides: "OCR job submission response (202, job queued)"
      contains: "job_id"
    - path: "fixtures/ocr/paddleocr_poll_done.json"
      provides: "OCR poll response when job completes"
      contains: "completed"
    - path: "fixtures/ocr/paddleocr_error.json"
      provides: "OCR API error response"
      contains: "error"
    - path: "fixtures/pdf/generate_fixtures.py"
      provides: "Programmatic PDF generator using pymupdf"
      contains: "fitz"
    - path: "fixtures/vault_builder.py"
      provides: "Shared vault factory for all test layers"
      exports: ["VaultBuilder"]
  key_links:
    - from: "fixtures/MANIFEST.json"
      to: "fixtures/zotero/*.json"
      via: "paths listed in manifest entries"
      pattern: "fixtures/zotero"
    - from: "fixtures/vault_builder.py"
      to: "fixtures/zotero/*.json"
      via: "VaultBuilder._copy_exports() copies zotero fixtures into vault exports/"
      pattern: "FIXTURES_DIR"
    - from: "fixtures/vault_builder.py"
      to: "fixtures/pdf/"
      via: "VaultBuilder._copy_pdfs() copies PDFs into mock Zotero storage"
      pattern: "FIXTURES_DIR"

---

<objective>
**Objective:** Create the complete golden dataset (`fixtures/` directory) — Zotero JSON variants, minimal valid PDFs, mock OCR response fixtures, expected output snapshots, and a shared vault builder factory — all tracked in `MANIFEST.json`.

**Purpose:** The golden dataset is the shared input foundation for ALL test layers (L1-L6). Every deterministic test depends on these fixtures being present, correct, and documented. Generating PDFs from code prevents git bloat.

**Output:** Full `fixtures/` directory with Zotero JSONs (10 variants), PDFs (4 generated), OCR fixtures (5 API responses + expected outputs), expected snapshots (4 subdirectories), MANIFEST.json, and vault_builder.py.
</objective>

<execution_context>
@C:/Users/Lin/.opencode/get-shit-done/workflows/execute-plan.md
@C:/Users/Lin/.opencode/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/REQUIREMENTS.md
@.planning/ROADMAP.md
@.planning/research/ARCHITECTURE.md
@.planning/research/SUMMARY.md

<interfaces>

=== Existing BBT JSON format (from tests/sandbox/exports/) ===
JSON structure: `{ "items": [...], "collections": {...} }`
Each item has: key, itemKey, itemType, title, creators, publicationTitle, date, DOI, PMID, abstractNote, attachments, collections
Each attachment has: path, contentType (application/pdf)
Path formats: "storage:KEY/file.pdf", "D:\\Zotero\\storage\\KEY\\file.pdf", "KEY/file.pdf"

=== Existing test vault pattern (from tests/conftest.py:create_test_vault()) ===
- Creates vault with: paperforge.json, 99_System/PaperForge/{exports/, ocr/}, 03_Resources/Literature/, 05_Bases/
- paperforge.json has: version, system_dir, resources_dir, literature_dir, control_dir, base_dir, skill_dir
- .env has: PADDLEOCR_API_TOKEN, PADDLEOCR_JOB_URL

=== Existing OCR meta.json format (from tests/sandbox/ocr-complete/TSTONE001/meta.json) ===
{ "zotero_key": "...", "ocr_status": "done", "page_count": 3, "generated_at": "..." }

=== Existing OCR result.json format (from tests/sandbox/ocr-complete/TSTONE001/json/result.json) ===
{ "pages": [{"page_num": 1, "markdown": "..."}], "figures": [...], "tables": [...] }

</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Zotero JSON fixture variants + MANIFEST.json</name>
  <files>
    fixtures/__init__.py
    fixtures/zotero/orthopedic.json
    fixtures/zotero/sports_medicine.json
    fixtures/zotero/multi_attachment.json
    fixtures/zotero/no_pdf.json
    fixtures/zotero/absolute_paths.json
    fixtures/zotero/storage_prefix.json
    fixtures/zotero/bare_relative.json
    fixtures/zotero/empty.json
    fixtures/zotero/malformed.json
    fixtures/zotero/missing_keys.json
    fixtures/zotero/cjk_content.json
    fixtures/MANIFEST.json
  </files>

  <action>
    Follow the established BBT JSON format from tests/sandbox/exports/骨科.json. Each fixture MUST be a valid `{ "items": [...], "collections": {...} }` structure (except malformed.json which is intentionally broken, and empty.json which is `[]`).

    === 1a. Create fixtures/__init__.py ===
    Empty marker file (one blank line).

    === 1b. Create fixtures/zotero/ directory structure ===
    Create the following 10 JSON fixture files. Use the shared format constants below:

    **Shared structure for valid fixtures:**
    - Top-level: `{ "items": [<entry>, ...], "collections": { "<domain>": { "name": "...", "parent": "", "items": ["<KEY>"] } } }`
    - Attachment path format varies per fixture (targeting a specific path type)
    - Each paper has a unique 8-char uppercase key (e.g., "FIXT0010", "FIXT0020")

    **File-by-file specifications:**

    1. `orthopedic.json` — Standard single-paper export with storage: path format
       - Items: 1 paper (key: "FIXT0001"), domain "orthopedic"
       - Attachment: `"storage:FIXT0001/FIXT0001.pdf"`
       - Mirror the structure from tests/sandbox/exports/骨科.json exactly

    2. `sports_medicine.json` — Second domain with CJK collection name, storage: path
       - Items: 1 paper (key: "FIXT0002"), domain "sports_medicine", collection "运动医学"
       - Attachment: `"storage:FIXT0002/FIXT0002.pdf"`
       - title: "ACL Reconstruction: A Meta-Analysis of Outcomes"

    3. `multi_attachment.json` — 1 main PDF + 2 supplementary attachments (key: "FIXT0003")
       - 3 attachments total. Main: `"storage:FIXT0003/main.pdf"`, title="PDF"
       - Supplementary: `"storage:FIXT0003/supp1.pdf"`, `"storage:FIXT0003/supp2.pdf"`
       - Domain: "orthopedic"
       - BBT sorts attachments; our hybrid strategy picks the one with title="PDF" or largest

    4. `no_pdf.json` — Entry with ZERO attachments (key: "FIXT0004")
       - attachments: [] or omitted entirely
       - Domain: "orthopedic"
       - Verifies graceful handling of PDF-less papers

    5. `absolute_paths.json` — Windows absolute path format (key: "FIXT0005")
       - Attachment: `"D:\\Zotero\\storage\\FIXT0005\\FIXT0005.pdf"`
       - Domain: "orthopedic"
       - Tests the `bbt_path_raw` → wikilink conversion path

    6. `storage_prefix.json` — storage: prefix format (key: "FIXT0006")
       - Single attachment: `"storage:FIXT0006/FIXT0006.pdf"`
       - Domain: "orthopedic"
       - The most common format in modern BBT exports

    7. `bare_relative.json` — Bare KEY/file.pdf format (key: "FIXT0007")
       - Single attachment: `"FIXT0007/FIXT0007.pdf"`
       - Domain: "orthopedic"
       - Tests the fallback resolver that prepends storage:

    8. `empty.json` — Empty export edge case
       - Content: `[]` (bare empty array)
       - Tests that sync handles completely empty exports gracefully

    9. `malformed.json` — Broken JSON for error-path testing
       - Content: `{ invalid json here {{{` (a file that raises JSONDecodeError)
       - Tests error handling in export readers

    10. `missing_keys.json` — Entry missing critical fields (key: "FIXT0008")
        - Has no `title` field, no `DOI`, no `date`
        - Has `citationKey: "FIXT0008"` but NOT `key` (tests both field fallbacks)
        - Domain: "orthopedic"
        - Tests graceful handling of incomplete metadata

    11. `cjk_content.json` — Full CJK content (key: "FIXT0009")
        - Domain: "骨科"
        - title: Chinese title "肩袖修复中锚钉固定的生物力学比较"
        - creators: Chinese names "王伟", "李明"
        - publicationTitle: "中华骨科杂志"
        - Attachment: `"storage:FIXT0009/锚钉固定.pdf"` (CJK filename)
        - Tests CJK encoding through the entire sync pipeline

    All JSON files must use `ensure_ascii=False` style — CJK characters as-is, NOT escaped. Write with `json.dumps(..., ensure_ascii=False, indent=2)`.

    === 1c. Create MANIFEST.json ===
    At `fixtures/MANIFEST.json`. Structure:
    ```json
    {
      "format_version": "1",
      "generated_at": "2026-05-08T00:00:00+00:00",
      "fixtures": [
        {
          "path": "zotero/orthopedic.json",
          "desc": "Standard valid BBT JSON: single paper, domain=orthopedic, storage: path format",
          "used_by": ["tests/unit/test_bbt_parser.py", "tests/cli/", "tests/e2e/"],
          "generated": false
        },
        ... (one entry per fixture)
      ]
    }
    ```

    Include entries for ALL files in the fixtures/ tree: all 10 zotero/ files, all 4 pdf/ files, all 5+ ocr/ files, all 4 snapshot files, vault_builder.py, and generate_fixtures.py. Each entry MUST have: path, desc, used_by (list of test files or layers), generated (true/false).

    Use realistic `used_by` values based on the research architecture:
    - zotero/ fixtures → ["tests/unit/test_bbt_parser.py", "tests/cli/", "tests/e2e/"]
    - pdf/ fixtures → ["tests/unit/test_pdf_resolver.py", "tests/e2e/"]
    - ocr/ fixtures → ["tests/unit/test_ocr_state_machine.py", "tests/cli/"]
    - snapshots/ → ["tests/cli/"]
    - vault_builder.py → ["tests/cli/conftest.py", "tests/e2e/conftest.py"]
  </action>

  <verify>
    <automated>
      python -c "
import json, os

errs = []

# Check MANIFEST exists
manifest_path = 'fixtures/MANIFEST.json'
if not os.path.exists(manifest_path):
    errs.append('MANIFEST.json missing')
else:
    m = json.load(open(manifest_path, encoding='utf-8'))
    if 'fixtures' not in m:
        errs.append('MANIFEST.json missing fixtures array')
    else:
        paths = {f['path'] for f in m['fixtures']}
        required = [
            'zotero/orthopedic.json', 'zotero/empty.json', 'zotero/malformed.json',
            'zotero/absolute_paths.json', 'zotero/storage_prefix.json', 'zotero/bare_relative.json',
            'zotero/cjk_content.json', 'zotero/multi_attachment.json', 'zotero/no_pdf.json',
            'zotero/missing_keys.json',
            'pdf/generate_fixtures.py',
            'ocr/paddleocr_submit.json', 'ocr/paddleocr_poll_done.json', 'ocr/paddleocr_error.json',
        ]
        for r in required:
            if r not in paths:
                errs.append(f'MANIFEST missing fixture: {r}')
        for f in m['fixtures']:
            for field in ['path', 'desc', 'used_by', 'generated']:
                if field not in f:
                    errs.append(f'Fixture {f.get(\"path\",\"?\")} missing field: {field}')

# Check each zotero fixture is valid JSON (except malformed)
zotero_dir = 'fixtures/zotero'
for fname in os.listdir(zotero_dir):
    fpath = os.path.join(zotero_dir, fname)
    if not fname.endswith('.json'):
        continue
    with open(fpath, encoding='utf-8') as f:
        content = f.read()
    if fname == 'malformed.json':
        # Must NOT parse as valid JSON
        try:
            json.loads(content)
            errs.append('malformed.json parsed as valid JSON (should fail)')
        except json.JSONDecodeError:
            pass
        continue
    if fname == 'empty.json':
        data = json.loads(content)
        if data != []:
            errs.append('empty.json should be []')
        continue
    # Valid fixtures must have items + collections
    data = json.loads(content)
    if 'items' not in data:
        errs.append(f'{fname} missing items key')
    if 'collections' not in data:
        errs.append(f'{fname} missing collections key')

# Count unique fixtures
zotero_count = sum(1 for f in os.listdir(zotero_dir) if f.endswith('.json'))
if zotero_count < 8:
    errs.append(f'Only {zotero_count} zotero fixtures (need 8+)')

if errs:
    for e in errs:
        print(f'FAIL: {e}')
    exit(1)
else:
    print('All Zotero fixture checks PASS')
    print(f'  {zotero_count} JSON files in fixtures/zotero/')
      "
    </automated>
  </verify>

  <done>
    - 10 Zotero JSON fixture files created in fixtures/zotero/ (orthopedic, sports_medicine, multi_attachment, no_pdf, absolute_paths, storage_prefix, bare_relative, empty, malformed, missing_keys, cjk_content)
    - Each valid fixture has correct structure: { items: [...], collections: {...} }
    - malformed.json intentionally breaks JSON parse
    - empty.json is bare []
    - All CJK fixtures use non-escaped Unicode
    - MANIFEST.json tracks all 20+ fixtures with used_by, generated, desc
    - All 8+ variants from FIX-01 are covered
  </done>
</task>

<task type="auto">
  <name>Task 2: Minimal valid PDFs + Mock OCR response fixtures</name>
  <files>
    fixtures/pdf/generate_fixtures.py
    fixtures/ocr/paddleocr_submit.json
    fixtures/ocr/paddleocr_poll_pending.json
    fixtures/ocr/paddleocr_poll_done.json
    fixtures/ocr/paddleocr_result.json
    fixtures/ocr/paddleocr_error.json
    fixtures/ocr/paddleocr_timeout.json
    fixtures/ocr/extracted_fulltext.md
    fixtures/ocr/figure_map.json
  </files>

  <action>
    === 2a. Create PDF generator at fixtures/pdf/generate_fixtures.py ===
    This script generates minimal valid PDFs using pymupdf (fitz). Run the script after writing it.

    ```python
    """Generate minimal valid PDF fixtures for test golden datasets.

    Run: python fixtures/pdf/generate_fixtures.py
    Output: blank.pdf, two_page.pdf, with_figures.pdf, CJK_文件名.pdf
    """
    import sys
    from pathlib import Path

    try:
        import fitz  # pymupdf
    except ImportError:
        print("ERROR: pymupdf not installed. Run: pip install pymupdf")
        sys.exit(1)

    OUT_DIR = Path(__file__).parent
    WIDTH, HEIGHT = 612, 792  # US Letter

    def blank_pdf(path: Path):
        doc = fitz.open()
        page = doc.new_page(width=WIDTH, height=HEIGHT)
        page.insert_text((72, 72), "Blank PDF for PaperForge testing", fontsize=12)
        doc.save(str(path))
        doc.close()
        print(f"  Created: {path.name} ({path.stat().st_size} bytes)")

    def two_page_pdf(path: Path):
        doc = fitz.open()
        page1 = doc.new_page(width=WIDTH, height=HEIGHT)
        page1.insert_text((72, 72), "Page 1 of 2", fontsize=12)
        page2 = doc.new_page(width=WIDTH, height=HEIGHT)
        page2.insert_text((72, 72), "Page 2 of 2", fontsize=12)
        doc.save(str(path))
        doc.close()
        print(f"  Created: {path.name} ({path.stat().st_size} bytes)")

    def with_figures_pdf(path: Path):
        """PDF with placeholder rectangles simulating embedded figures."""
        doc = fitz.open()
        page = doc.new_page(width=WIDTH, height=HEIGHT)
        page.insert_text((72, 72), "Paper with embedded figures", fontsize=12)
        page.insert_text((72, 100), "Figure 1: Testing setup", fontsize=10)
        page.draw_rect((72, 120, 300, 300), color=(0,0,0), width=1)
        page.insert_text((72, 320), "Figure 2: Results chart", fontsize=10)
        page.draw_rect((72, 340, 300, 500), color=(0,0,0), width=1)
        doc.save(str(path))
        doc.close()
        print(f"  Created: {path.name} ({path.stat().st_size} bytes)")

    def cjk_filename_pdf(path: Path):
        """PDF with CJK filename for CJK path resolution tests."""
        doc = fitz.open()
        page = doc.new_page(width=WIDTH, height=HEIGHT)
        page.insert_text((72, 72), "中文论文测试文件", fontsize=12)
        page.insert_text((72, 100), "This PDF tests CJK filename handling in path resolution.", fontsize=10)
        doc.save(str(path))
        doc.close()
        print(f"  Created: {path.name} ({path.stat().st_size} bytes)")

    if __name__ == "__main__":
        print("Generating PDF fixtures...")
        blank_pdf(OUT_DIR / "blank.pdf")
        two_page_pdf(OUT_DIR / "two_page.pdf")
        with_figures_pdf(OUT_DIR / "with_figures.pdf")
        cjk_filename_pdf(OUT_DIR / "CJK_文件名.pdf")
        print("Done. 4 PDF files created.")
    ```

    After writing, run the generator:
    ```bash
    python fixtures/pdf/generate_fixtures.py
    ```
    Verify 4 PDFs exist under 1MB each.

    === 2b. Create Mock OCR response fixtures at fixtures/ocr/ ===

    These simulate the PaddleOCR API at different lifecycle states. Based on the architecture doc's mock OCR strategy, responses library intercepts at HTTP layer.

    1. `paddleocr_submit.json` — Simulated POST /api/v2/ocr/jobs response (job submission, 202):
       ```json
       {
         "job_id": "mock-job-001",
         "status": "queued",
         "estimated_time_seconds": 15
       }
       ```

    2. `paddleocr_poll_pending.json` — Simulated GET /api/v2/ocr/jobs/{id} response (still processing):
       ```json
       {
         "job_id": "mock-job-001",
         "status": "processing",
         "progress": 0.45
       }
       ```

    3. `paddleocr_poll_done.json` — Simulated GET /api/v2/ocr/jobs/{id} response (completed):
       ```json
       {
         "job_id": "mock-job-001",
         "status": "completed",
         "result_url": "https://api.mock/results/mock-job-001",
         "progress": 1.0
       }
       ```

    4. `paddleocr_result.json` — Simulated GET result_url response (the OCR extraction data):
       This should mirror the actual OCR output format from tests/sandbox/ocr-complete/TSTONE001/json/result.json:
       ```json
       {
         "pages": [
           {
             "page_num": 1,
             "markdown": "# Test Article\n\nThis is the OCR-extracted text for testing purposes.\n\n## Introduction\n\nMock OCR content for deterministic testing.\n\n<!-- page 1 -->"
           },
           {
             "page_num": 2,
             "markdown": "## Methods\n\nWe used a mock testing methodology.\n\n## Results\n\nThe mock OCR produced deterministic output.\n\nTable 1: Mock results summary.\n<!-- page 2 -->"
           }
         ],
         "figures": [
           {"page": 1, "filename": "page_001_fig_01.png", "caption": "Mock figure 1: Testing flowchart."}
         ],
         "tables": [
           {"page": 2, "filename": "page_002_table_01.png", "caption": "Mock table 1: Results summary."}
         ]
       }
       ```

    5. `paddleocr_error.json` — Simulated API error response (400/401/500):
       ```json
       {
         "error": {
           "code": "authentication_failed",
           "message": "Invalid API token"
         }
       }
       ```

    6. `paddleocr_timeout.json` — Simulated status where job never completes (stays queued):
       ```json
       {
         "job_id": "mock-job-timeout-001",
         "status": "queued",
         "progress": 0.0
       }
       ```
       Used by chaos/timeout tests — poll returns queued forever.

    === 2c. Create extracted_fulltext.md at fixtures/ocr/ ===
    Expected OCR fulltext output (what ocr worker writes to <system_dir>/PaperForge/ocr/<key>/fulltext.md):

    ```markdown
    <!-- page 1 -->
    # Test Article
    
    This is the OCR-extracted text for testing purposes.
    
    ## Introduction
    
    Mock OCR content for deterministic testing.
    
    <!-- page 2 -->
    ## Methods
    
    We used a mock testing methodology.
    
    ## Results
    
    The mock OCR produced deterministic output.
    
    Table 1: Mock results summary.
    ```

    === 2d. Create figure_map.json at fixtures/ocr/ ===
    Expected figure-map.json output (what postprocess creates):
    ```json
    {
      "figures": [
        {
          "page": 1,
          "filename": "page_001_fig_01.png",
          "caption": "Mock figure 1: Testing flowchart.",
          "key": "fig_001"
        }
      ],
      "tables": [
        {
          "page": 2,
          "filename": "page_002_table_01.png",
          "caption": "Mock table 1: Results summary.",
          "key": "tbl_001"
        }
      ],
      "total_figures": 1,
      "total_tables": 1
    }
    ```

    === 2e. Run the PDF generator and verify ===
    ```bash
    python fixtures/pdf/generate_fixtures.py
    ```
    If pymupdf is not installed, install it first:
    ```bash
    pip install pymupdf
    ```
    Verify output:
    ```bash
    ls -la fixtures/pdf/*.pdf
    ```
  </action>

  <verify>
    <automated>
      python -c "
import json, os, sys

errs = []

# Check PDFs exist
pdf_dir = 'fixtures/pdf'
pdfs = ['blank.pdf', 'two_page.pdf', 'with_figures.pdf', 'CJK_文件名.pdf']
for p in pdfs:
    fpath = os.path.join(pdf_dir, p)
    if not os.path.exists(fpath):
        errs.append(f'Missing PDF: {p}')
    elif os.path.getsize(fpath) > 10_000_000:
        errs.append(f'PDF too large: {p} ({os.path.getsize(fpath)} bytes)')

# Check OCR fixtures
ocr_dir = 'fixtures/ocr'
ocr_files = ['paddleocr_submit.json', 'paddleocr_poll_pending.json', 'paddleocr_poll_done.json',
             'paddleocr_result.json', 'paddleocr_error.json', 'paddleocr_timeout.json',
             'extracted_fulltext.md', 'figure_map.json']
for fname in ocr_files:
    fpath = os.path.join(ocr_dir, fname)
    if not os.path.exists(fpath):
        errs.append(f'Missing OCR fixture: {fname}')
        continue
    if fname.endswith('.json'):
        try:
            json.load(open(fpath, encoding='utf-8'))
        except json.JSONDecodeError as e:
            errs.append(f'Invalid JSON in {fname}: {e}')

# Verify specific OCR fixture content
submit = json.load(open(os.path.join(ocr_dir, 'paddleocr_submit.json')))
if 'job_id' not in submit:
    errs.append('paddleocr_submit.json missing job_id')
if submit.get('status') != 'queued':
    errs.append('paddleocr_submit.json status should be queued')

err_resp = json.load(open(os.path.join(ocr_dir, 'paddleocr_error.json')))
if 'error' not in err_resp:
    errs.append('paddleocr_error.json missing error key')

result = json.load(open(os.path.join(ocr_dir, 'paddleocr_result.json')))
for key in ['pages', 'figures', 'tables']:
    if key not in result:
        errs.append(f'paddleocr_result.json missing {key}')

if errs:
    for e in errs:
        print(f'FAIL: {e}')
    exit(1)
else:
    print('All PDF and OCR fixture checks PASS')
    print(f'  {len(pdfs)} PDFs, {len(ocr_files)} OCR fixtures')
      "
    </automated>
  </verify>

  <done>
    - 4 minimal valid PDFs in fixtures/pdf/ (blank, two_page, with_figures, CJK_文件名.pdf)
    - PDF generator script (generate_fixtures.py) can regenerate at any time
    - 6 mock PaddleOCR API response fixtures in fixtures/ocr/ (submit, poll_pending, poll_done, result, error, timeout)
    - extracted_fulltext.md and figure_map.json as expected OCR output artifacts
    - All JSON is valid; each fixture matches its documented schema
    - FIX-02 (PDF fixtures) and FIX-03 (mock OCR fixtures) satisfied
  </done>
</task>

<task type="auto">
  <name>Task 3: Expected output snapshots + shared vault builder</name>
  <files>
    fixtures/snapshots/paths_json/default_config.json
    fixtures/snapshots/status_json/empty_vault.json
    fixtures/snapshots/formal_note_frontmatter/orthopedic_article.yaml
    fixtures/snapshots/index_json/after_sync.json
    fixtures/vault_builder.py
  </files>

  <action>
    === 3a. Create expected output snapshots ===

    These are the "expected" output files that pytest-snapshot tests compare against. They define the contract.

    1. `fixtures/snapshots/paths_json/default_config.json`
       Expected output of `paperforge paths --json` with default paperforge.json config:
       ```json
       {
         "vault": "/tmp/pytest-.../test_vault",
         "worker_script": "/tmp/pytest-.../test_vault/System/PaperForge/paperforge/worker/...",
         "ld_deep_script": "/tmp/pytest-.../test_vault/.opencode/skills/literature-qa/scripts/ld_deep.py"
       }
       ```
       NOTE: The snapshot test will normalize dynamic paths before comparing. This file serves as the shape template — the test normalizes vault paths to a placeholder.

    2. `fixtures/snapshots/status_json/empty_vault.json`
       Expected output of `paperforge status --json` on an empty vault (no papers synced).
       Use static placeholder paths. The snapshot test normalizes absolute paths to `<VAULT>` and timestamps to `<TIMESTAMP>`.
       ```json
       {
         "total_papers": 0,
         "formal_notes": 0,
         "do_ocr": 0,
         "analyze": 0,
         "ocr_done": 0,
         "ocr_pending": 0,
         "ocr_failed": 0,
         "path_errors": 0,
         "deep_reading_done": 0,
         "deep_reading_pending": 0,
         "version": "x.y.z",
         "vault": "<VAULT>",
         "generated_at": "<TIMESTAMP>"
       }
       ```

    3. `fixtures/snapshots/formal_note_frontmatter/orthopedic_article.yaml`
       Expected frontmatter YAML of a formal note after sync with orthopedic.json fixture.
       This documents the expected frontmatter contract:
       ```yaml
       zotero_key: FIXT0001
       domain: orthopedic
       title: "Biomechanical Comparison of Suture Anchor Fixations in Rotator Cuff Repair"
       year: "2024"
       doi: "10.1016/j.jse.2024.01.001"
       has_pdf: true
       pdf_path: "[[System/Zotero/storage/FIXT0001/FIXT0001.pdf]]"
       recommend_analyze: true
       analyze: false
       do_ocr: false
       ocr_status: pending
       deep_reading_status: pending
       path_error: ""
       analysis_note: ""
       ```

    4. `fixtures/snapshots/index_json/after_sync.json`
       Expected canonical index entry after sync with orthopedic.json:
       ```json
       {
         "format_version": "1",
         "generated_at": "<TIMESTAMP>",
         "paperforge_version": "x.y.z",
         "items": [
           {
             "zotero_key": "FIXT0001",
             "domain": "orthopedic",
             "title": "Biomechanical Comparison of Suture Anchor Fixations in Rotator Cuff Repair",
             "year": "2024",
             "doi": "10.1016/j.jse.2024.01.001",
             "has_pdf": true,
             "pdf_path": "<VAULT>/System/Zotero/storage/FIXT0001/FIXT0001.pdf",
             "ocr_status": "pending",
             "paper_root": "<VAULT>/Resources/Literature/orthopedic/FIXT0001 - Biomechanical...",
             "main_note_path": "<VAULT>/Resources/Literature/orthopedic/FIXT0001 - ...md",
             "lifecycle": "indexed",
             "health": {},
             "next_step": "review_ocr"
           }
         ]
       }
       ```

    === 3b. Create fixtures/vault_builder.py ===
    Shared vault factory that creates disposable test vaults at different completeness levels. Used by CLI conftest, E2E conftest, and journey conftest.

    ```python
    """VaultBuilder — factory for creating disposable test vaults from golden datasets.

    Three completeness levels:
    - minimal: tmp_path + paperforge.json + dirs + .env
    - standard: minimal + BBT exports copied + mock PDFs + Zotero storage
    - full: standard + OCR fixtures + formal notes + canonical index

    Usage:
        builder = VaultBuilder()
        vault = builder.build("minimal")   # Returns Path to vault
    """

    from __future__ import annotations

    import json
    import shutil
    from pathlib import Path

    FIXTURES_DIR = Path(__file__).resolve().parent


    class VaultBuilder:
        """Creates disposable test vaults from golden dataset fixtures."""

        def __init__(self, fixtures_root: Path | None = None):
            self.fixtures_root = fixtures_root or FIXTURES_DIR

        def build(self, level: str = "minimal") -> Path:
            """Build a test vault at the given completeness level.

            Args:
                level: One of "minimal", "standard", "full"

            Returns:
                Path to the vault root directory.
            """
            import tempfile
            vault = Path(tempfile.mkdtemp(prefix="pf_vault_"))
            self._create_config(vault)
            self._create_dirs(vault)
            self._create_env(vault)

            if level in ("standard", "full"):
                self._copy_exports(vault)
                self._copy_pdfs(vault)

            if level == "full":
                self._copy_ocr_fixtures(vault)
                self._create_formal_notes(vault)

            return vault

        def _create_config(self, vault: Path) -> None:
            """Create paperforge.json with default clean directory names."""
            config = {
                "version": "1.0.0",
                "system_dir": "System",
                "resources_dir": "Resources",
                "literature_dir": "Literature",
                "control_dir": "LiteratureControl",
                "base_dir": "Bases",
                "skill_dir": ".opencode/skills",
            }
            (vault / "paperforge.json").write_text(
                json.dumps(config, indent=2, ensure_ascii=False),
                encoding="utf-8",
            )

        def _create_dirs(self, vault: Path) -> None:
            """Create all required vault directories."""
            dirs = [
                "System/PaperForge/exports",
                "System/PaperForge/ocr",
                "System/PaperForge/indexes",
                "System/PaperForge/config",
                "System/Zotero/storage",
                "Resources/Literature",
                "Resources/LiteratureControl",
                "Bases",
                ".opencode/skills/literature-qa/scripts",
            ]
            for d in dirs:
                (vault / d).mkdir(parents=True, exist_ok=True)

        def _create_env(self, vault: Path) -> None:
            """Create .env with mock credentials."""
            env_content = (
                "PADDLEOCR_API_TOKEN=mock_test_token\n"
                "PADDLEOCR_JOB_URL=https://paddleocr.mock/api/v2/ocr/jobs\n"
            )
            (vault / "System/PaperForge/.env").write_text(env_content, encoding="utf-8")

        def _copy_exports(self, vault: Path) -> None:
            """Copy Zotero JSON fixtures into vault exports/."""
            exports_dir = vault / "System/PaperForge/exports"
            zotero_dir = self.fixtures_root / "zotero"
            if zotero_dir.exists():
                for f in zotero_dir.glob("*.json"):
                    if f.name in ("empty.json", "malformed.json"):
                        continue  # skip edge cases for standard builds
                    shutil.copy2(f, exports_dir / f.name)

        def _copy_pdfs(self, vault: Path) -> None:
            """Copy minimal PDFs into per-paper Zotero storage directories.

            Maps fixture keys to Zotero storage dirs:
            FIXT0001 -> System/Zotero/storage/FIXT0001/FIXT0001.pdf
            """
            pdfs = {
                "FIXT0001": "blank.pdf",
                "FIXT0002": "blank.pdf",
            }
            pdf_dir = self.fixtures_root / "pdf"
            for key, pdf_name in pdfs.items():
                src = pdf_dir / pdf_name
                if not src.exists():
                    continue
                dst_dir = vault / "System/Zotero/storage" / key
                dst_dir.mkdir(parents=True, exist_ok=True)
                shutil.copy2(src, dst_dir / f"{key}.pdf")

        def _copy_ocr_fixtures(self, vault: Path) -> None:
            """Copy OCR fixture data into vault OCR directory."""
            ocr_dir = self.fixtures_root / "ocr"
            target = vault / "System/PaperForge/ocr/FIXT0001"
            target.mkdir(parents=True, exist_ok=True)
            # Copy expected outputs
            for fname in ("extracted_fulltext.md", "figure_map.json"):
                src = ocr_dir / fname
                if src.exists():
                    shutil.copy2(src, target / fname)
            # Create meta.json
            (target / "meta.json").write_text(
                json.dumps({
                    "zotero_key": "FIXT0001",
                    "ocr_status": "done",
                    "page_count": 2,
                    "generated_at": "2026-05-08T00:00:00+00:00",
                }, indent=2),
                encoding="utf-8",
            )

        def _create_formal_notes(self, vault: Path) -> None:
            """Create formal note files for synced papers."""
            note_content = (
                "---\n"
                'zotero_key: "FIXT0001"\n'
                'domain: "orthopedic"\n'
                'title: "Test Article"\n'
                'year: "2024"\n'
                'doi: "10.1016/j.test.2024.01.001"\n'
                "has_pdf: true\n"
                'pdf_path: "[[System/Zotero/storage/FIXT0001/FIXT0001.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"
                "# Test Article\n\n"
                "Mock formal note for testing.\n"
            )
            domain_dir = vault / "Resources/Literature/orthopedic"
            domain_dir.mkdir(parents=True, exist_ok=True)
            (domain_dir / "FIXT0001 - Test Article.md").write_text(
                note_content, encoding="utf-8",
            )
    ```

    All code is self-contained — no imports from production paperforge modules.
  </action>

  <verify>
    <automated>
      python -c "
import json, os, sys
from pathlib import Path

errs = []

# Check snapshot files exist
snap_dir = 'fixtures/snapshots'
snapshots = [
    ('paths_json/default_config.json', json),
    ('status_json/empty_vault.json', json),
    ('index_json/after_sync.json', json),
]
for rel_path, parser in snapshots:
    fpath = os.path.join(snap_dir, rel_path)
    if not os.path.exists(fpath):
        errs.append(f'Missing snapshot: {rel_path}')
    else:
        data = parser.load(open(fpath, encoding='utf-8'))
        if not isinstance(data, dict):
            errs.append(f'Snapshot {rel_path} should be a JSON object')

# Check vault_builder.py exists and is valid Python
vb_path = 'fixtures/vault_builder.py'
if not os.path.exists(vb_path):
    errs.append('Missing vault_builder.py')
else:
    import py_compile
    try:
        py_compile.compile(vb_path, doraise=True)
    except py_compile.PyCompileError as e:
        errs.append(f'vault_builder.py syntax error: {e}')

# Check formal note frontmatter snapshot
yaml_path = os.path.join(snap_dir, 'formal_note_frontmatter/orthopedic_article.yaml')
if not os.path.exists(yaml_path):
    errs.append('Missing formal_note_frontmatter snapshot')

# Quick integration: import VaultBuilder and build a minimal vault
try:
    sys.path.insert(0, 'fixtures')
    from vault_builder import VaultBuilder
    builder = VaultBuilder()
    vault = builder.build('minimal')
    assert vault.exists(), 'Vault not created'
    assert (vault / 'paperforge.json').exists(), 'paperforge.json missing'
    assert (vault / 'System/PaperForge/.env').exists(), '.env missing'
    assert (vault / 'Resources/Literature').exists(), 'Literature dir missing'

    # Build standard vault
    vault2 = builder.build('standard')
    exports = list((vault2 / 'System/PaperForge/exports').glob('*.json'))
    print(f'  Standard vault: {len(exports)} export files')

    print('  VaultBuilder integration: PASS')
except Exception as e:
    errs.append(f'VaultBuilder integration failed: {e}')

if errs:
    for e in errs:
        print(f'FAIL: {e}')
    exit(1)
else:
    print('All snapshot and vault builder checks PASS')
      )
    </automated>
  </verify>

  <done>
    - Expected output snapshots in 4 subdirectories: paths_json, status_json, formal_note_frontmatter, index_json
    - Snapshots use placeholder markers (<VAULT>, <TIMESTAMP>) at positions that will be normalized in tests
    - VaultBuilder class in fixtures/vault_builder.py supports 3 levels: minimal, standard, full
    - VaultBuilder tested: builds minimal vault with config+dirs+env, standard vault adds exports+PDFs
    - FIX-04 (expected output snapshots) and FIX-05 (vault builder + MANIFEST) satisfied
    - Full golden dataset ready for CLI contract tests (Plan 52-002)
  </done>
</task>

</tasks>

<verification>
=== Plan-level Verification ===

1. **FIX-01 (Zotero JSON fixtures)**: 10 variants created covering valid, empty, malformed, missing keys, CJK content, multi-attachment, absolute/storage/bare paths — Task 1
2. **FIX-02 (PDF fixtures)**: 4 minimal valid PDFs including CJK filename — Task 2, generated from code
3. **FIX-03 (Mock OCR responses)**: 6 API response fixtures covering submit, poll (pending/done), result, error, timeout — Task 2
4. **FIX-04 (Expected output snapshots)**: 4 snapshot directories with shape-specific expected outputs — Task 3
5. **FIX-05 (MANIFEST.json)**: Tracks all 20+ fixtures with used_by, generated, desc — Task 1

=== File Conflict Check ===
- Task 1: fixtures/zotero/*.json + MANIFEST.json — no overlap with Tasks 2, 3
- Task 2: fixtures/pdf/* + fixtures/ocr/* — no overlap with Tasks 1, 3
- Task 3: fixtures/snapshots/* + vault_builder.py — no overlap with Tasks 1, 2
- All files are independent; no file is modified by more than one task

=== Context Budget ===
- Task 1: ~18% (10 JSON fixtures + MANIFEST — straightforward copy/paste with variations)
- Task 2: ~17% (PDF generator script + 6 OCR fixtures + expected outputs)
- Task 3: ~15% (4 snapshot files + vault_builder.py)
- Total: ~50% — within budget
</verification>

<success_criteria>
- [ ] 10 Zotero JSON fixtures in fixtures/zotero/ covering all FIX-01 variants
- [ ] 4 minimal PDFs in fixtures/pdf/ including CJK filename, generated from code
- [ ] 6 mock OCR response fixtures in fixtures/ocr/ covering all API states (FIX-03)
- [ ] extracted_fulltext.md and figure_map.json as expected OCR outputs
- [ ] 4 snapshot files in fixtures/snapshots/ defining expected CLI output contracts (FIX-04)
- [ ] MANIFEST.json tracks all 20+ fixtures with used_by, generated, desc (FIX-05)
- [ ] VaultBuilder in fixtures/vault_builder.py creates 3-level vaults from golden data
- [ ] Automated verification commands all pass
- [ ] All 5 FIX requirements satisfied
</success_criteria>

<output>
After completion, create `.planning/phases/52-golden-datasets-cli-contracts/52-001-SUMMARY.md`
</output>
