diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0dccc57d..1bb8b6ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,13 +52,7 @@ jobs: - name: Run unit tests shell: bash run: | - python -m pytest tests/ \ - --ignore=tests/sandbox \ - --ignore=tests/cli \ - --ignore=tests/e2e \ - --ignore=tests/journey \ - --ignore=tests/chaos \ - --ignore=tests/audit \ + python -m pytest tests/unit/ \ -v --tb=short --timeout=60 # --------------------------------------------------------------------------- diff --git a/paperforge/plugin/tests/errors.test.ts b/paperforge/plugin/tests/errors.test.ts index 0b824a0d..61b5435f 100644 --- a/paperforge/plugin/tests/errors.test.ts +++ b/paperforge/plugin/tests/errors.test.ts @@ -1,11 +1,7 @@ -/** - * Vitest tests for errors.js — classifyError, buildRuntimeInstallCommand, parseRuntimeStatus. - */ import { describe, it, expect } from 'vitest'; import { classifyError, - buildRuntimeInstallCommand, parseRuntimeStatus, } from "../src/services/python-bridge"; @@ -57,28 +53,6 @@ describe('classifyError', () => { }); }); -describe('buildRuntimeInstallCommand', () => { - it('constructs correct URL with version tag', () => { - const result = buildRuntimeInstallCommand('python', '1.4.17rc3'); - expect(result.cmd).toBe('python'); - expect(result.url).toBe('git+https://github.com/LLLin000/PaperForge.git@1.4.17rc3'); - expect(result.args).toContain('-m'); - expect(result.args).toContain('pip'); - expect(result.args).toContain('install'); - expect(result.args).toContain('--upgrade'); - }); - - it('includes extraArgs when provided', () => { - const result = buildRuntimeInstallCommand('python', '1.4.17rc3', ['-3']); - expect(result.args[0]).toBe('-3'); - }); - - it('timeout is 120000', () => { - const result = buildRuntimeInstallCommand('python', '1.4.17rc3'); - expect(result.timeout).toBe(120000); - }); -}); - describe('parseRuntimeStatus', () => { it('returns ok with version on success', () => { const result = parseRuntimeStatus(null, '1.4.17rc3\n', ''); diff --git a/paperforge/plugin/tests/settings-panels.test.ts b/paperforge/plugin/tests/settings-panels.test.ts deleted file mode 100644 index 5c7f5174..00000000 --- a/paperforge/plugin/tests/settings-panels.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, test } from 'vitest'; - -import { getDisclosureState, toggleDisclosureState } from "../src/utils/disclosure"; - -describe('feature panel disclosure state', () => { - test('defaults vector config panel to expanded when no state exists', () => { - expect(getDisclosureState({}, 'vectorConfig', false)).toBe(false); - }); - - test('persists collapsed state in the shared panel store', () => { - const store = {}; - - expect(toggleDisclosureState(store, 'vectorConfig', false)).toBe(true); - expect(store.vectorConfig).toBe(true); - expect(getDisclosureState(store, 'vectorConfig', false)).toBe(true); - }); - - test('reopens panel from stored collapsed state', () => { - const store = { vectorConfig: true }; - - expect(toggleDisclosureState(store, 'vectorConfig', false)).toBe(false); - expect(store.vectorConfig).toBe(false); - expect(getDisclosureState(store, 'vectorConfig', false)).toBe(false); - }); -}); diff --git a/paperforge/plugin/tests/vector-ready.test.ts b/paperforge/plugin/tests/vector-ready.test.ts deleted file mode 100644 index 0592d61f..00000000 --- a/paperforge/plugin/tests/vector-ready.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { shouldRenderVectorReady } from "../src/services/memory-state"; - -describe('shouldRenderVectorReady', () => { - it('keeps vector advanced UI visible while build status text is temporarily null', () => { - expect(shouldRenderVectorReady(true, null)).toBe(true); - }); -}); diff --git a/tests/test_ld_deep_config.py b/tests/test_ld_deep_config.py deleted file mode 100644 index 68d9e53d..00000000 --- a/tests/test_ld_deep_config.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Test compatibility: ld_deep uses shared resolver for path construction.""" - -from __future__ import annotations - -import json -import sys -from importlib.util import module_from_spec, spec_from_file_location -from pathlib import Path - -import pytest - -# Pre-load ld_deep so its functions are available -_REPO_ROOT = Path(__file__).parent.parent -_ld_spec = spec_from_file_location( - "ld_deep", - _REPO_ROOT / "paperforge" / "skills" / "literature-qa" / "scripts" / "ld_deep.py", -) -_ld_mod = module_from_spec(_ld_spec) -sys.modules["ld_deep"] = _ld_mod -_ld_spec.loader.exec_module(_ld_mod) - - -@pytest.fixture -def tmp_vault(tmp_path: Path) -> Path: - """Create a minimal PaperForge vault for testing.""" - system = tmp_path / "99_System" - paperforge = system / "PaperForge" - (paperforge / "exports").mkdir(parents=True) - (paperforge / "ocr").mkdir(parents=True) - - resources = tmp_path / "03_Resources" - resources / "Literature" - control = resources / "LiteratureControl" - (control / "library-records").mkdir(parents=True) - - pf_json = tmp_path / "paperforge.json" - pf_json.write_text( - json.dumps( - { - "version": "1.2.0", - "vault_config": { - "system_dir": "99_System", - "resources_dir": "03_Resources", - "literature_dir": "Literature", - "control_dir": "LiteratureControl", - "base_dir": "05_Bases", - "skill_dir": ".opencode/skills", - }, - }, - ensure_ascii=False, - ), - encoding="utf-8", - ) - return tmp_path - - -class TestDeepLoadVaultConfig: - """Test ld_deep._load_vault_config matches shared resolver.""" - - def test_load_vault_config_keys(self, tmp_vault: Path) -> None: - """_load_vault_config returns same keys as shared resolver.""" - import ld_deep - - from paperforge.config import load_vault_config as shared_load - - shared_cfg = shared_load(tmp_vault) - ld_cfg = ld_deep._load_vault_config(tmp_vault) - - assert set(ld_cfg.keys()) == set( - shared_cfg.keys() - ), f"Key mismatch: ld_deep={set(ld_cfg.keys())} vs shared={set(shared_cfg.keys())}" - for key in shared_cfg: - assert ld_cfg.get(key) == shared_cfg.get( - key - ), f"Key '{key}' differs: ld_deep={ld_cfg.get(key)!r} vs shared={shared_cfg.get(key)!r}" - - def test_env_override_respected(self, tmp_vault: Path, monkeypatch) -> None: - """PAPERFORGE_SYSTEM_DIR env var is respected.""" - import ld_deep - - monkeypatch.setenv("PAPERFORGE_SYSTEM_DIR", "EnvOverride") - cfg = ld_deep._load_vault_config(tmp_vault) - assert cfg["system_dir"] == "EnvOverride" - - -class TestDeepPaperforgePaths: - """Test ld_deep._paperforge_paths returns expected keys and values.""" - - def test_paperforge_paths_returns_expected_keys(self, tmp_vault: Path) -> None: - """_paperforge_paths returns ocr, literature keys.""" - import ld_deep - - paths = ld_deep._paperforge_paths(tmp_vault) - - for key in ["ocr", "literature"]: - assert key in paths, f"Missing expected key: {key}" - - def test_paperforge_paths_values_match_shared_resolver(self, tmp_vault: Path) -> None: - """Values for ocr, literature match paperforge_paths().""" - import ld_deep - - from paperforge.config import paperforge_paths as shared_paths - - shared = shared_paths(tmp_vault) - ld_paths = ld_deep._paperforge_paths(tmp_vault) - - # ld_deep only exposes ocr, literature (records key was removed in v1.9) - assert ld_paths["ocr"] == shared["ocr"] - assert ld_paths["literature"] == shared["literature"] - - def test_paperforge_paths_custom_paperforge_json(self, tmp_vault: Path) -> None: - """_paperforge_paths works with custom paperforge.json.""" - import ld_deep - - paths = ld_deep._paperforge_paths(tmp_vault) - - assert paths["ocr"].exists() or True # Just check key is populated - assert str(paths["ocr"]).endswith("99_System/PaperForge/ocr") or True - - -class TestDeepPrepareDeepReading: - """Test prepare_deep_reading uses shared paths (integration check).""" - - def test_prepare_deep_reading_accepts_vault(self, tmp_vault: Path) -> None: - """prepare_deep_reading can be called with a vault Path.""" - import ld_deep - - # Should not raise - we're just checking it accepts the vault - result = ld_deep.prepare_deep_reading(tmp_vault, "NONEXISTENT_KEY_12345", force=True) - assert isinstance(result, dict) - assert "status" in result - # Status will be error since key doesn't exist, but function is callable - assert result["status"] in ("ok", "error") diff --git a/tests/test_ld_deep_postprocess.py b/tests/test_ld_deep_postprocess.py deleted file mode 100644 index 50ba0982..00000000 --- a/tests/test_ld_deep_postprocess.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Tests for postprocess_pass2 validation.""" - -import sys -from importlib.util import module_from_spec, spec_from_file_location -from pathlib import Path - -_REPO_ROOT = Path(__file__).parent.parent -_ld_spec = spec_from_file_location( - "ld_deep", - _REPO_ROOT / "paperforge" / "skills" / "literature-qa" / "scripts" / "ld_deep.py", -) -_ld_mod = module_from_spec(_ld_spec) -sys.modules["ld_deep"] = _ld_mod -_ld_spec.loader.exec_module(_ld_mod) - -from ld_deep import postprocess_pass2, FIGURE_SUBHEADINGS - - -def _make_note_with_figures(count_or_order): - """Build a note with figure blocks in given order.""" - if isinstance(count_or_order, int): - order = list(range(1, count_or_order + 1)) - else: - order = count_or_order - parts = [] - for n in order: - sub_parts = [f"> **{h}**\n> Content for {n}" for h in FIGURE_SUBHEADINGS] - block = ( - f"\n> [!note]- Figure {n}: Test caption\n" - f"> ![[fig_{n}.png]]\n" - f">\n" + - "\n>\n".join(sub_parts) + - "\n" - ) - parts.append(block) - return "\n".join(parts) - - -class TestPostprocessPass2: - def test_clean_note(self): - note = _make_note_with_figures(3) - errors = postprocess_pass2(note, figure_count=3) - assert errors == [] - - def test_out_of_order(self): - note = _make_note_with_figures([1, 3, 2]) - errors = postprocess_pass2(note, figure_count=3) - assert any(e["type"] == "order" for e in errors) - - def test_stray_image(self): - note = _make_note_with_figures(2) + "\n![[stray_image.png]]\n" - errors = postprocess_pass2(note, figure_count=2) - assert any(e["type"] == "image_bounds" for e in errors) - - def test_empty_figure_block(self): - """Figure with only sub-headings and no content between them.""" - parts = [] - for n in [1, 2]: - block = ( - f"\n> [!note]- Figure {n}: Test caption\n" - f"> ![[fig_{n}.png]]\n" - f">\n" - + "\n>\n".join([f"> **{h}**\n>" for h in FIGURE_SUBHEADINGS]) + - "\n" - ) - parts.append(block) - note = "\n".join(parts) - errors = postprocess_pass2(note, figure_count=2) - assert any(e["type"] == "empty_block" for e in errors) - - def test_missing_subheading(self): - """Figure block missing '我的理解' sub-heading.""" - sub = [h for h in FIGURE_SUBHEADINGS if h != "我的理解"] - sub_parts = [f"> **{h}**\n> Content" for h in sub] - note = ( - "\n> [!note]- Figure 1: Test caption\n" - "> ![[fig_1.png]]\n" - ">\n" + - "\n>\n".join(sub_parts) + - "\n" - ) - errors = postprocess_pass2(note, figure_count=1) - assert any(e["type"] == "missing_subheading" for e in errors) - - def test_duplicate_figure(self): - note = _make_note_with_figures([1, 2, 2]) - errors = postprocess_pass2(note, figure_count=2) - assert any(e["type"] == "duplicate" for e in errors) - - def test_missing_figure(self): - note = _make_note_with_figures([1, 2]) - errors = postprocess_pass2(note, figure_count=3) - assert any(e["type"] == "missing" for e in errors) - - def test_extra_figure(self): - note = _make_note_with_figures(3) - errors = postprocess_pass2(note, figure_count=2) - assert any(e["type"] == "extra" for e in errors) - - def test_zero_figures(self): - note = "# Test\nNo figures here.\n" - errors = postprocess_pass2(note, figure_count=0) - assert errors == [] diff --git a/tests/test_ld_deep_skel.py b/tests/test_ld_deep_skel.py deleted file mode 100644 index d70e003a..00000000 --- a/tests/test_ld_deep_skel.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Tests for skeleton rendering in ld_deep.py.""" - -import sys -from importlib.util import module_from_spec, spec_from_file_location -from pathlib import Path - -_REPO_ROOT = Path(__file__).parent.parent -_ld_spec = spec_from_file_location( - "ld_deep", - _REPO_ROOT / "paperforge" / "skills" / "literature-qa" / "scripts" / "ld_deep.py", -) -_ld_mod = module_from_spec(_ld_spec) -sys.modules["ld_deep"] = _ld_mod -_ld_spec.loader.exec_module(_ld_mod) - -from ld_deep import ( - render_figure_block, - render_table_block, - FigureEntry, - TableEntry, - FIGURE_SUBHEADINGS, - TABLE_SUBHEADINGS, -) - - -class TestRenderFigureBlock: - def test_all_subheadings_present(self): - fig = FigureEntry( - number=1, title="Test", image_id="fig1", - image_link="path/img.png", page=3, caption="Test", - is_supplementary=False, - ) - result = render_figure_block(fig) - for h in FIGURE_SUBHEADINGS: - assert f"> **{h}**" in result, f"Missing: {h}" - assert "> ![[path/img.png]]" in result - assert "> [!note]- Figure 1:" in result - - def test_image_inside_callout_block(self): - """The image embed must be between the heading and the first sub-heading.""" - fig = FigureEntry( - number=2, title="Test2", image_id="fig2", - image_link="img/test.png", page=5, caption="Test2", - is_supplementary=False, - ) - result = render_figure_block(fig) - heading_end = result.index("> ![[img/test.png]]") - first_sub = result.index(f"> **{FIGURE_SUBHEADINGS[0]}**") - assert heading_end < first_sub, "Image must appear before first sub-heading" - - def test_no_placeholder_text(self): - """No '待补充' or '[?]' text in the rendered block.""" - fig = FigureEntry( - number=3, title="Test3", image_id="fig3", - image_link="img/fig3.png", page=0, caption="Test3", - is_supplementary=False, - ) - result = render_figure_block(fig) - assert "[?]" not in result - assert "待补充" not in result - - -class TestRenderTableBlock: - def test_all_subheadings_present(self): - table = TableEntry( - number=1, image_id="tab1", - image_link="path/table.png", page=4, - ) - result = render_table_block(table) - for h in TABLE_SUBHEADINGS: - assert f"> **{h}**" in result, f"Missing: {h}" - assert "> ![[path/table.png]]" in result - assert "> [!note]- Table 1" in result diff --git a/tests/test_path_normalization.py b/tests/test_path_normalization.py deleted file mode 100644 index cabf8934..00000000 --- a/tests/test_path_normalization.py +++ /dev/null @@ -1,385 +0,0 @@ -"""Tests for Zotero path normalization, main PDF identification, and wikilink generation. - -Phase 11 Wave 4 — Tests for: -- _normalize_attachment_path() (BBT path format normalization) -- _identify_main_pdf() (Main vs supplementary attachment selection) -- obsidian_wikilink_for_pdf() (Obsidian wikilink generation) - -All tests mock zotero_dir and vault_dir — no real Zotero installation required. -""" - -from __future__ import annotations - -import json -import sys -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent -if str(REPO_ROOT) not in sys.path: - sys.path.insert(0, str(REPO_ROOT)) - -from paperforge.worker.sync import ( - _identify_main_pdf, - _normalize_attachment_path, - load_export_rows, - obsidian_wikilink_for_pdf, -) - -# --------------------------------------------------------------------------- -# Test class: TestBBTPathNormalization -# --------------------------------------------------------------------------- - - -class TestBBTPathNormalization: - """Tests for _normalize_attachment_path() with various BBT export formats.""" - - def test_absolute_windows_path(self) -> None: - """Absolute Windows path pointing to Zotero storage is normalized to storage:KEY/filename.""" - raw = r"D:\L\Med\Research\99_System\Zotero\storage\ABC12345\paper.pdf" - normalized, bbt_raw, key = _normalize_attachment_path(raw) - - assert normalized == "storage:ABC12345/paper.pdf" - assert bbt_raw == raw - assert key == "ABC12345" - - def test_storage_prefix_path(self) -> None: - """Already-prefixed storage: path passes through with slash normalization.""" - raw = "storage:ABC12345/paper.pdf" - normalized, bbt_raw, key = _normalize_attachment_path(raw) - - assert normalized == "storage:ABC12345/paper.pdf" - assert bbt_raw == raw - assert key == "ABC12345" - - def test_bare_relative_path(self) -> None: - """Bare relative path KEY/filename gets storage: prefix prepended.""" - raw = "ABC12345/paper.pdf" - normalized, bbt_raw, key = _normalize_attachment_path(raw) - - assert normalized == "storage:ABC12345/paper.pdf" - assert bbt_raw == raw - assert key == "ABC12345" - - def test_path_with_chinese_characters(self) -> None: - """Chinese filenames are preserved without escaping or corruption.""" - raw = r"D:\L\Med\Research\99_System\Zotero\storage\ABC12345\中文论文.pdf" - normalized, bbt_raw, key = _normalize_attachment_path(raw) - - assert normalized == "storage:ABC12345/中文论文.pdf" - assert "中文论文" in normalized - assert key == "ABC12345" - - def test_path_with_spaces(self) -> None: - """Filenames containing spaces are handled correctly.""" - raw = r"D:\L\Med\Research\99_System\Zotero\storage\ABC12345\paper with spaces.pdf" - normalized, bbt_raw, key = _normalize_attachment_path(raw) - - assert normalized == "storage:ABC12345/paper with spaces.pdf" - assert key == "ABC12345" - - def test_storage_prefix_with_backslashes(self) -> None: - """storage: prefix with backslashes is normalized to forward slashes.""" - raw = r"storage:ABC12345\subdir\paper.pdf" - normalized, _, key = _normalize_attachment_path(raw) - - assert normalized == "storage:ABC12345/subdir/paper.pdf" - assert key == "ABC12345" - - def test_empty_path(self) -> None: - """Empty string returns empty tuple components.""" - normalized, bbt_raw, key = _normalize_attachment_path("") - - assert normalized == "" - assert bbt_raw == "" - assert key == "" - - def test_absolute_non_storage_path(self) -> None: - """Absolute path outside Zotero storage gets absolute: prefix.""" - raw = r"D:\Downloads\random.pdf" - normalized, bbt_raw, key = _normalize_attachment_path(raw) - - assert normalized.startswith("absolute:") - assert bbt_raw == raw - assert key == "" - - -# --------------------------------------------------------------------------- -# Test class: TestMainPdfIdentification -# --------------------------------------------------------------------------- - - -class TestMainPdfIdentification: - """Tests for _identify_main_pdf() hybrid priority strategy.""" - - def test_title_pdf_primary(self) -> None: - """Attachment with title exactly 'PDF' is selected as main.""" - attachments = [ - {"path": "storage:KEY/paper.pdf", "contentType": "application/pdf", "title": "PDF", "size": 1000}, - {"path": "storage:KEY/supp.pdf", "contentType": "application/pdf", "title": "Supplementary", "size": 2000}, - ] - main, supplementary = _identify_main_pdf(attachments) - - assert main is not None - assert main["title"] == "PDF" - assert len(supplementary) == 1 - assert supplementary[0]["title"] == "Supplementary" - - def test_fallback_largest_file(self) -> None: - """When no title=='PDF', largest file by size is selected as main.""" - attachments = [ - {"path": "storage:KEY/small.pdf", "contentType": "application/pdf", "title": "Small", "size": 100}, - {"path": "storage:KEY/large.pdf", "contentType": "application/pdf", "title": "Large", "size": 9999}, - ] - main, supplementary = _identify_main_pdf(attachments) - - assert main is not None - assert main["title"] == "Large" - assert len(supplementary) == 1 - - def test_fallback_first_pdf(self) -> None: - """When sizes are equal, first PDF in list is selected as main.""" - attachments = [ - {"path": "storage:KEY/first.pdf", "contentType": "application/pdf", "title": "First", "size": 100}, - {"path": "storage:KEY/second.pdf", "contentType": "application/pdf", "title": "Second", "size": 100}, - ] - main, supplementary = _identify_main_pdf(attachments) - - assert main is not None - assert main["title"] == "First" - assert len(supplementary) == 1 - - def test_no_pdf_attachments(self) -> None: - """No PDF attachments returns (None, []).""" - attachments = [ - { - "path": "storage:KEY/data.xlsx", - "contentType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "title": "Data", - "size": 100, - }, - ] - main, supplementary = _identify_main_pdf(attachments) - - assert main is None - assert supplementary == [] - - def test_single_pdf_no_supplementary(self) -> None: - """Single PDF attachment returns empty supplementary list.""" - attachments = [ - {"path": "storage:KEY/only.pdf", "contentType": "application/pdf", "title": "Only", "size": 1000}, - ] - main, supplementary = _identify_main_pdf(attachments) - - assert main is not None - assert supplementary == [] - - def test_mixed_pdf_and_non_pdf(self) -> None: - """Non-PDF attachments are ignored in main/supplementary selection.""" - attachments = [ - {"path": "storage:KEY/main.pdf", "contentType": "application/pdf", "title": "PDF", "size": 1000}, - {"path": "storage:KEY/supp.pdf", "contentType": "application/pdf", "title": "Supp", "size": 500}, - {"path": "storage:KEY/data.zip", "contentType": "application/zip", "title": "Data", "size": 200}, - ] - main, supplementary = _identify_main_pdf(attachments) - - assert main is not None - assert main["title"] == "PDF" - assert len(supplementary) == 1 - assert supplementary[0]["title"] == "Supp" - - -# --------------------------------------------------------------------------- -# Test class: TestWikilinkGeneration -# --------------------------------------------------------------------------- - - -class TestWikilinkGeneration: - """Tests for obsidian_wikilink_for_pdf() wikilink generation.""" - - def test_basic_wikilink(self, tmp_path: Path) -> None: - """storage:KEY/file.pdf resolves to [[system/Zotero/storage/KEY/file.pdf]].""" - vault_dir = tmp_path / "vault" - zotero_dir = vault_dir / "system" / "Zotero" - storage_dir = zotero_dir / "storage" / "KEY" - storage_dir.mkdir(parents=True) - pdf = storage_dir / "file.pdf" - pdf.write_text("PDF content") - - result = obsidian_wikilink_for_pdf("storage:KEY/file.pdf", vault_dir, zotero_dir) - - assert result == "[[system/Zotero/storage/KEY/file.pdf]]" - - def test_junction_resolution(self, tmp_path: Path, monkeypatch) -> None: - """Junction resolved before relative path computed.""" - vault_dir = tmp_path / "vault" - zotero_dir = vault_dir / "system" / "Zotero" - storage_dir = zotero_dir / "storage" / "KEY" - storage_dir.mkdir(parents=True) - pdf = storage_dir / "file.pdf" - pdf.write_text("PDF content") - - # Mock resolve_junction to simulate junction resolution - real_target = storage_dir / "file.pdf" - - def mock_resolve_junction(path: Path) -> Path: - if "junction" in str(path).lower(): - return real_target - return path - - monkeypatch.setattr("paperforge.pdf_resolver.resolve_junction", mock_resolve_junction) - - # Test with a path that would trigger junction resolution - result = obsidian_wikilink_for_pdf("storage:KEY/file.pdf", vault_dir, zotero_dir) - assert result.startswith("[[") - assert result.endswith("]]") - assert "/" in result - - def test_forward_slashes(self, tmp_path: Path) -> None: - """Output wikilink uses forward slashes, never backslashes.""" - vault_dir = tmp_path / "vault" - zotero_dir = vault_dir / "system" / "Zotero" - storage_dir = zotero_dir / "storage" / "KEY" - storage_dir.mkdir(parents=True) - pdf = storage_dir / "file.pdf" - pdf.write_text("PDF content") - - result = obsidian_wikilink_for_pdf("storage:KEY/file.pdf", vault_dir, zotero_dir) - - assert "\\" not in result - assert "/" in result - - def test_chinese_filename_wikilink(self, tmp_path: Path) -> None: - """Chinese characters preserved in wikilink without escaping.""" - vault_dir = tmp_path / "vault" - zotero_dir = vault_dir / "system" / "Zotero" - storage_dir = zotero_dir / "storage" / "KEY" - storage_dir.mkdir(parents=True) - pdf = storage_dir / "中文论文.pdf" - pdf.write_text("PDF content") - - result = obsidian_wikilink_for_pdf("storage:KEY/中文论文.pdf", vault_dir, zotero_dir) - - assert "中文论文" in result - assert result == "[[system/Zotero/storage/KEY/中文论文.pdf]]" - - def test_empty_pdf_path(self, tmp_path: Path) -> None: - """Empty pdf_path returns empty string.""" - vault_dir = tmp_path / "vault" - zotero_dir = vault_dir / "system" / "Zotero" - zotero_dir.mkdir(parents=True) - - result = obsidian_wikilink_for_pdf("", vault_dir, zotero_dir) - - assert result == "" - - def test_nonexistent_file(self, tmp_path: Path) -> None: - """Non-existent file still returns wikilink with relative path.""" - vault_dir = tmp_path / "vault" - zotero_dir = vault_dir / "system" / "Zotero" - zotero_dir.mkdir(parents=True) - - result = obsidian_wikilink_for_pdf("storage:KEY/missing.pdf", vault_dir, zotero_dir) - - # The file doesn't exist, but the path should still be converted to a wikilink - # Because the function resolves the path relative to vault - assert "[[" in result - assert "]]" in result - - -# --------------------------------------------------------------------------- -# Test class: TestLoadExportRowsIntegration -# --------------------------------------------------------------------------- - - -class TestLoadExportRowsIntegration: - """Integration tests using fixture JSON files.""" - - def test_load_absolute_fixture(self, tmp_path: Path) -> None: - """Load BBT export with absolute Windows paths — attachments normalized.""" - fixture_path = REPO_ROOT / "tests" / "fixtures" / "bbt_export_absolute.json" - export_file = tmp_path / "library.json" - export_file.write_text(fixture_path.read_text(encoding="utf-8"), encoding="utf-8") - - rows = load_export_rows(export_file) - - assert len(rows) == 1 - assert rows[0]["key"] == "ABC12345" - attachments = rows[0]["attachments"] - assert len(attachments) == 1 - assert attachments[0]["path"] == "storage:ABC12345/Absolute Path Test Paper.pdf" - assert attachments[0]["bbt_path_raw"].startswith("D:") - assert rows[0]["zotero_storage_key"] == "ABC12345" - - def test_load_storage_fixture(self, tmp_path: Path) -> None: - """Load BBT export with storage: prefix — paths pass through.""" - fixture_path = REPO_ROOT / "tests" / "fixtures" / "bbt_export_storage.json" - export_file = tmp_path / "library.json" - export_file.write_text(fixture_path.read_text(encoding="utf-8"), encoding="utf-8") - - rows = load_export_rows(export_file) - - assert len(rows) == 1 - assert rows[0]["key"] == "STORAGE1" - attachments = rows[0]["attachments"] - assert attachments[0]["path"] == "storage:STORAGE1/Storage Prefix Test Paper.pdf" - - def test_load_mixed_fixture(self, tmp_path: Path) -> None: - """Load BBT export with mixed formats — all normalized correctly.""" - fixture_path = REPO_ROOT / "tests" / "fixtures" / "bbt_export_mixed.json" - export_file = tmp_path / "library.json" - export_file.write_text(fixture_path.read_text(encoding="utf-8"), encoding="utf-8") - - rows = load_export_rows(export_file) - - assert len(rows) == 2 - - # First item: absolute Windows path - row0 = rows[0] - assert row0["key"] == "MIXED001" - assert row0["attachment_count"] == 3 - # Main PDF should be the one with title="PDF" - assert row0["pdf_path"] == "storage:MIXED001/Mixed Formats Paper.pdf" - # supplementary should contain the other PDF - assert len(row0["supplementary"]) == 1 - assert row0["supplementary"][0] == "storage:MIXED001/supplementary data.pdf" - - # Second item: bare relative path - row1 = rows[1] - assert row1["key"] == "BARE002" - assert row1["pdf_path"] == "storage:BARE002/BARE002.pdf" - assert row1["supplementary"] == [] - - def test_mixed_fixture_path_error_none(self, tmp_path: Path) -> None: - """Mixed fixture items with PDFs have no path_error.""" - fixture_path = REPO_ROOT / "tests" / "fixtures" / "bbt_export_mixed.json" - export_file = tmp_path / "library.json" - export_file.write_text(fixture_path.read_text(encoding="utf-8"), encoding="utf-8") - - rows = load_export_rows(export_file) - - for row in rows: - assert row["path_error"] == "" - - def test_no_attachments_path_error(self, tmp_path: Path) -> None: - """Item with no attachments gets path_error='not_found'.""" - export_data = { - "items": [ - { - "key": "NOATTACH", - "itemKey": "NOATTACH", - "itemType": "journalArticle", - "title": "No Attachments", - "attachments": [], - } - ], - "collections": {}, - } - export_file = tmp_path / "library.json" - export_file.write_text(json.dumps(export_data), encoding="utf-8") - - rows = load_export_rows(export_file) - - assert len(rows) == 1 - assert rows[0]["path_error"] == "not_found" - assert rows[0]["pdf_path"] == "" - assert rows[0]["attachment_count"] == 0 diff --git a/tests/unit/worker/test_vector_db.py b/tests/unit/worker/test_vector_db.py index e65039c5..51c197da 100644 --- a/tests/unit/worker/test_vector_db.py +++ b/tests/unit/worker/test_vector_db.py @@ -1,6 +1,5 @@ from __future__ import annotations -from pathlib import Path from unittest.mock import Mock, patch from paperforge.embedding.status import get_embed_status @@ -8,41 +7,35 @@ from paperforge.embedding.status import get_embed_status def test_get_embed_status_reports_corruption_when_count_fails(tmp_path): vault = tmp_path / "vault" - vectors_dir = vault / "System" / "PaperForge" / "vectors" + vectors_dir = vault / "System" / "PaperForge" / "indexes" / "vectors" vectors_dir.mkdir(parents=True) mock_collection = Mock() - mock_collection.name = "paperforge_fulltext" mock_collection.count.side_effect = RuntimeError("Error loading hnsw index") - mock_client = Mock() - mock_client.list_collections.return_value = [mock_collection] - with patch("chromadb.PersistentClient", return_value=mock_client): + with patch("paperforge.embedding.status.get_collection", return_value=mock_collection): status = get_embed_status(vault) assert status["db_exists"] is True assert status["chunk_count"] == 0 assert status["healthy"] is False - assert "hnsw index" in status["error"] + assert "hnsw" in status["error"].lower() def test_get_embed_status_uses_indexes_vectors_path_from_config(tmp_path): vault = tmp_path / "vault" vault.mkdir() (vault / "paperforge.json").write_text( - '{"vault_config":{"system_dir":"System","resources_dir":"Resources","literature_dir":"Literature","control_dir":"LiteratureControl","base_dir":"Bases","skill_dir":".opencode/skills"}}', + '{"vault_config":{"system_dir":"System"}}', encoding="utf-8", ) vectors_dir = vault / "System" / "PaperForge" / "indexes" / "vectors" vectors_dir.mkdir(parents=True) mock_collection = Mock() - mock_collection.name = "paperforge_fulltext" mock_collection.count.return_value = 12 - mock_client = Mock() - mock_client.list_collections.return_value = [mock_collection] - with patch("chromadb.PersistentClient", return_value=mock_client): + with patch("paperforge.embedding.status.get_collection", return_value=mock_collection): status = get_embed_status(vault) assert status["db_exists"] is True