mirror of
https://github.com/lllin000/PaperForge.git
synced 2026-07-22 17:00:23 +00:00
385 lines
15 KiB
Python
385 lines
15 KiB
Python
"""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
|