Files

419 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import json
import unittest
from argparse import Namespace
from dataclasses import asdict
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
from twitter.emit_archive import (
archive_for_post,
quote_ref_for,
render_gaps,
)
from twitter.models import PostData, PostMetrics, ThreadData
from twitter.render import _post_block, render_spine
from twitter.tree import spine_quote_ids
from twitter.tw import (
_merge_existing_capture,
build_parser,
chase_spine_quotes,
cmd_refresh,
overlay_quote_of_ids,
)
def post(
post_id: str,
handle: str,
text: str,
reply_to_id: str | None,
quote_of_id: str | None = None,
) -> PostData:
return PostData(
post_id=post_id,
author=handle,
handle=handle,
text=text,
timestamp="2025-02-09 02:35:43",
media_urls=(),
reply_to_id=reply_to_id,
quote_of_id=quote_of_id,
metrics=PostMetrics(0, 0, 0, None),
)
class PostBlockQuoteRefTests(unittest.TestCase):
def test_quote_ref_url_and_wikilink_before_media(self) -> None:
quoted = post(
"1888416459132395792",
"AgileJebrim",
"Check your compiler.\n\nstep() isn’t necessary however. https://t.co/ianYUdKGcc",
None,
"1888409333182218691",
)
block = _post_block(
1,
quoted,
{"1888416459132395792": ("https://pbs.twimg.com/media/AAA.jpg",)},
None,
(
"https://x.com/iquilezles/status/1888409333182218691",
"archive/threads/iquilezles/2025-02-09-slug",
),
)
self.assertIn("https://t.co/ianYUdKGcc", block)
url_at = block.index(
"https://x.com/iquilezles/status/1888409333182218691"
)
wiki_at = block.index(
"[[archive/threads/iquilezles/2025-02-09-slug]]"
)
media_at = block.index("https://pbs.twimg.com/media/AAA.jpg")
self.assertLess(url_at, wiki_at)
self.assertLess(wiki_at, media_at)
def test_quote_ref_url_only_when_wiki_missing(self) -> None:
quoted = post("1", "AgileJebrim", "body https://t.co/ianYUdKGcc", None, "9")
block = _post_block(
1,
quoted,
None,
None,
("https://x.com/i/status/9", None),
)
self.assertIn("https://x.com/i/status/9\n", block)
self.assertNotIn("[[", block)
self.assertIn("https://t.co/ianYUdKGcc", block)
def test_no_quote_ref_leaves_body_unchanged(self) -> None:
quoted = post("1", "AgileJebrim", "body", None, "9")
block = _post_block(1, quoted, None)
self.assertNotIn("x.com", block)
class SpineQuoteIdTests(unittest.TestCase):
def test_spine_only_unique_order(self) -> None:
thread = ThreadData(
root_post_id="1",
posts=(
post("1", "A", "qt", None, "9"),
post("2", "B", "reply", "1"),
post("3", "A", "qt2", "2", "8"),
post("4", "C", "branch qt", "1", "7"),
),
source_url="https://x.com/i/status/1",
)
self.assertEqual(spine_quote_ids(thread, ["1", "2", "3"]), ["9", "8"])
def test_duplicate_quote_id_once(self) -> None:
thread = ThreadData(
root_post_id="1",
posts=(
post("1", "A", "a", None, "9"),
post("2", "A", "b", "1", "9"),
),
source_url="https://x.com/i/status/1",
)
self.assertEqual(spine_quote_ids(thread, ["1", "2"]), ["9"])
class RenderSpineQuoteTests(unittest.TestCase):
def test_render_spine_passes_quote_ref(self) -> None:
thread = ThreadData(
root_post_id="1",
posts=(post("1", "AgileJebrim", "Check your compiler.", None, "9"),),
source_url="https://x.com/i/status/1",
)
text = render_spine(
thread,
["1"],
[],
{},
"2026-08-26",
None,
{"1": ("https://x.com/iquilezles/status/9", "archive/threads/iquilezles/slug")},
)
self.assertIn("https://x.com/iquilezles/status/9", text)
self.assertIn("[[archive/threads/iquilezles/slug]]", text)
class MergeOmittedOpTests(unittest.TestCase):
def test_retains_op_when_fresh_omits_root(self) -> None:
existing = ThreadData(
root_post_id="1",
posts=(
post("1", "A", "op qt", None, "9"),
post("2", "B", "tip", "1"),
),
source_url="https://x.com/i/status/1",
)
fresh = ThreadData(
root_post_id="2",
posts=(post("2", "B", "tip-fresh", "1"),),
source_url="https://x.com/i/status/2",
)
merged, retained = _merge_existing_capture(fresh, existing, "2")
ids = {p.post_id: p for p in merged.posts}
self.assertIn("1", ids)
self.assertEqual(ids["1"].quote_of_id, "9")
self.assertEqual(ids["2"].text, "tip-fresh")
self.assertEqual(retained, ("1",))
class OverlayQuoteOfIdTests(unittest.TestCase):
def test_keeps_on_disk_when_fresh_empty(self) -> None:
previous = ThreadData(
root_post_id="1",
posts=(post("1", "A", "old", None, "9"),),
source_url="https://x.com/i/status/1",
)
fresh = ThreadData(
root_post_id="1",
posts=(post("1", "A", "new", None, None),),
source_url="https://x.com/i/status/1",
)
merged = overlay_quote_of_ids(fresh, previous)
self.assertEqual(merged.posts[0].text, "new")
self.assertEqual(merged.posts[0].quote_of_id, "9")
def test_fresh_nonempty_wins(self) -> None:
previous = ThreadData(
root_post_id="1",
posts=(post("1", "A", "old", None, "9"),),
source_url="https://x.com/i/status/1",
)
fresh = ThreadData(
root_post_id="1",
posts=(post("1", "A", "new", None, "8"),),
source_url="https://x.com/i/status/1",
)
merged = overlay_quote_of_ids(fresh, previous)
self.assertEqual(merged.posts[0].quote_of_id, "8")
def test_missing_previous_post_unchanged(self) -> None:
previous = ThreadData(
root_post_id="1",
posts=(post("1", "A", "old", None, "9"),),
source_url="https://x.com/i/status/1",
)
fresh = ThreadData(
root_post_id="2",
posts=(post("2", "A", "only-fresh", None, None),),
source_url="https://x.com/i/status/2",
)
merged = overlay_quote_of_ids(fresh, previous)
self.assertIsNone(merged.posts[0].quote_of_id)
def _write_thread(path: Path, thread: ThreadData) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(asdict(thread), indent=2) + "\n",
encoding="utf-8",
newline="\n",
)
class ArchiveForPostTests(unittest.TestCase):
def test_finds_post_in_thread_data(self) -> None:
with TemporaryDirectory() as tmp:
vault = Path(tmp)
assets = vault / "assets" / "threads" / "iquilezles" / "2025-02-09-slug"
notes = vault / "archive" / "threads" / "iquilezles" / "2025-02-09-slug"
notes.mkdir(parents=True)
thread = ThreadData(
root_post_id="9",
posts=(post("9", "iquilezles", "quoted root", None),),
source_url="https://x.com/iquilezles/status/9",
)
_write_thread(assets / "thread_data.json", thread)
found_assets, found_notes = archive_for_post(vault, "9")
self.assertEqual(found_assets, assets)
self.assertEqual(found_notes, notes)
url, wiki = quote_ref_for(vault, "9")
self.assertEqual(url, "https://x.com/iquilezles/status/9")
self.assertEqual(wiki, "archive/threads/iquilezles/2025-02-09-slug")
def test_block_id_when_not_op(self) -> None:
with TemporaryDirectory() as tmp:
vault = Path(tmp)
assets = vault / "assets" / "threads" / "H" / "slug"
notes = vault / "archive" / "threads" / "H" / "slug"
notes.mkdir(parents=True)
thread = ThreadData(
root_post_id="1",
posts=(
post("1", "H", "op", None),
post("2", "H", "child", "1"),
),
source_url="https://x.com/H/status/1",
)
_write_thread(assets / "thread_data.json", thread)
url, wiki = quote_ref_for(vault, "2")
self.assertEqual(url, "https://x.com/H/status/2")
self.assertEqual(wiki, "archive/threads/H/slug#^2")
def test_miss_uses_i_status(self) -> None:
with TemporaryDirectory() as tmp:
url, wiki = quote_ref_for(Path(tmp), "9")
self.assertEqual(url, "https://x.com/i/status/9")
self.assertIsNone(wiki)
class RenderGapsQuoteTests(unittest.TestCase):
def test_lists_missing_quoted_ids(self) -> None:
thread = ThreadData(
root_post_id="1",
posts=(post("1", "A", "qt", None, "9"),),
source_url="https://x.com/i/status/1",
)
text = render_gaps(
thread, ["1"], input_kind="tip", missing_quote_of=["9"]
)
self.assertIn("quote_of:\n9\n", text)
self.assertNotIn("quote_of:\n1\n", text)
def test_unset_when_none_missing(self) -> None:
thread = ThreadData(
root_post_id="1",
posts=(post("1", "A", "qt", None, "9"),),
source_url="https://x.com/i/status/1",
)
text = render_gaps(
thread, ["1"], input_kind="tip", missing_quote_of=[]
)
self.assertIn("quote_of: unset\n", text)
class ParserNoQuotesTests(unittest.TestCase):
def test_refresh_accepts_no_quotes(self) -> None:
args = build_parser().parse_args(
["refresh", "--id", "1", "--tip", "--no-quotes"]
)
self.assertTrue(args.no_quotes)
def test_refresh_default_chases(self) -> None:
args = build_parser().parse_args(["refresh", "--id", "1", "--tip"])
self.assertFalse(args.no_quotes)
class ChaseSpineQuotesTests(unittest.TestCase):
def test_inner_refresh_is_root_no_quotes(self) -> None:
thread = ThreadData(
root_post_id="1",
posts=(
post("1", "A", "qt", None, "9"),
post("2", "B", "reply", "1"),
post("4", "C", "branch qt", "1", "7"),
),
source_url="https://x.com/i/status/1",
)
calls: list[Namespace] = []
def fake_refresh(args: Namespace) -> int:
calls.append(args)
return 0
with (
patch("twitter.tw.locate", return_value=(None, None)),
patch("twitter.tw.check_frozen"),
patch("twitter.tw.cmd_refresh", fake_refresh),
):
chase_spine_quotes(thread, tip=True, tip_id="2")
self.assertEqual(len(calls), 1)
self.assertEqual(calls[0].id, "9")
self.assertFalse(calls[0].tip)
self.assertTrue(calls[0].no_quotes)
self.assertEqual(calls[0].branch, [])
def test_locate_hit_skips_refresh(self) -> None:
thread = ThreadData(
root_post_id="1",
posts=(post("1", "A", "qt", None, "9"),),
source_url="https://x.com/i/status/1",
)
calls: list[Namespace] = []
def fake_refresh(args: Namespace) -> int:
calls.append(args)
return 0
with (
patch(
"twitter.tw.locate",
return_value=(Path("assets"), Path("notes")),
),
patch("twitter.tw.cmd_refresh", fake_refresh),
):
chase_spine_quotes(thread, tip=True, tip_id="1")
self.assertEqual(calls, [])
def test_frozen_skips_refresh(self) -> None:
thread = ThreadData(
root_post_id="1",
posts=(post("1", "A", "qt", None, "9"),),
source_url="https://x.com/i/status/1",
)
calls: list[Namespace] = []
def fake_refresh(args: Namespace) -> int:
calls.append(args)
return 0
def fake_frozen(post_id: str) -> None:
raise SystemExit(f"frozen: skipped ({post_id})")
with (
patch("twitter.tw.locate", return_value=(None, None)),
patch("twitter.tw.check_frozen", fake_frozen),
patch("twitter.tw.cmd_refresh", fake_refresh),
):
chase_spine_quotes(thread, tip=True, tip_id="1")
self.assertEqual(calls, [])
def test_inner_failure_does_not_raise(self) -> None:
thread = ThreadData(
root_post_id="1",
posts=(post("1", "A", "qt", None, "9"),),
source_url="https://x.com/i/status/1",
)
def fake_refresh(args: Namespace) -> int:
raise SystemExit("gallery-dl failed")
with (
patch("twitter.tw.locate", return_value=(None, None)),
patch("twitter.tw.check_frozen"),
patch("twitter.tw.cmd_refresh", fake_refresh),
):
chase_spine_quotes(thread, tip=True, tip_id="1")
def test_cmd_refresh_no_quotes_skips_chase(self) -> None:
args = Namespace(
id="1",
branch=[],
tip=True,
slug=None,
preserve_existing=False,
attach=[],
allow_broken_walk=False,
retire_old_dir=False,
no_quotes=True,
)
with (
patch("twitter.tw.check_frozen"),
patch("twitter.tw.cmd_refetch", return_value=0),
patch("twitter.tw.cmd_graph", return_value=0),
patch("twitter.tw.cmd_emit", return_value=0),
patch("twitter.tw.chase_spine_quotes") as chase,
patch("twitter.tw.scratch_dir") as scratch,
patch("twitter.tw.locate", return_value=(None, None)),
):
scratch.return_value = Path("missing-scratch")
result = cmd_refresh(args)
self.assertEqual(result, 0)
chase.assert_not_called()