From 91bbc6586e582412048e3ff82f2a61bfdd3cc257 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 26 Jun 2026 23:32:01 -0400 Subject: [PATCH 1/2] Add CI/CD pipeline, expand test coverage to 96%, ship sample documents CI (.github/workflows/ci.yml): gate job runs ruff lint + pytest (with a --cov-fail-under=90 gate) + package build across Python 3.11/3.12/3.13 on every PR and push. A release job on push to main computes the next patch version from git tags, tags it, and publishes a GitHub Release with generated notes (mirrors the actual-ai-categorizer tag-based model). Tests: added test_cli, test_client (fake httpx), test_lock, test_proposals, test_schema, test_extract, test_apply, test_pipeline_edges, test_ledger. Coverage 63% -> 96%. Configured ruff + coverage in pyproject and fixed all lint findings. Samples: committed samples/inbox (receipt PDF, invoice docx, drawing PNG) plus a regenerator script and a runnable samples/config.yaml; .gitignore tracks the inputs and demo config but ignores generated outputs (library/state/logs/proposals) and coverage artifacts. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 87 ++++++++++++++++++++++++++ .gitignore | 14 +++++ README.md | 40 ++++++++++++ pyproject.toml | 23 +++++++ samples/config.yaml | 47 ++++++++++++++ samples/generate_samples.py | 82 +++++++++++++++++++++++++ samples/inbox/PIC00001.png | Bin 0 -> 2784 bytes samples/inbox/SCAN00001.pdf | Bin 0 -> 1104 bytes samples/inbox/SCAN00002.docx | Bin 0 -> 36858 bytes scanfiler/ai/schema.py | 8 +-- scanfiler/apply.py | 3 +- scanfiler/cli.py | 16 +++-- scanfiler/ledger.py | 10 +-- scanfiler/lock.py | 2 +- scanfiler/proposals.py | 2 +- tests/conftest.py | 15 ++++- tests/test_apply.py | 92 ++++++++++++++++++++++++++++ tests/test_cli.py | 115 +++++++++++++++++++++++++++++++++++ tests/test_client.py | 102 +++++++++++++++++++++++++++++++ tests/test_config.py | 1 - tests/test_extract.py | 85 ++++++++++++++++++++++++++ tests/test_ledger.py | 57 +++++++++++++++++ tests/test_lock.py | 54 ++++++++++++++++ tests/test_pipeline.py | 2 +- tests/test_pipeline_edges.py | 72 ++++++++++++++++++++++ tests/test_proposals.py | 33 ++++++++++ tests/test_schema.py | 26 ++++++++ 27 files changed, 969 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 samples/config.yaml create mode 100644 samples/generate_samples.py create mode 100644 samples/inbox/PIC00001.png create mode 100644 samples/inbox/SCAN00001.pdf create mode 100644 samples/inbox/SCAN00002.docx create mode 100644 tests/test_apply.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_client.py create mode 100644 tests/test_extract.py create mode 100644 tests/test_ledger.py create mode 100644 tests/test_lock.py create mode 100644 tests/test_pipeline_edges.py create mode 100644 tests/test_proposals.py create mode 100644 tests/test_schema.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b1cb4f9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,87 @@ +name: CI + +# Gates run on every PR and every push to main. A release is cut only when a push +# to main passes all gates. +on: + push: + branches: [main] + pull_request: + branches: [main] + +# Don't let overlapping runs race on the release/tag step. PR runs supersede each +# other; runs on main are never cancelled mid-release. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + gate: + name: Gate (Python ${{ matrix.python }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python: ['3.11', '3.12', '3.13'] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + cache: pip + + - name: Install + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Lint (ruff) + run: ruff check . + + - name: Test (pytest + coverage gate) + run: pytest + + - name: Build (sdist + wheel) + run: python -m build + + release: + name: Release + needs: [gate] + # Only on direct pushes to main (not PRs, not forks). + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: write # create tags and releases + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # need full tag history to compute the next version + + - name: Determine next version + id: ver + run: | + latest=$(git tag -l 'v*.*.*' --sort=-v:refname | head -n1) + if [ -z "$latest" ]; then + next="v0.1.0" + else + ver=${latest#v} + IFS='.' read -r major minor patch <<< "$ver" + next="v${major}.${minor}.$((patch + 1))" + fi + echo "Next release: $next (previous: ${latest:-none})" + echo "next=$next" >> "$GITHUB_OUTPUT" + + - name: Create tag and GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + next="${{ steps.ver.outputs.next }}" + git tag "$next" + git push origin "$next" + gh release create "$next" \ + --title "$next" \ + --target "${{ github.sha }}" \ + --generate-notes diff --git a/.gitignore b/.gitignore index a706a05..cc67c65 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,13 @@ __pycache__/ build/ dist/ +# Test / coverage artifacts +.coverage +.coverage.* +htmlcov/ +.pytest_cache/ +.ruff_cache/ + # Runtime state / secrets / user config config.yaml secrets.env @@ -14,6 +21,13 @@ proposals.jsonl /state/ *.lock +# Sample demo: keep the input documents + demo config, ignore generated outputs. +!samples/config.yaml +/samples/library/ +/samples/state/ +/samples/logs/ +/samples/proposals.jsonl + # OS .DS_Store Thumbs.db diff --git a/README.md b/README.md index ee6b0d3..10ac74b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # scanfiler +[![CI](https://github.com/ridaken/Scanfiler/actions/workflows/ci.yml/badge.svg)](https://github.com/ridaken/Scanfiler/actions/workflows/ci.yml) + AI-powered renamer / reorganizer for a dump of scanned documents. It walks an inbox **one file at a time**, sends the first page(s) to an OpenAI-compatible vision-language model (built for [llama.cpp / llama-server](https://github.com/ggml-org/llama.cpp) and @@ -53,6 +55,23 @@ scanfiler undo --last # reverse the most recent apply run scanfiler --dry-run # decide + log, never touch disk ``` +## Try it on the bundled samples + +The repo ships three sample inputs in `samples/inbox/` (an auto-service receipt PDF, +an electrician's invoice docx, and a child's crayon drawing PNG) and a ready-to-run +`samples/config.yaml`. Point `ai.base_url` at a vision model, then: + +```bash +scanfiler -c samples/config.yaml plan --proposals samples/proposals.jsonl +# review samples/proposals.jsonl, then: +scanfiler -c samples/config.yaml apply --proposals samples/proposals.jsonl +# results land in samples/library/ (gitignored); undo with: +scanfiler -c samples/config.yaml undo --last +``` + +The drawing has no text, so it exercises the low-confidence → `_Unsorted` path. +Regenerate the samples any time with `python samples/generate_samples.py`. + ## Key config | Key | Meaning | @@ -90,4 +109,25 @@ macOS/Windows: use `scanfiler loop` under launchd / Task Scheduler. ```bash pytest # stubs the AI client and generates sample PDFs/images; nothing external needed +ruff check . # lint ``` + +`pytest` enforces a coverage floor (`--cov-fail-under=90` in `pyproject.toml`). + +## Contributing & releases + +Changes land via **feature branch → pull request → merge into `main`**, not direct +commits to `main`. + +```bash +git checkout -b my-change +ruff check . && pytest +git push -u origin my-change +gh pr create --base main --fill +``` + +CI (`.github/workflows/ci.yml`) runs the gates on every PR and push: **ruff lint**, +**pytest + coverage gate**, and a **package build**, across Python 3.11/3.12/3.13. +On a push to `main` that passes all gates, the release job auto-increments the patch +version, tags it (`vX.Y.Z`), and publishes a GitHub Release with generated notes — so +direct commits to `main` would make those notes noisy; use PRs. diff --git a/pyproject.toml b/pyproject.toml index 3b56432..174859c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,9 @@ dependencies = [ [project.optional-dependencies] dev = [ "pytest>=8.0", + "pytest-cov>=5.0", + "ruff>=0.6", + "build>=1.2", ] [project.scripts] @@ -29,3 +32,23 @@ include = ["scanfiler*"] [tool.pytest.ini_options] testpaths = ["tests"] +addopts = "--cov=scanfiler --cov-report=term-missing --cov-fail-under=90" + +[tool.coverage.run] +omit = [ + "scanfiler/__main__.py", # thin `python -m scanfiler` shim +] + +[tool.coverage.report] +exclude_also = [ + "if __name__ == .__main__.:", + "raise SystemExit", +] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] +ignore = ["B904"] # allow `raise ... ` without `from` in retry/validation paths diff --git a/samples/config.yaml b/samples/config.yaml new file mode 100644 index 0000000..363ef14 --- /dev/null +++ b/samples/config.yaml @@ -0,0 +1,47 @@ +# Ready-to-run demo config for the committed sample documents. +# From the repo root, with a vision model served at ai.base_url: +# +# scanfiler -c samples/config.yaml plan --proposals samples/proposals.jsonl +# # review samples/proposals.jsonl, then: +# scanfiler -c samples/config.yaml apply --proposals samples/proposals.jsonl +# +# Outputs (samples/library, samples/state, samples/logs, proposals) are gitignored. + +paths: + inbox_dir: samples/inbox + library_dir: samples/library + unsorted_subdir: _Unsorted + +ai: + base_url: http://localhost:8080/v1 # point at your llama-server / mlx-vlm / cloud + api_key: ${AI_API_KEY} + model: local-vlm + constrained_output: true + +extraction: + pdf_max_pages: 2 + send_mode: auto + +selection: + process_pattern: '^(SCAN|PIC|IMG)[\W_]*\d+' + min_mtime_age_s: 0 # samples aren't being synced; process immediately + +naming: + date_prefix: true + allow_new_subdirs: true + +categorization: + confidence_threshold: 0.6 + +apply: + mode: review + action: copy + +logging: + audit_file: samples/logs/audit.jsonl + ledger_db: samples/state/ledger.sqlite + +prompt: + context: > + Demo set of personal scanned documents: an auto-service receipt, an electrician's + invoice, and a child's crayon drawing. diff --git a/samples/generate_samples.py b/samples/generate_samples.py new file mode 100644 index 0000000..c465adb --- /dev/null +++ b/samples/generate_samples.py @@ -0,0 +1,82 @@ +"""Regenerate the sample input documents in samples/inbox/. + +These committed samples let anyone try scanfiler locally without supplying their own +documents. Run from the repo root: + + python samples/generate_samples.py + +Then point scanfiler at them: + + scanfiler -c samples/config.yaml plan --proposals samples/proposals.jsonl +""" + +from __future__ import annotations + +from pathlib import Path + +INBOX = Path(__file__).resolve().parent / "inbox" + + +def _receipt_pdf(path: Path) -> None: + import fitz + + doc = fitz.open() + page = doc.new_page() + lines = [ + "RIVERSIDE AUTO SERVICE", + "123 Main St, Springfield", + "", + "RECEIPT # 4471", + "Date: 2025-06-15", + "", + "Kia Telluride - Brake pad replacement (front) $189.00", + "Synthetic oil change $ 64.00", + "Shop supplies $ 12.50", + "", + "TOTAL $265.50", + "Paid: VISA ****1234", + "Thank you for your business!", + ] + page.insert_text((72, 90), "\n".join(lines), fontsize=11) + doc.save(str(path)) + doc.close() + + +def _invoice_docx(path: Path) -> None: + import docx + + d = docx.Document() + d.add_heading("INVOICE", level=1) + d.add_paragraph("Bright Spark Electric LLC") + d.add_paragraph("Invoice #2025-0098 Date: 2025-05-02") + d.add_paragraph("Bill to: Jordan Avery") + t = d.add_table(rows=1, cols=2) + t.rows[0].cells[0].text = "Panel upgrade to 200A" + t.rows[0].cells[1].text = "$1,450.00" + d.add_paragraph("Amount due: $1,450.00 - Net 30") + d.save(str(path)) + + +def _drawing_image(path: Path) -> None: + from PIL import Image, ImageDraw + + im = Image.new("RGB", (600, 400), (255, 252, 240)) + draw = ImageDraw.Draw(im) + # A child's crayon-style house + sun, no text -> exercises the VLM / low-confidence path. + draw.rectangle([180, 200, 380, 340], outline=(60, 90, 200), width=6) + draw.polygon([(165, 200), (280, 110), (395, 200)], outline=(200, 60, 60), width=6) + draw.rectangle([250, 270, 310, 340], outline=(60, 150, 60), width=5) + draw.ellipse([470, 40, 560, 130], outline=(240, 190, 40), width=6) + im.save(str(path)) + + +def main() -> None: + INBOX.mkdir(parents=True, exist_ok=True) + _receipt_pdf(INBOX / "SCAN00001.pdf") + _invoice_docx(INBOX / "SCAN00002.docx") + _drawing_image(INBOX / "PIC00001.png") + print(f"Wrote samples to {INBOX}") + + +if __name__ == "__main__": + main() diff --git a/samples/inbox/PIC00001.png b/samples/inbox/PIC00001.png new file mode 100644 index 0000000000000000000000000000000000000000..16f17e0a4d904683629af1f5ade9a31dc9549430 GIT binary patch literal 2784 zcmd5;YdBQ<8ecQp*vn|NB-HG2S-FIcOD@kgWtW!Qs1XUFXozT2COR2)8bfQ!ASUH9 zLKv4R%3y>Pwh|$ih(l?c5M7iGDuwp)biVj_KJ2IS?f<;*`+sl0-}Agbp380r5|K;< z0D$C3vvmakye9zQ_#hs$EbilV0RXYs(RPO$D{E?aN765LYg?ppZO;crWW}0sKQU#~ zvN*lsu~k%n_)870h4x;H01Gvpbk&oGedc@@8^Y8Aub(PidgXrl?LLj|ccKA~+rdY^ z0MMb00=7yBkS_-VJt)KpMnG$GSVl7w!b46VE>8CcxQE^h$^#71)Nu8{LS^r#=xqV4 zxexcg4u${>_Pl?c9twmE6-LY1twlJ6l)eUT0%l2Am)6)Ejy`qWy{pNvpI`3WT_?OFPF2p@OJ-Yerk_{)2z7nsuF?>IAb zC-QsC&O-P;*Qqh){BOh+=R@ngojn%cOJrCy1G#-|TTw4?p#KrMq{>qdQ)*>PeFVmGG>fi3S|uznpbQ zrOQ)0Q-kvA27<55My%FBnW2qi{Lus7f~Px8j|$n{YZ35#?~JllD-0EgIj2@=uJm7jPv;FOH-x zo!NleUU{NzSm2}R+&=eS3D)i#?`l$?o;U7QwJz29rZTOL(DJwZ^@DG z_cQ4Ny*%nPgz|6tF9(WM9fY%^b;CZy ze_w0D@Rb^OgP_ze9nSQ1w@4+?sQxbO4o{ss9j5-7frA%_Ms#_v z4B?)WnlaRcn_kI0{^?pX`_1GEb$HCkU>!}~D-jCcs+?HJExm7s9D`$6I^tDIuyIlB zzx>Sua7b&rNHaMY9;TAVz3W>j#;en`(F4tzaRYKCSYtD8{7qEdLM?b~y+Lb2Fg#^R z9#7EbpJpv~V{0j{O*p<$2>XDrv&)W?d0dGmYE=~bmL`9#%M4lC7(;0(NvNQs3oU2j zZPt_YcY*WiL{pB6>m^~&iS+H3q}TV?DEMzkEQ)m-wRp%vGTPP7XWlVgPX}#k2V4XL z8?~bB*R>d)v$rx00F?YxlnYeLJY`K3|6>=%{_^yR6pUW@SS7zpO?Txrlnhj=T5o(l z+DN+dCjJHe2i(ISd5f28PtCn0G+KtS7t2S-mqsV5E7O5ZW{NBIK~&|c+O~SR!dF*L zY*?ioSDxXvfhHMO>KNHO9fdg+cg#}c=%h=+Obl}L?Pwo?$^ob zd#qpD-sS5oI=Db)&S4Aw{FlE{>;9f_sf_rPm%Zj{%-I_*t5->hs~QK#?nqca*TV0? zT^nBhl~24kX)B#Q9D6IbwZ-*ATG6Lh#nr;6lDDhwcw4#S@T~rIw#N$?pG;=wyY{Ks z)9`7NVyx z#h>p>M7)%`vaD}e6!PF=hDN|d zh%RQ1sm{m{m>AL28Dr6FVql3-$5m34n3do(#XWg j+|bzB!qvjr!pRPl2Z~D)i%P&jU}0>)rK;-c@5TiHMb~;D literal 0 HcmV?d00001 diff --git a/samples/inbox/SCAN00002.docx b/samples/inbox/SCAN00002.docx new file mode 100644 index 0000000000000000000000000000000000000000..8e303b367c2446f5298f641d8c4dd5bdb092f925 GIT binary patch literal 36858 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 int: if not audit_file.is_file(): return 0 - records = [json.loads(line) for line in audit_file.read_text(encoding="utf-8").splitlines() if line.strip()] + lines = audit_file.read_text(encoding="utf-8").splitlines() + records = [json.loads(line) for line in lines if line.strip()] moves = [r for r in records if r.get("action") in ("copy", "move")] if not moves: return 0 diff --git a/scanfiler/cli.py b/scanfiler/cli.py index 16f282a..aa3dffc 100644 --- a/scanfiler/cli.py +++ b/scanfiler/cli.py @@ -163,16 +163,24 @@ def add_dry(sp): sp.set_defaults(func=cmd_init) sp = sub.add_parser("plan", help="extract + AI, write proposals (no moves)") - add_proposals(sp); add_dry(sp); sp.set_defaults(func=cmd_plan) + add_proposals(sp) + add_dry(sp) + sp.set_defaults(func=cmd_plan) sp = sub.add_parser("apply", help="execute proposals into the library") - add_proposals(sp); add_dry(sp); sp.set_defaults(func=cmd_apply) + add_proposals(sp) + add_dry(sp) + sp.set_defaults(func=cmd_apply) sp = sub.add_parser("run", help="one full cycle (plan, then apply if mode=auto)") - add_proposals(sp); add_dry(sp); sp.set_defaults(func=cmd_run) + add_proposals(sp) + add_dry(sp) + sp.set_defaults(func=cmd_run) sp = sub.add_parser("loop", help="daemon: run a cycle every polling_minutes") - add_proposals(sp); add_dry(sp); sp.set_defaults(func=cmd_loop) + add_proposals(sp) + add_dry(sp) + sp.set_defaults(func=cmd_loop) sp = sub.add_parser("undo", help="reverse a run from the audit log") sp.add_argument("--run", help="run id to undo") diff --git a/scanfiler/ledger.py b/scanfiler/ledger.py index 682e510..b150ba3 100644 --- a/scanfiler/ledger.py +++ b/scanfiler/ledger.py @@ -12,10 +12,10 @@ import json import sqlite3 import time +from collections.abc import Iterator from contextlib import contextmanager from dataclasses import dataclass, field from pathlib import Path -from typing import Iterator, Optional # Lifecycle of a file in the ledger. STATUS_PENDING = "pending" # seen, not yet decided @@ -43,8 +43,8 @@ class LedgerEntry: status: str decision: dict = field(default_factory=dict) metadata: dict = field(default_factory=dict) - new_path: Optional[str] = None - error: Optional[str] = None + new_path: str | None = None + error: str | None = None created_at: float = 0.0 updated_at: float = 0.0 @@ -77,13 +77,13 @@ def __init__(self, db_path: str | Path): def close(self) -> None: self._conn.close() - def __enter__(self) -> "Ledger": + def __enter__(self) -> Ledger: return self def __exit__(self, *exc) -> None: self.close() - def get(self, file_hash: str) -> Optional[LedgerEntry]: + def get(self, file_hash: str) -> LedgerEntry | None: row = self._conn.execute( "SELECT * FROM files WHERE file_hash = ?", (file_hash,) ).fetchone() diff --git a/scanfiler/lock.py b/scanfiler/lock.py index 6f55508..0fb84df 100644 --- a/scanfiler/lock.py +++ b/scanfiler/lock.py @@ -8,9 +8,9 @@ import os import time +from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path -from typing import Iterator _STALE_AGE_S = 6 * 60 * 60 # reclaim locks older than this (crash safety net) diff --git a/scanfiler/proposals.py b/scanfiler/proposals.py index d971fe7..4389983 100644 --- a/scanfiler/proposals.py +++ b/scanfiler/proposals.py @@ -8,9 +8,9 @@ from __future__ import annotations import json +from collections.abc import Iterator from dataclasses import asdict, dataclass, field from pathlib import Path -from typing import Iterator @dataclass diff --git a/tests/conftest.py b/tests/conftest.py index 8c1e5e1..f0e59ce 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -41,7 +41,10 @@ def workspace(tmp_path: Path) -> dict: inbox.mkdir() library.mkdir() - _make_pdf(inbox / "SCAN0001.pdf", "INVOICE\nAcme Plumbing\nTotal due: $240.00\nDate: 2025-06-15") + _make_pdf( + inbox / "SCAN0001.pdf", + "INVOICE\nAcme Plumbing\nTotal due: $240.00\nDate: 2025-06-15", + ) _make_docx(inbox / "SCAN0002.docx", "Medical record: annual checkup notes for patient.") _make_image(inbox / "PIC0001.jpg") # Already-named file that should be skipped unless process_all is on. @@ -95,3 +98,13 @@ def decide(self, system_prompt, user_content, existing_subdirs, allow_new) -> De @pytest.fixture def stub_client() -> StubClient: return StubClient() + + +@pytest.fixture +def config_file(workspace, config) -> Path: + """Write the test config to a YAML file on disk for CLI-level tests.""" + import yaml + + path = workspace["root"] / "config.yaml" + path.write_text(yaml.safe_dump(config.model_dump(mode="json")), encoding="utf-8") + return path diff --git a/tests/test_apply.py b/tests/test_apply.py new file mode 100644 index 0000000..e176c17 --- /dev/null +++ b/tests/test_apply.py @@ -0,0 +1,92 @@ +"""Edge cases for apply/undo beyond the happy path in test_pipeline.""" + +from __future__ import annotations + +import json + +from scanfiler.apply import apply_proposals, undo +from scanfiler.ledger import Ledger +from scanfiler.proposals import Proposal + + +def _proposal(workspace, **over): + src = workspace["inbox"] / "SCAN0001.pdf" + base = dict(file_hash="h1", original_path=str(src), subdir="Receipts", + new_filename="Receipt.pdf", confidence=0.9) + base.update(over) + return Proposal(**base) + + +def test_move_action_removes_original(config, workspace): + config.apply.action = "move" + src = workspace["inbox"] / "SCAN0001.pdf" + with Ledger(config.logging.ledger_db) as ledger: + apply_proposals(config, [_proposal(workspace)], ledger) + assert not src.exists() # moved out + assert (config.paths.library_dir / "Receipts" / "Receipt.pdf").is_file() + + +def test_move_undo_restores_to_inbox(config, workspace): + config.apply.action = "move" + src = workspace["inbox"] / "SCAN0001.pdf" + with Ledger(config.logging.ledger_db) as ledger: + apply_proposals(config, [_proposal(workspace)], ledger) + assert not src.exists() + restored = undo(config, last=True) + assert restored == 1 + assert src.exists() # moved back + + +def test_dry_run_touches_nothing(config, workspace): + with Ledger(config.logging.ledger_db) as ledger: + result = apply_proposals(config, [_proposal(workspace)], ledger, dry_run=True) + assert result.applied == 1 + assert not (config.paths.library_dir / "Receipts").exists() + assert not config.logging.audit_file.exists() + + +def test_missing_source_is_skipped(config): + p = Proposal(file_hash="h", original_path="/does/not/exist.pdf", + subdir="X", new_filename="Y.pdf") + with Ledger(config.logging.ledger_db) as ledger: + result = apply_proposals(config, [p], ledger) + assert result.skipped == 1 and result.applied == 0 + + +def test_collision_skip_policy(config, workspace): + config.apply.on_collision = "skip" + dest = config.paths.library_dir / "Receipts" / "Receipt.pdf" + dest.parent.mkdir(parents=True) + dest.write_text("existing", encoding="utf-8") + with Ledger(config.logging.ledger_db) as ledger: + result = apply_proposals(config, [_proposal(workspace)], ledger) + assert result.skipped == 1 + assert dest.read_text(encoding="utf-8") == "existing" # untouched + + +def test_error_is_recorded_not_raised(config, workspace, monkeypatch): + import scanfiler.apply as apply_mod + + def boom(*a, **k): + raise OSError("disk full") + + monkeypatch.setattr(apply_mod.shutil, "copy2", boom) + with Ledger(config.logging.ledger_db) as ledger: + result = apply_proposals(config, [_proposal(workspace)], ledger) + assert result.errors == 1 + records = [json.loads(x) for x in config.logging.audit_file.read_text().splitlines()] + assert any(r["action"] == "error" for r in records) + + +def test_undo_no_audit_file_returns_zero(config): + assert undo(config, last=True) == 0 + + +def test_sidecar_written_with_metadata(config, workspace): + p = _proposal(workspace, doc_type="receipt", tags=["x"], summary="hi", date="2025-06") + with Ledger(config.logging.ledger_db) as ledger: + apply_proposals(config, [p], ledger) + sidecar = config.paths.library_dir / "Receipts" / "Receipt.pdf.json" + data = json.loads(sidecar.read_text(encoding="utf-8")) + assert data["doc_type"] == "receipt" + assert data["source_hash"] == "h1" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..7bd3f41 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,115 @@ +"""CLI-level tests: drive scanfiler.cli.main with a stubbed AI client.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +import scanfiler.cli as cli + + +@pytest.fixture(autouse=True) +def _patch_client(monkeypatch, stub_client): + monkeypatch.setattr(cli, "_make_client", lambda cfg: stub_client) + + +def test_init_writes_and_refuses_overwrite(tmp_path, capsys): + cfgp = tmp_path / "config.yaml" + assert cli.main(["-c", str(cfgp), "init"]) == 0 + assert cfgp.is_file() + # second time refuses + assert cli.main(["-c", str(cfgp), "init"]) == 1 + assert "not overwriting" in capsys.readouterr().out + + +def test_plan_then_apply_then_status_then_undo(config_file, workspace, capsys): + c = str(config_file) + proposals = str(workspace["root"] / "proposals.jsonl") + + assert cli.main(["-c", c, "plan", "--proposals", proposals]) == 0 + assert Path(proposals).is_file() + assert "proposed=" in capsys.readouterr().out + + assert cli.main(["-c", c, "apply", "--proposals", proposals]) == 0 + assert "applied=" in capsys.readouterr().out + + assert cli.main(["-c", c, "status"]) == 0 + assert "applied" in capsys.readouterr().out + + assert cli.main(["-c", c, "undo", "--last"]) == 0 + assert "restored" in capsys.readouterr().out + + +def test_plan_dry_run_writes_nothing(config_file, workspace, capsys): + proposals = workspace["root"] / "proposals.jsonl" + cli.main(["-c", str(config_file), "plan", "--proposals", str(proposals), "--dry-run"]) + assert not proposals.exists() + assert "dry-run" in capsys.readouterr().out + + +def test_run_review_mode_writes_proposals(config_file, workspace, capsys): + proposals = workspace["root"] / "proposals.jsonl" + assert cli.main(["-c", str(config_file), "run", "--proposals", str(proposals)]) == 0 + assert proposals.is_file() + + +def test_run_auto_mode_applies(config_file, config, workspace, monkeypatch, capsys): + # Flip mode=auto by rewriting the config file. + import yaml + + data = yaml.safe_load(config_file.read_text(encoding="utf-8")) + data["apply"]["mode"] = "auto" + config_file.write_text(yaml.safe_dump(data), encoding="utf-8") + + assert cli.main(["-c", str(config_file), "run"]) == 0 + assert "auto-apply" in capsys.readouterr().out + # something landed in the library + assert any(config.paths.library_dir.rglob("*.pdf")) + + +def test_run_skips_when_locked(config_file, config, capsys): + from scanfiler.lock import file_lock + + lock_path = Path(config.logging.ledger_db).with_suffix(".lock") + with file_lock(lock_path): # hold the lock; the run should bail out cleanly + assert cli.main(["-c", str(config_file), "run"]) == 0 + assert "skip:" in capsys.readouterr().out + + +def test_status_empty_ledger(config_file, capsys): + assert cli.main(["-c", str(config_file), "status"]) == 0 + assert "ledger empty" in capsys.readouterr().out + + +def test_undo_nonexistent_run(config_file, capsys): + assert cli.main(["-c", str(config_file), "undo", "--run", "deadbeef"]) == 0 + assert "restored 0" in capsys.readouterr().out + + +def test_loop_stops_on_keyboard_interrupt(config_file, monkeypatch, capsys): + def stop(*a, **k): + raise KeyboardInterrupt + + monkeypatch.setattr(cli, "_run_once", stop) + assert cli.main(["-c", str(config_file), "loop"]) == 0 + assert "stopped" in capsys.readouterr().out + + +def test_loop_survives_a_bad_cycle(config_file, monkeypatch, capsys): + calls = {"n": 0} + + def flaky(cfg, args): + calls["n"] += 1 + raise RuntimeError("transient cycle failure") + + # First sleep ends the loop so the test doesn't hang; the cycle error must be caught. + def fake_sleep(_): + raise KeyboardInterrupt + + monkeypatch.setattr(cli, "_run_once", flaky) + monkeypatch.setattr(cli.time, "sleep", fake_sleep) + with pytest.raises(KeyboardInterrupt): + cli.main(["-c", str(config_file), "loop"]) + assert calls["n"] == 1 + assert "cycle error" in capsys.readouterr().err diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..76609c9 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,102 @@ +"""AIClient tests with a fake httpx transport (no network).""" + +from __future__ import annotations + +import json + +import pytest + +from scanfiler.ai.client import OpenAICompatClient, make_client +from scanfiler.config import AIConfig + + +class _FakeResponse: + def __init__(self, payload, status_ok=True): + self._payload = payload + self._ok = status_ok + + def raise_for_status(self): + if not self._ok: + raise RuntimeError("HTTP 500") + + def json(self): + return self._payload + + +class _FakeClient: + """Stands in for httpx.Client; scripted per-call responses.""" + + script: list = [] + posted: list = [] + + def __init__(self, *a, **k): + pass + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def post(self, url, json=None, headers=None): + _FakeClient.posted.append({"url": url, "json": json, "headers": headers}) + item = _FakeClient.script.pop(0) + if isinstance(item, Exception): + raise item + return item + + +def _decision_payload(**over): + base = {"filename": "Doc", "subdir": "Misc", "confidence": 0.7} + base.update(over) + return {"choices": [{"message": {"content": json.dumps(base)}}]} + + +@pytest.fixture(autouse=True) +def _patch_httpx(monkeypatch): + import httpx + + _FakeClient.script = [] + _FakeClient.posted = [] + monkeypatch.setattr(httpx, "Client", _FakeClient) + + +def _client(**over): + cfg = AIConfig(max_retries=3, request_timeout_s=1, **over) + return OpenAICompatClient(cfg) + + +def test_decide_success_parses_decision(): + _FakeClient.script = [_FakeResponse(_decision_payload(filename="Invoice"))] + d = _client().decide("sys", [{"type": "text", "text": "x"}], ["Misc"], True) + assert d.filename == "Invoice" + assert d.confidence == 0.7 + + +def test_decide_retries_then_succeeds(): + _FakeClient.script = [RuntimeError("boom"), _FakeResponse(_decision_payload())] + d = _client().decide("sys", [], [], True) + assert d.filename == "Doc" + assert len(_FakeClient.posted) == 2 # one failed, one succeeded + + +def test_decide_exhausts_retries_and_raises(): + _FakeClient.script = [RuntimeError("a"), RuntimeError("b"), RuntimeError("c")] + with pytest.raises(RuntimeError, match="failed after 3 attempts"): + _client().decide("sys", [], [], True) + + +def test_constrained_output_adds_response_format(): + _FakeClient.script = [_FakeResponse(_decision_payload())] + _client(constrained_output=True).decide("sys", [], ["A", "B"], False) + assert "response_format" in _FakeClient.posted[0]["json"] + + +def test_api_key_sets_auth_header(): + _FakeClient.script = [_FakeResponse(_decision_payload())] + _client(api_key="tok").decide("sys", [], [], True) + assert _FakeClient.posted[0]["headers"]["Authorization"] == "Bearer tok" + + +def test_make_client_returns_openai_compat(): + assert isinstance(make_client(AIConfig()), OpenAICompatClient) diff --git a/tests/test_config.py b/tests/test_config.py index fe02a28..e49bf56 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,4 +1,3 @@ -import os from scanfiler.config import load_config diff --git a/tests/test_extract.py b/tests/test_extract.py new file mode 100644 index 0000000..61abe78 --- /dev/null +++ b/tests/test_extract.py @@ -0,0 +1,85 @@ +from pathlib import Path + +from scanfiler.config import ExtractionConfig +from scanfiler.extract import classify, extract + + +def test_classify(): + assert classify(Path("a.pdf")) == "pdf" + assert classify(Path("a.DOCX")) == "docx" + assert classify(Path("a.JPG")) == "image" + assert classify(Path("a.xyz")) == "unknown" + + +def test_unknown_type_returns_error(tmp_path): + f = tmp_path / "weird.xyz" + f.write_text("hello", encoding="utf-8") + r = extract(f, ExtractionConfig()) + assert r.kind == "unknown" + assert r.error is not None + assert r.has_content is False + + +def test_corrupt_pdf_surfaces_error(tmp_path): + f = tmp_path / "broken.pdf" + f.write_bytes(b"%PDF-1.4 not really a pdf") + r = extract(f, ExtractionConfig()) + assert r.error is not None # exception captured, not raised + + +def test_pdf_text_mode_carries_no_images(tmp_path): + import fitz + + f = tmp_path / "doc.pdf" + doc = fitz.open() + doc.new_page().insert_text((72, 72), "Plenty of words " * 30) + doc.save(str(f)) + doc.close() + + r = extract(f, ExtractionConfig(send_mode="text")) + assert r.text.strip() + assert r.images == [] + + +def test_pdf_vision_mode_renders_images(tmp_path): + import fitz + + f = tmp_path / "doc.pdf" + doc = fitz.open() + doc.new_page().insert_text((72, 72), "x") + doc.save(str(f)) + doc.close() + + r = extract(f, ExtractionConfig(send_mode="vision", pdf_max_pages=1)) + assert len(r.images) == 1 + + +def test_image_downscaled_and_pngified(tmp_path): + from PIL import Image + + f = tmp_path / "big.png" + Image.new("RGB", (4000, 100), (1, 2, 3)).save(str(f)) + r = extract(f, ExtractionConfig()) + assert r.kind == "image" + assert len(r.images) == 1 + # re-open the produced PNG; longest edge must be capped + import io + + with Image.open(io.BytesIO(r.images[0])) as im: + assert max(im.size) <= 1600 + + +def test_docx_extracts_paragraphs_and_tables(tmp_path): + import docx + + f = tmp_path / "d.docx" + d = docx.Document() + d.add_paragraph("Hello world") + t = d.add_table(rows=1, cols=2) + t.rows[0].cells[0].text = "Key" + t.rows[0].cells[1].text = "Value" + d.save(str(f)) + + r = extract(f, ExtractionConfig()) + assert "Hello world" in r.text + assert "Key | Value" in r.text diff --git a/tests/test_ledger.py b/tests/test_ledger.py new file mode 100644 index 0000000..e145797 --- /dev/null +++ b/tests/test_ledger.py @@ -0,0 +1,57 @@ +from scanfiler.ledger import ( + STATUS_APPLIED, + STATUS_ERROR, + STATUS_PENDING, + LedgerEntry, + hash_file, + open_ledger, +) + + +def test_hash_file_stable(tmp_path): + f = tmp_path / "a.bin" + f.write_bytes(b"hello world") + assert hash_file(f) == hash_file(f) + g = tmp_path / "b.bin" + g.write_bytes(b"hello world!") + assert hash_file(f) != hash_file(g) + + +def test_upsert_and_get(tmp_path): + with open_ledger(tmp_path / "l.sqlite") as ledger: + ledger.upsert(LedgerEntry(file_hash="h", original_name="x.pdf", + status=STATUS_PENDING)) + e = ledger.get("h") + assert e is not None and e.status == STATUS_PENDING + assert e.created_at > 0 and e.updated_at >= e.created_at + + +def test_upsert_updates_in_place_keeping_created(tmp_path): + with open_ledger(tmp_path / "l.sqlite") as ledger: + ledger.upsert(LedgerEntry(file_hash="h", original_name="x", status=STATUS_PENDING)) + created = ledger.get("h").created_at + ledger.upsert(LedgerEntry(file_hash="h", original_name="x", status=STATUS_APPLIED, + metadata={"k": "v"})) + e = ledger.get("h") + assert e.status == STATUS_APPLIED + assert e.metadata == {"k": "v"} + assert e.created_at == created + + +def test_seen_only_for_decided_states(tmp_path): + with open_ledger(tmp_path / "l.sqlite") as ledger: + ledger.upsert(LedgerEntry(file_hash="p", original_name="x", status=STATUS_PENDING)) + ledger.upsert(LedgerEntry(file_hash="e", original_name="y", status=STATUS_ERROR)) + ledger.upsert(LedgerEntry(file_hash="a", original_name="z", status=STATUS_APPLIED)) + assert ledger.seen("p") is False # pending -> retry + assert ledger.seen("e") is False # error -> retry + assert ledger.seen("a") is True + assert ledger.seen("missing") is False + + +def test_counts(tmp_path): + with open_ledger(tmp_path / "l.sqlite") as ledger: + ledger.upsert(LedgerEntry(file_hash="a", original_name="x", status=STATUS_APPLIED)) + ledger.upsert(LedgerEntry(file_hash="b", original_name="y", status=STATUS_APPLIED)) + ledger.upsert(LedgerEntry(file_hash="c", original_name="z", status=STATUS_ERROR)) + assert ledger.counts() == {STATUS_APPLIED: 2, STATUS_ERROR: 1} diff --git a/tests/test_lock.py b/tests/test_lock.py new file mode 100644 index 0000000..8f2ced0 --- /dev/null +++ b/tests/test_lock.py @@ -0,0 +1,54 @@ +import os +import sys +import time + +import pytest + +from scanfiler.lock import _STALE_AGE_S, LockHeld, file_lock + + +def test_lock_acquire_release(tmp_path): + lp = tmp_path / "x.lock" + with file_lock(lp): + assert lp.exists() + assert not lp.exists() # released on exit + + +def test_lock_held_raises(tmp_path): + lp = tmp_path / "x.lock" + with file_lock(lp): + with pytest.raises(LockHeld): + with file_lock(lp): + pass + + +def test_stale_lock_reclaimed_by_age(tmp_path): + # An old lock is reclaimed by age on every platform (crash safety net). + lp = tmp_path / "x.lock" + lp.write_text(f"{os.getpid()} 0", encoding="utf-8") + old = time.time() - _STALE_AGE_S - 60 + os.utime(lp, (old, old)) + with file_lock(lp): + assert lp.exists() + + +@pytest.mark.skipif(sys.platform == "win32", reason="PID liveness check is POSIX-only") +def test_stale_lock_reclaimed_by_dead_pid(tmp_path): + lp = tmp_path / "x.lock" + lp.write_text("999999999 0", encoding="utf-8") # definitely-dead PID + with file_lock(lp): + assert lp.exists() + + +def test_garbage_lock_reclaimed(tmp_path): + lp = tmp_path / "x.lock" + lp.write_text("not-a-pid", encoding="utf-8") + with file_lock(lp): + assert lp.exists() + + +def test_lock_writes_pid(tmp_path): + lp = tmp_path / "x.lock" + with file_lock(lp): + content = lp.read_text(encoding="utf-8") + assert content.split()[0] == str(os.getpid()) diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index c7d63c7..0e06dfa 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -1,7 +1,7 @@ from pathlib import Path from scanfiler.apply import apply_proposals, undo -from scanfiler.ledger import STATUS_APPLIED, Ledger, hash_file +from scanfiler.ledger import Ledger from scanfiler.pipeline import plan diff --git a/tests/test_pipeline_edges.py b/tests/test_pipeline_edges.py new file mode 100644 index 0000000..bf36127 --- /dev/null +++ b/tests/test_pipeline_edges.py @@ -0,0 +1,72 @@ +"""Selection and routing edge cases in the pipeline.""" + +from __future__ import annotations + +from pathlib import Path + +from scanfiler.ai.schema import Decision +from scanfiler.ledger import STATUS_ERROR, Ledger +from scanfiler.pipeline import iter_inbox, plan + + +class _FixedClient: + def __init__(self, decision): + self.decision = decision + + def decide(self, *a, **k): + return self.decision + + +def test_process_all_includes_named_files(config, stub_client, workspace): + config.selection.process_all = True + with Ledger(config.logging.ledger_db) as ledger: + proposals, _ = plan(config, stub_client, ledger) + names = {Path(p.original_path).name for p in proposals} + assert "TaxReturn2024.pdf" in names # normally excluded by process_pattern + + +def test_blocked_new_subdir_routes_to_unsorted(config, workspace): + config.naming.allow_new_subdirs = False + client = _FixedClient( + Decision(filename="Doc", subdir="BrandNew", is_new_subdir=True, confidence=0.99) + ) + with Ledger(config.logging.ledger_db) as ledger: + proposals, stats = plan(config, client, ledger) + assert all(p.subdir == config.paths.unsorted_subdir for p in proposals) + assert stats.unsorted == len(proposals) + + +def test_extraction_failure_marks_error(config, stub_client, workspace): + # An empty PDF extension with no content -> extraction yields no content -> error status. + bad = workspace["inbox"] / "SCAN0003.pdf" + bad.write_bytes(b"%PDF-1.4 garbage") + with Ledger(config.logging.ledger_db) as ledger: + plan(config, stub_client, ledger) + entries = ledger.by_status(STATUS_ERROR) + assert any(e.original_name == "SCAN0003.pdf" for e in entries) + + +def test_ai_exception_marks_error(config, workspace): + class _Boom: + def decide(self, *a, **k): + raise RuntimeError("model down") + + with Ledger(config.logging.ledger_db) as ledger: + proposals, stats = plan(config, _Boom(), ledger) + errors = ledger.by_status(STATUS_ERROR) + assert proposals == [] + assert stats.errors >= 1 + assert any("ai:" in (e.error or "") for e in errors) + + +def test_mtime_guard_excludes_fresh_files(config, workspace): + config.selection.min_mtime_age_s = 3600 # everything is "too fresh" + assert list(iter_inbox(config)) == [] + + +def test_ignore_globs_exclude_hidden_and_partial(config, workspace): + (workspace["inbox"] / ".hidden.pdf").write_bytes(b"%PDF-1.4") + (workspace["inbox"] / "SCAN0007.pdf.partial").write_bytes(b"x") + selected = {p.name for p in iter_inbox(config)} + assert ".hidden.pdf" not in selected + assert "SCAN0007.pdf.partial" not in selected diff --git a/tests/test_proposals.py b/tests/test_proposals.py new file mode 100644 index 0000000..4595264 --- /dev/null +++ b/tests/test_proposals.py @@ -0,0 +1,33 @@ +import pytest + +from scanfiler.proposals import Proposal, read_proposals, write_proposals + + +def _p(**over): + base = dict(file_hash="h1", original_path="/in/SCAN0001.pdf", subdir="Receipts", + new_filename="Receipt.pdf") + base.update(over) + return Proposal(**base) + + +def test_roundtrip(tmp_path): + path = tmp_path / "p.jsonl" + items = [_p(), _p(file_hash="h2", tags=["a", "b"], confidence=0.9)] + write_proposals(path, items) + back = list(read_proposals(path)) + assert back == items + + +def test_skips_blank_and_comment_lines(tmp_path): + path = tmp_path / "p.jsonl" + write_proposals(path, [_p()]) + with open(path, "a", encoding="utf-8") as f: + f.write("\n# a hand comment\n") + assert len(list(read_proposals(path))) == 1 + + +def test_invalid_json_raises_with_line_number(tmp_path): + path = tmp_path / "p.jsonl" + path.write_text('{"file_hash": "h1"\n', encoding="utf-8") # truncated JSON + with pytest.raises(ValueError, match=":1:"): + list(read_proposals(path)) diff --git a/tests/test_schema.py b/tests/test_schema.py new file mode 100644 index 0000000..fe97a29 --- /dev/null +++ b/tests/test_schema.py @@ -0,0 +1,26 @@ +from scanfiler.ai.schema import Decision, build_response_format + + +def test_enum_constrained_when_new_disallowed(): + rf = build_response_format(["A", "B"], allow_new=False) + subdir = rf["json_schema"]["schema"]["properties"]["subdir"] + assert subdir == {"type": "string", "enum": ["A", "B"]} + + +def test_open_string_when_new_allowed(): + rf = build_response_format(["A", "B"], allow_new=True) + subdir = rf["json_schema"]["schema"]["properties"]["subdir"] + assert subdir == {"type": "string"} + assert rf["json_schema"]["strict"] is True + + +def test_open_string_when_no_existing_subdirs(): + rf = build_response_format([], allow_new=False) + assert rf["json_schema"]["schema"]["properties"]["subdir"] == {"type": "string"} + + +def test_decision_defaults(): + d = Decision(filename="X", subdir="Y", confidence=0.5) + assert d.is_new_subdir is False + assert d.tags == [] + assert d.date is None From 79763aee2962af8301f085d6d80316ab38a6d423 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 26 Jun 2026 23:34:45 -0400 Subject: [PATCH 2/2] Fix cross-module test import that fails off the local sys.path test_collision_resolution_same_batch imported _make_pdf from tests.conftest, which only resolves when the repo root is on sys.path (true locally, not on CI -> ModuleNotFoundError: No module named 'tests'). Expose the helper as a `make_pdf` conftest fixture and consume it via the fixture instead. Co-Authored-By: Claude Opus 4.8 --- tests/conftest.py | 6 ++++++ tests/test_pipeline.py | 6 ++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index f0e59ce..1c5c160 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -100,6 +100,12 @@ def stub_client() -> StubClient: return StubClient() +@pytest.fixture +def make_pdf(): + """Factory for writing a small text PDF (avoids cross-module test imports).""" + return _make_pdf + + @pytest.fixture def config_file(workspace, config) -> Path: """Write the test config to a YAML file on disk for CLI-level tests.""" diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 0e06dfa..5f15414 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -71,11 +71,9 @@ def test_undo_restores(config, stub_client): ledger.close() -def test_collision_resolution_same_batch(config, stub_client, workspace): +def test_collision_resolution_same_batch(config, stub_client, workspace, make_pdf): # Two files that both extract as "invoice" -> same desired name -> must dedupe. - from tests.conftest import _make_pdf - - _make_pdf(workspace["inbox"] / "SCAN0009.pdf", "INVOICE second one 2025-06") + make_pdf(workspace["inbox"] / "SCAN0009.pdf", "INVOICE second one 2025-06") ledger, proposals, _ = _plan(config, stub_client) invoice_names = [p.new_filename for p in proposals if p.subdir == "Invoices"] assert len(invoice_names) == len(set(invoice_names)) # all unique