test: add unit tests and fixtures
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
224
tests/test_exporters.py
Normal file
224
tests/test_exporters.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""Unit tests for src/exporters/."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.exporters.markdown import MarkdownExporter, _yaml_escape, _format_timestamp
|
||||
from src.exporters.json_export import JSONExporter
|
||||
|
||||
|
||||
SAMPLE_CONV = {
|
||||
"id": "abc12345def67890",
|
||||
"title": "Test Conversation",
|
||||
"provider": "claude",
|
||||
"project": "my-project",
|
||||
"created_at": "2024-06-10T14:32:00Z",
|
||||
"updated_at": "2024-06-10T15:00:00Z",
|
||||
"message_count": 2,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Hello, how are you?",
|
||||
"content_type": "text",
|
||||
"timestamp": "2024-06-10T14:32:00Z",
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "I'm doing well, thank you! How can I help?",
|
||||
"content_type": "text",
|
||||
"timestamp": "2024-06-10T14:32:10Z",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
NO_PROJECT_CONV = {
|
||||
**SAMPLE_CONV,
|
||||
"id": "noproj12345",
|
||||
"project": None,
|
||||
"title": "No Project Chat",
|
||||
}
|
||||
|
||||
CODE_CONV = {
|
||||
**SAMPLE_CONV,
|
||||
"id": "code12345",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Here is some code:\n```python\nprint('hello')\n```",
|
||||
"content_type": "text",
|
||||
"timestamp": None,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class TestMarkdownFrontmatter:
|
||||
def test_yaml_frontmatter_present(self, tmp_path):
|
||||
exp = MarkdownExporter(tmp_path)
|
||||
path = exp.export(SAMPLE_CONV)
|
||||
content = path.read_text()
|
||||
assert content.startswith("---\n")
|
||||
assert "title: " in content
|
||||
assert "provider: claude" in content
|
||||
assert "conversation_id: abc12345def67890" in content
|
||||
assert "created_at: 2024-06-10T14:32:00Z" in content
|
||||
assert "exported_at: " in content
|
||||
assert "message_count: 2" in content
|
||||
assert "tags: [claude, my-project]" in content
|
||||
|
||||
def test_no_project_uses_no_project_label(self, tmp_path):
|
||||
exp = MarkdownExporter(tmp_path)
|
||||
path = exp.export(NO_PROJECT_CONV)
|
||||
content = path.read_text()
|
||||
assert "project: no-project" in content
|
||||
assert "tags: [claude]" in content
|
||||
|
||||
def test_metadata_table_present(self, tmp_path):
|
||||
exp = MarkdownExporter(tmp_path)
|
||||
path = exp.export(SAMPLE_CONV)
|
||||
content = path.read_text()
|
||||
assert "| Provider | Claude |" in content
|
||||
assert "| Project | my-project |" in content
|
||||
assert "| Date | 2024-06-10 |" in content
|
||||
assert "| Messages | 2 |" in content
|
||||
|
||||
def test_messages_rendered(self, tmp_path):
|
||||
exp = MarkdownExporter(tmp_path)
|
||||
path = exp.export(SAMPLE_CONV)
|
||||
content = path.read_text()
|
||||
assert "Hello, how are you?" in content
|
||||
assert "I'm doing well" in content
|
||||
assert "🧑 Human" in content
|
||||
assert "🤖 Assistant" in content
|
||||
|
||||
def test_code_fences_preserved(self, tmp_path):
|
||||
exp = MarkdownExporter(tmp_path)
|
||||
path = exp.export(CODE_CONV)
|
||||
content = path.read_text()
|
||||
assert "```python" in content
|
||||
assert "print('hello')" in content
|
||||
|
||||
|
||||
class TestMarkdownFilenameGeneration:
|
||||
def test_filename_format(self, tmp_path):
|
||||
exp = MarkdownExporter(tmp_path)
|
||||
path = exp.export(SAMPLE_CONV)
|
||||
assert path.name == "2024-06-10_test-conversation_abc12345.md"
|
||||
|
||||
def test_no_project_goes_to_no_project_dir(self, tmp_path):
|
||||
exp = MarkdownExporter(tmp_path)
|
||||
path = exp.export(NO_PROJECT_CONV)
|
||||
assert "no-project" in str(path)
|
||||
|
||||
def test_project_slug_in_path(self, tmp_path):
|
||||
exp = MarkdownExporter(tmp_path)
|
||||
path = exp.export(SAMPLE_CONV)
|
||||
assert "my-project" in str(path)
|
||||
|
||||
def test_year_in_path(self, tmp_path):
|
||||
exp = MarkdownExporter(tmp_path)
|
||||
path = exp.export(SAMPLE_CONV)
|
||||
assert "/2024/" in str(path)
|
||||
|
||||
def test_output_structure_provider_project(self, tmp_path):
|
||||
exp = MarkdownExporter(tmp_path, output_structure="provider/project")
|
||||
path = exp.export(SAMPLE_CONV)
|
||||
# Should NOT have year subdirectory
|
||||
parts = path.parts
|
||||
assert "2024" not in parts
|
||||
|
||||
|
||||
class TestMarkdownEmptyMessages:
|
||||
def test_empty_message_skipped(self, tmp_path, caplog):
|
||||
import logging
|
||||
conv = {
|
||||
**SAMPLE_CONV,
|
||||
"messages": [
|
||||
{"role": "user", "content": " ", "content_type": "text", "timestamp": None},
|
||||
{"role": "assistant", "content": "Real response", "content_type": "text", "timestamp": None},
|
||||
],
|
||||
}
|
||||
exp = MarkdownExporter(tmp_path)
|
||||
with caplog.at_level(logging.WARNING, logger="src.exporters.markdown"):
|
||||
path = exp.export(conv)
|
||||
content = path.read_text()
|
||||
assert "Real response" in content
|
||||
assert any("empty" in r.message.lower() for r in caplog.records)
|
||||
|
||||
|
||||
class TestMarkdownAtomicWrite:
|
||||
def test_permissions_600(self, tmp_path):
|
||||
exp = MarkdownExporter(tmp_path)
|
||||
path = exp.export(SAMPLE_CONV)
|
||||
mode = oct(os.stat(path).st_mode)[-3:]
|
||||
assert mode == "600"
|
||||
|
||||
def test_no_tmp_files_left(self, tmp_path):
|
||||
exp = MarkdownExporter(tmp_path)
|
||||
exp.export(SAMPLE_CONV)
|
||||
tmp_files = list(tmp_path.rglob("*.tmp"))
|
||||
assert tmp_files == []
|
||||
|
||||
|
||||
class TestJSONExporter:
|
||||
def test_produces_valid_json(self, tmp_path):
|
||||
exp = JSONExporter(tmp_path)
|
||||
path = exp.export(SAMPLE_CONV)
|
||||
data = json.loads(path.read_text())
|
||||
assert data["id"] == "abc12345def67890"
|
||||
assert data["title"] == "Test Conversation"
|
||||
assert len(data["messages"]) == 2
|
||||
|
||||
def test_includes_exported_at(self, tmp_path):
|
||||
exp = JSONExporter(tmp_path)
|
||||
path = exp.export(SAMPLE_CONV)
|
||||
data = json.loads(path.read_text())
|
||||
assert "exported_at" in data
|
||||
|
||||
def test_permissions_600(self, tmp_path):
|
||||
exp = JSONExporter(tmp_path)
|
||||
path = exp.export(SAMPLE_CONV)
|
||||
mode = oct(os.stat(path).st_mode)[-3:]
|
||||
assert mode == "600"
|
||||
|
||||
def test_json_extension(self, tmp_path):
|
||||
exp = JSONExporter(tmp_path)
|
||||
path = exp.export(SAMPLE_CONV)
|
||||
assert path.suffix == ".json"
|
||||
|
||||
def test_pretty_printed(self, tmp_path):
|
||||
exp = JSONExporter(tmp_path)
|
||||
path = exp.export(SAMPLE_CONV)
|
||||
raw = path.read_text()
|
||||
# Pretty-printed JSON has newlines and indentation
|
||||
assert "\n" in raw
|
||||
assert " " in raw
|
||||
|
||||
|
||||
class TestYamlEscape:
|
||||
def test_escapes_double_quotes(self):
|
||||
assert _yaml_escape('Say "hello"') == 'Say \\"hello\\"'
|
||||
|
||||
def test_escapes_backslash(self):
|
||||
assert _yaml_escape("path\\to\\file") == "path\\\\to\\\\file"
|
||||
|
||||
def test_no_change_for_plain_string(self):
|
||||
assert _yaml_escape("Hello World") == "Hello World"
|
||||
|
||||
|
||||
class TestFormatTimestamp:
|
||||
def test_strips_fractional_seconds(self):
|
||||
result = _format_timestamp("2024-06-10T14:32:00.123456Z")
|
||||
assert "." not in result
|
||||
|
||||
def test_replaces_T_with_space(self):
|
||||
result = _format_timestamp("2024-06-10T14:32:00Z")
|
||||
assert "T" not in result
|
||||
assert "2024-06-10 14:32:00" == result
|
||||
|
||||
def test_empty_string(self):
|
||||
assert _format_timestamp("") == ""
|
||||
Reference in New Issue
Block a user