Add sync engine, web UI, Docker setup, and tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
182
tests/test_phase_g_history.py
Normal file
182
tests/test_phase_g_history.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
Phase G — Sync History Tests
|
||||
|
||||
Tests for GET /history: rendering _sync_log.md as a reverse-chronological table.
|
||||
"""
|
||||
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SAMPLE_LOG = textwrap.dedent("""\
|
||||
# Sync Log
|
||||
|
||||
| Timestamp | Direction | Files | Status |
|
||||
|-----------|-----------|-------|--------|
|
||||
| 2026-03-03 22:15 | push | 1 updated | error: CV.md failed |
|
||||
| 2026-03-04 08:00 | pull | 0 changes | ok |
|
||||
| 2026-03-05 09:10 | push | 2 updated, 1 created | ok |
|
||||
| 2026-03-06 14:32 | pull | 3 updated | ok |
|
||||
""")
|
||||
|
||||
MINIMAL_LOG = textwrap.dedent("""\
|
||||
# Sync Log
|
||||
|
||||
| Timestamp | Direction | Files | Status |
|
||||
|-----------|-----------|-------|--------|
|
||||
| 2026-01-01 00:00 | pull | 1 updated | ok |
|
||||
""")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# US-G1 — History page renders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHistoryPage:
|
||||
|
||||
async def test_history_returns_200(self, client):
|
||||
r = await client.get("/history")
|
||||
assert r.status_code == 200
|
||||
|
||||
async def test_history_returns_html(self, client):
|
||||
r = await client.get("/history")
|
||||
assert "text/html" in r.headers.get("content-type", "")
|
||||
|
||||
async def test_history_page_contains_table(self, client, vault_dir, sync_log):
|
||||
r = await client.get("/history")
|
||||
body = r.text.lower()
|
||||
assert "<table" in body, "History page must render an HTML table"
|
||||
|
||||
async def test_history_shows_direction_labels(self, client, vault_dir):
|
||||
(vault_dir / "_sync_log.md").write_text(SAMPLE_LOG)
|
||||
r = await client.get("/history")
|
||||
body = r.text.lower()
|
||||
assert "pull" in body, "History must show pull entries"
|
||||
assert "push" in body, "History must show push entries"
|
||||
|
||||
async def test_history_shows_timestamps(self, client, vault_dir):
|
||||
(vault_dir / "_sync_log.md").write_text(SAMPLE_LOG)
|
||||
r = await client.get("/history")
|
||||
body = r.text
|
||||
assert "2026-03-06" in body, "History must show timestamps from _sync_log.md"
|
||||
|
||||
async def test_history_shows_file_counts(self, client, vault_dir):
|
||||
(vault_dir / "_sync_log.md").write_text(SAMPLE_LOG)
|
||||
r = await client.get("/history")
|
||||
body = r.text.lower()
|
||||
assert "updated" in body or "created" in body, (
|
||||
"History must show file change counts"
|
||||
)
|
||||
|
||||
async def test_history_shows_status(self, client, vault_dir):
|
||||
(vault_dir / "_sync_log.md").write_text(SAMPLE_LOG)
|
||||
r = await client.get("/history")
|
||||
body = r.text.lower()
|
||||
assert "ok" in body or "error" in body, "History must show entry status"
|
||||
|
||||
async def test_history_empty_when_no_log(self, client, vault_dir):
|
||||
"""If _sync_log.md does not exist, page should render gracefully (no 500)."""
|
||||
log_path = vault_dir / "_sync_log.md"
|
||||
if log_path.exists():
|
||||
log_path.unlink()
|
||||
|
||||
r = await client.get("/history")
|
||||
assert r.status_code == 200, "History page must not crash when log is missing"
|
||||
|
||||
async def test_history_empty_state_message(self, client, vault_dir):
|
||||
"""Empty history should show a helpful message, not a blank page."""
|
||||
log_path = vault_dir / "_sync_log.md"
|
||||
if log_path.exists():
|
||||
log_path.unlink()
|
||||
|
||||
r = await client.get("/history")
|
||||
body = r.text.lower()
|
||||
assert any(phrase in body for phrase in (
|
||||
"no history", "no sync", "empty", "no entries", "nothing yet"
|
||||
)), "Empty history must show a message"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# US-G2 — _sync_log.md parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSyncLogParsing:
|
||||
|
||||
async def test_entries_shown_in_reverse_chronological_order(
|
||||
self, client, vault_dir
|
||||
):
|
||||
"""Most recent entry must appear before older entries in the HTML."""
|
||||
(vault_dir / "_sync_log.md").write_text(SAMPLE_LOG)
|
||||
r = await client.get("/history")
|
||||
body = r.text
|
||||
|
||||
pos_newest = body.find("2026-03-06")
|
||||
pos_oldest = body.find("2026-03-03")
|
||||
|
||||
assert pos_newest != -1, "Most recent entry must appear in history"
|
||||
assert pos_oldest != -1, "Oldest entry must appear in history"
|
||||
assert pos_newest < pos_oldest, (
|
||||
"Most recent entry (2026-03-06) must appear before oldest (2026-03-03)"
|
||||
)
|
||||
|
||||
async def test_error_entries_visually_distinct(self, client, vault_dir):
|
||||
"""Entries with non-ok status should be highlighted differently."""
|
||||
(vault_dir / "_sync_log.md").write_text(SAMPLE_LOG)
|
||||
r = await client.get("/history")
|
||||
body = r.text.lower()
|
||||
# Error entry from 2026-03-03 should have visual distinction
|
||||
# This is checked loosely: error word near some CSS class or color
|
||||
assert "error" in body, "Error entries must be shown in history"
|
||||
|
||||
async def test_raw_markdown_not_shown_as_pipe_table(self, client, vault_dir):
|
||||
"""The raw markdown pipe-table syntax must not be visible in rendered output."""
|
||||
(vault_dir / "_sync_log.md").write_text(SAMPLE_LOG)
|
||||
r = await client.get("/history")
|
||||
body = r.text
|
||||
# Pipe characters from the markdown table should NOT appear verbatim
|
||||
# (they should be parsed and rendered as HTML <table>)
|
||||
raw_table_lines = [l for l in body.splitlines() if l.strip().startswith("|---")]
|
||||
assert len(raw_table_lines) == 0, (
|
||||
"Raw markdown table separator lines must not appear in rendered HTML"
|
||||
)
|
||||
|
||||
async def test_all_log_entries_appear(self, client, vault_dir):
|
||||
"""All 4 entries in SAMPLE_LOG must appear in the rendered history."""
|
||||
(vault_dir / "_sync_log.md").write_text(SAMPLE_LOG)
|
||||
r = await client.get("/history")
|
||||
body = r.text
|
||||
|
||||
assert "2026-03-06" in body
|
||||
assert "2026-03-05" in body
|
||||
assert "2026-03-04" in body
|
||||
assert "2026-03-03" in body
|
||||
|
||||
async def test_single_entry_log_renders(self, client, vault_dir):
|
||||
(vault_dir / "_sync_log.md").write_text(MINIMAL_LOG)
|
||||
r = await client.get("/history")
|
||||
assert r.status_code == 200
|
||||
assert "2026-01-01" in r.text
|
||||
|
||||
async def test_history_api_endpoint_returns_json(self, client, vault_dir):
|
||||
"""
|
||||
GET /history?format=json returns structured history data.
|
||||
This is optional but strongly recommended for future HTMX updates.
|
||||
"""
|
||||
(vault_dir / "_sync_log.md").write_text(SAMPLE_LOG)
|
||||
r = await client.get("/history?format=json")
|
||||
# If not implemented, 200 HTML is also acceptable
|
||||
if r.status_code == 200 and "application/json" in r.headers.get("content-type", ""):
|
||||
data = r.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) >= 4
|
||||
for entry in data:
|
||||
assert "timestamp" in entry or "date" in entry
|
||||
assert "direction" in entry
|
||||
Reference in New Issue
Block a user