From 3805685d8fb4d6a70745a71f47d18c7025d992aa Mon Sep 17 00:00:00 2001 From: Ed_ Date: Mon, 24 Aug 2026 15:45:13 -0400 Subject: [PATCH] another test of refresh with diff op. --- ...-why-not-just-1k-https-strlen-com-false.md | 35 --- .../index.md | 34 --- archive/threads/NOTimothyLottes/index.md | 1 - archive/threads/index.md | 1 + .../index.md | 46 ++++ archive/threads/kenpex/index.md | 12 + scripts/twitter/emit_archive.py | 13 +- scripts/twitter/models.py | 211 ++++++++++++++++++ scripts/twitter/tests/test_emit_op_owned.py | 177 +++++++++++++++ scripts/twitter/tests/test_models_boundary.py | 183 +++++++++++++++ 10 files changed, 640 insertions(+), 73 deletions(-) delete mode 100644 archive/threads/NOTimothyLottes/2023-04-26-can-make-a-custom-forth-to-binary-in-a-few-k/2023-04-26-wvo-a-few-k-why-not-just-1k-https-strlen-com-false.md delete mode 100644 archive/threads/NOTimothyLottes/2023-04-26-can-make-a-custom-forth-to-binary-in-a-few-k/index.md create mode 100644 archive/threads/kenpex/2023-04-25-nerds-are-crazy-https-git-sr-ht-vdupras-duskos/index.md create mode 100644 archive/threads/kenpex/index.md create mode 100644 scripts/twitter/tests/test_models_boundary.py diff --git a/archive/threads/NOTimothyLottes/2023-04-26-can-make-a-custom-forth-to-binary-in-a-few-k/2023-04-26-wvo-a-few-k-why-not-just-1k-https-strlen-com-false.md b/archive/threads/NOTimothyLottes/2023-04-26-can-make-a-custom-forth-to-binary-in-a-few-k/2023-04-26-wvo-a-few-k-why-not-just-1k-https-strlen-com-false.md deleted file mode 100644 index d6f567b..0000000 --- a/archive/threads/NOTimothyLottes/2023-04-26-can-make-a-custom-forth-to-binary-in-a-few-k/2023-04-26-wvo-a-few-k-why-not-just-1k-https-strlen-com-false.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -title: "@NOTimothyLottes @kenpex @wadetb A few K? Why not just 1K? https://strlen.com/false-language/" -type: archive -source: twitter -source_url: "https://x.com/wvo/status/1651282559287042048" -author: "Wouter van Oortmerssen" -handle: wvo -post_id: "1651282559287042048" -date: 2023-04-26 -archived: 2026-08-24 -draft: false -tags: - - archive - - twitter - - NOTimothyLottes -description: "@NOTimothyLottes @kenpex @wadetb A few K? Why not just 1K? https://strlen.com/false-language/" -in_reply_to: "" -parent_post_id: "1651268028795961344" ---- - -## Source - -- URL: https://x.com/wvo/status/1651282559287042048 -- Author: Wouter van Oortmerssen (@wvo) -- Posted: 2023-04-26 17:50:16 - -## Branch - -**1/** - -@NOTimothyLottes @kenpex @wadetb A few K? Why not just 1K? https://strlen.com/false-language/ - -## Related - -- Spine: [[archive/threads/NOTimothyLottes/2023-04-26-can-make-a-custom-forth-to-binary-in-a-few-k]] diff --git a/archive/threads/NOTimothyLottes/2023-04-26-can-make-a-custom-forth-to-binary-in-a-few-k/index.md b/archive/threads/NOTimothyLottes/2023-04-26-can-make-a-custom-forth-to-binary-in-a-few-k/index.md deleted file mode 100644 index 139e050..0000000 --- a/archive/threads/NOTimothyLottes/2023-04-26-can-make-a-custom-forth-to-binary-in-a-few-k/index.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -title: "@kenpex @wadetb Can make a custom forth to binary in a few K." -type: archive -source: twitter -source_url: "https://x.com/NOTimothyLottes/status/1651268028795961344" -author: "NOTimothyLottes" -handle: NOTimothyLottes -post_id: "1651268028795961344" -date: 2023-04-26 -archived: 2026-08-24 -draft: true -tags: - - archive - - twitter - - NOTimothyLottes -description: "@kenpex @wadetb Can make a custom forth to binary in a few K." -in_reply_to: "1651253961524142081" ---- - -## Source - -- URL: https://x.com/NOTimothyLottes/status/1651268028795961344 -- Author: NOTimothyLottes (@NOTimothyLottes) -- Posted: 2023-04-26 16:52:31 - -> @wadetb Yes of course. Forth is a language that took me a while to grok (re:the why it makes sense) - but once I understood the context it totally does. @NOTimothyLottes experiments w/it are also fun. - -## Thread - -**1/** - -@kenpex @wadetb Can make a custom forth to binary in a few K. Bring up a runtime assembler in the language and do anything fast and simple from that point, load libraries, make system/dll C calls. 100,000x complexity reduction in compiler toolchain size. - -Branches: [[archive/threads/NOTimothyLottes/2023-04-26-can-make-a-custom-forth-to-binary-in-a-few-k/2023-04-26-wvo-a-few-k-why-not-just-1k-https-strlen-com-false]] diff --git a/archive/threads/NOTimothyLottes/index.md b/archive/threads/NOTimothyLottes/index.md index 32449a5..b6b1a43 100644 --- a/archive/threads/NOTimothyLottes/index.md +++ b/archive/threads/NOTimothyLottes/index.md @@ -14,7 +14,6 @@ tags: - [[archive/threads/NOTimothyLottes/2022-11-08-example-of-bind-everything-once-layout-aliasing]] - [[archive/threads/NOTimothyLottes/2022-11-09-if-only-pc-vendors-with-open-isas-amd-intel-would]] - [[archive/threads/NOTimothyLottes/2022-11-13-my-prefered-method-of-managing-application-data]] -- [[archive/threads/NOTimothyLottes/2023-04-26-can-make-a-custom-forth-to-binary-in-a-few-k]] - [[archive/threads/NOTimothyLottes/2023-06-30-nothing-oriented-programming-nop-where-the-goal]] - [[archive/threads/NOTimothyLottes/2023-08-03-spc-using-8-deep-swap-all-aliasing-same-bo-image]] - [[archive/threads/NOTimothyLottes/2023-08-04-for-anyone-tuning-in-now-spc-is-kind-of-like-a]] diff --git a/archive/threads/index.md b/archive/threads/index.md index 96ade6a..3f4eb2b 100644 --- a/archive/threads/index.md +++ b/archive/threads/index.md @@ -16,5 +16,6 @@ Example of the note shape (single spine, no branches): [[archive/threads/How to - [[archive/threads/NOTimothyLottes]] - [[archive/threads/VPCOMPRESSB]] - [[archive/threads/VictorTaelin]] +- [[archive/threads/kenpex]] - [[archive/threads/rianflo]] - [[archive/threads/winning_tactic]] diff --git a/archive/threads/kenpex/2023-04-25-nerds-are-crazy-https-git-sr-ht-vdupras-duskos/index.md b/archive/threads/kenpex/2023-04-25-nerds-are-crazy-https-git-sr-ht-vdupras-duskos/index.md new file mode 100644 index 0000000..5fe8a25 --- /dev/null +++ b/archive/threads/kenpex/2023-04-25-nerds-are-crazy-https-git-sr-ht-vdupras-duskos/index.md @@ -0,0 +1,46 @@ +--- +title: "Nerds are crazy https://git.sr.ht/~vdupras/duskos" +type: archive +source: twitter +source_url: "https://x.com/kenpex/status/1650678968255913985" +author: "c0de517e/AngeloPesce" +handle: kenpex +post_id: "1650678968255913985" +date: 2023-04-25 +archived: 2026-08-24 +draft: false +tags: + - archive + - twitter + - kenpex +description: "Nerds are crazy https://git.sr.ht/~vdupras/duskos" +in_reply_to: "" +--- + +## Source + +- URL: https://x.com/kenpex/status/1650678968255913985 +- Author: c0de517e/AngeloPesce (@kenpex) +- Posted: 2023-04-25 01:51:48 + +## Thread + +**1/** + +Nerds are crazy https://git.sr.ht/~vdupras/duskos + +**2/** + +@kenpex I find the context hilarious, but Forth as asm-level-yet-very-usable-mini-OS has been done, as the boot firmware for OLPC (http://laptop.org) for many years. Looks like it's currently open sourced as https://github.com/openbios/openbios. + +**3/** + +@wadetb Yes of course. Forth is a language that took me a while to grok (re:the why it makes sense) - but once I understood the context it totally does. @NOTimothyLottes experiments w/it are also fun. + +**4/** + +@kenpex @wadetb Can make a custom forth to binary in a few K. Bring up a runtime assembler in the language and do anything fast and simple from that point, load libraries, make system/dll C calls. 100,000x complexity reduction in compiler toolchain size. + +**5/** + +@NOTimothyLottes @kenpex @wadetb A few K? Why not just 1K? https://strlen.com/false-language/ diff --git a/archive/threads/kenpex/index.md b/archive/threads/kenpex/index.md new file mode 100644 index 0000000..e9abb1f --- /dev/null +++ b/archive/threads/kenpex/index.md @@ -0,0 +1,12 @@ +--- +title: kenpex +type: note +draft: false +description: Archived threads by kenpex. +tags: + - archive + - twitter + - kenpex +--- + +- [[archive/threads/kenpex/2023-04-25-nerds-are-crazy-https-git-sr-ht-vdupras-duskos]] diff --git a/scripts/twitter/emit_archive.py b/scripts/twitter/emit_archive.py index e1ef75b..6bc2e63 100644 --- a/scripts/twitter/emit_archive.py +++ b/scripts/twitter/emit_archive.py @@ -544,11 +544,18 @@ def emit( if reuse_dir is None and force: _existing_ids, spines = collect_existing_ids(vault) - reuse_dir = spines.get(thread.root_post_id) + # Only accept a candidate whose parent handle matches the + # OP handle. A thread previously archived at a different + # author's dir (e.g. an old tip-as-root archive of a cross- + # author conversation) is treated as orphan, not a reuse. + candidate = spines.get(thread.root_post_id) + if candidate is not None and candidate.parent.name == handle: + reuse_dir = candidate if reuse_dir is None: for post in thread.posts: - reuse_dir = spines.get(post.post_id) - if reuse_dir is not None: + candidate = spines.get(post.post_id) + if candidate is not None and candidate.parent.name == handle: + reuse_dir = candidate break if reuse_dir is None and slug: candidate = vault / "archive" / "threads" / handle / slug diff --git a/scripts/twitter/models.py b/scripts/twitter/models.py index cafdaea..16bec5c 100644 --- a/scripts/twitter/models.py +++ b/scripts/twitter/models.py @@ -2,11 +2,13 @@ from __future__ import annotations from dataclasses import dataclass from pathlib import Path +from typing import Any, Self import json @dataclass(frozen=True) class PostMetrics: + """Post engagement counters (replies / reposts / likes / views).""" reply_count: int repost_count: int like_count: int @@ -15,6 +17,7 @@ class PostMetrics: @dataclass(frozen=True) class PostData: + """A single tweet post extracted from a Twitter archive.""" post_id: str author: str handle: str @@ -28,11 +31,149 @@ class PostData: @dataclass(frozen=True) class ThreadData: + """A complete Twitter thread (root post + replies).""" root_post_id: str posts: tuple[PostData, ...] source_url: str +@dataclass(frozen=True) +class MediaLocation: + """A single media location record (local / remote / fallback / mirror / derived).""" + kind: str + local_path: Path | None = None + url: str | None = None + bytes: int | None = None + sha256: str | None = None + checked_at: str | None = None + checked_status: int | None = None + + @classmethod + def from_dict(cls, raw: dict[str, Any]) -> Self: + if "kind" not in raw: + raise KeyError("MediaLocation.from_dict: 'kind' is required") + return cls( + kind=_required_str(raw["kind"], "kind"), + local_path=_optional_path(raw.get("local_path"), "local_path"), + url=_optional_str(raw.get("url"), "url"), + bytes=_optional_int(raw.get("bytes"), "bytes"), + sha256=_optional_str(raw.get("sha256"), "sha256"), + checked_at=_optional_str(raw.get("checked_at"), "checked_at"), + checked_status=_optional_int(raw.get("checked_status"), "checked_status"), + ) + + +@dataclass(frozen=True) +class MediaItem: + """A single media entry attached to a post (image / crt / crt_outline / denoise / ocr).""" + post_id: str + media_id: str + kind: str + locations: tuple[MediaLocation, ...] + embed: bool | None = None + caption: str | None = None + + @classmethod + def from_dict(cls, raw: dict[str, Any]) -> Self: + for required in ("post_id", "media_id", "kind", "locations"): + if required not in raw: + raise KeyError(f"MediaItem.from_dict: '{required}' is required") + locations_raw = raw["locations"] + if not isinstance(locations_raw, list): + raise TypeError( + f"MediaItem.from_dict: 'locations' must be list, got {type(locations_raw).__name__}" + ) + return cls( + post_id=_required_str(raw["post_id"], "post_id"), + media_id=_required_str(raw["media_id"], "media_id"), + kind=_required_str(raw["kind"], "kind"), + locations=tuple(MediaLocation.from_dict(loc) for loc in locations_raw), + embed=_optional_bool(raw.get("embed"), "embed"), + caption=_optional_str(raw.get("caption"), "caption"), + ) + + +@dataclass(frozen=True) +class MediaManifest: + """A whole media.json document (one thread).""" + root_post_id: str + items: tuple[MediaItem, ...] + captured_at: str | None = None + + @classmethod + def from_dict(cls, raw: dict[str, Any]) -> Self: + for required in ("root_post_id", "items"): + if required not in raw: + raise KeyError(f"MediaManifest.from_dict: '{required}' is required") + items_raw = raw["items"] + if not isinstance(items_raw, list): + raise TypeError( + f"MediaManifest.from_dict: 'items' must be list, got {type(items_raw).__name__}" + ) + return cls( + root_post_id=_required_str(raw["root_post_id"], "root_post_id"), + items=tuple(MediaItem.from_dict(item) for item in items_raw), + captured_at=_optional_str(raw.get("captured_at"), "captured_at"), + ) + + +@dataclass(frozen=True) +class LegacyThreadData: + """Pre-v2 thread_data.json shape paired with media items during migration.""" + root_post_id: str + posts: tuple[PostData, ...] + source_url: str + media_items: tuple[MediaItem, ...] = () + + @classmethod + def from_dict(cls, raw: dict[str, Any]) -> Self: + for required in ("root_post_id", "posts", "source_url"): + if required not in raw: + raise KeyError(f"LegacyThreadData.from_dict: '{required}' is required") + posts_raw = raw["posts"] + if not isinstance(posts_raw, list): + raise TypeError( + f"LegacyThreadData.from_dict: 'posts' must be list, got {type(posts_raw).__name__}" + ) + media_items_raw = raw.get("media_items") + media_items: tuple[MediaItem, ...] = () + if media_items_raw is not None: + if not isinstance(media_items_raw, list): + raise TypeError( + f"LegacyThreadData.from_dict: 'media_items' must be list, got {type(media_items_raw).__name__}" + ) + media_items = tuple(MediaItem.from_dict(item) for item in media_items_raw) + return cls( + root_post_id=_required_str(raw["root_post_id"], "root_post_id"), + posts=tuple(_parse_post(p) for p in posts_raw), + source_url=_required_str(raw["source_url"], "source_url"), + media_items=media_items, + ) + + +@dataclass(frozen=True) +class LegacyManifest: + """Pre-v2 corpus inventory shape used during media migration.""" + threads: tuple[LegacyThreadData, ...] + schema_version: int | None = None + generated_at: str | None = None + + @classmethod + def from_dict(cls, raw: dict[str, Any]) -> Self: + if "threads" not in raw: + raise KeyError("LegacyManifest.from_dict: 'threads' is required") + threads_raw = raw["threads"] + if not isinstance(threads_raw, list): + raise TypeError( + f"LegacyManifest.from_dict: 'threads' must be list, got {type(threads_raw).__name__}" + ) + return cls( + threads=tuple(LegacyThreadData.from_dict(t) for t in threads_raw), + schema_version=_optional_int(raw.get("schema_version"), "schema_version"), + generated_at=_optional_str(raw.get("generated_at"), "generated_at"), + ) + + def _norm_handle(raw: str) -> str: h = (raw or "").strip() return h[1:] if h.startswith("@") else h @@ -45,6 +186,76 @@ def _norm_id(raw: object) -> str | None: return s or None +def _required_str(value: object, name: str) -> str: + if not isinstance(value, str): + raise TypeError(f"{name}: expected str, got {type(value).__name__}") + return value + + +def _optional_str(value: object, field: str) -> str | None: + if value is None: + return None + if not isinstance(value, str): + raise TypeError(f"{field}: expected str, got {type(value).__name__}") + return value + + +def _optional_int(value: object, field: str) -> int | None: + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError(f"{field}: expected int, got {type(value).__name__}") + return value + + +def _optional_bool(value: object, field: str) -> bool | None: + if value is None: + return None + if not isinstance(value, bool): + raise TypeError(f"{field}: expected bool, got {type(value).__name__}") + return value + + +def _optional_path(value: object, field: str) -> Path | None: + if value is None: + return None + if isinstance(value, Path): + return value + if isinstance(value, str): + return Path(value) + raise TypeError(f"{field}: expected str or Path, got {type(value).__name__}") + + +def _parse_post(raw: object) -> PostData: + if not isinstance(raw, dict): + raise TypeError(f"post entry: expected dict, got {type(raw).__name__}") + m = raw.get("metrics") or {} + if not isinstance(m, dict): + raise TypeError(f"post entry metrics: expected dict, got {type(m).__name__}") + view = m.get("view_count") + media_urls_raw = raw.get("media_urls") or [] + if not isinstance(media_urls_raw, list): + raise TypeError( + f"post entry media_urls: expected list, got {type(media_urls_raw).__name__}" + ) + return PostData( + post_id=str(raw.get("post_id") or ""), + author=str(raw.get("author") or ""), + handle=_norm_handle(str(raw.get("handle") or "")), + text=str(raw.get("text") or ""), + timestamp=str(raw.get("timestamp") or ""), + media_urls=tuple(str(u) for u in media_urls_raw), + reply_to_id=_norm_id(raw.get("reply_to_id")), + quote_of_id=_norm_id(raw.get("quote_of_id")), + metrics=PostMetrics( + reply_count=int(m.get("reply_count") or 0), + repost_count=int(m.get("repost_count") or 0), + like_count=int(m.get("like_count") or 0), + view_count=int(view) if view is not None else None, + ), + ) + + def load_thread(path: Path) -> ThreadData: data = json.loads(path.read_text(encoding="utf-8")) posts: list[PostData] = [] diff --git a/scripts/twitter/tests/test_emit_op_owned.py b/scripts/twitter/tests/test_emit_op_owned.py index b0e27bc..c73b3b4 100644 --- a/scripts/twitter/tests/test_emit_op_owned.py +++ b/scripts/twitter/tests/test_emit_op_owned.py @@ -339,5 +339,182 @@ class OpOwnedEmitTipModeTests(unittest.TestCase): self.assertEqual(len(thread_data["posts"]), 7) +class OrphansNotReusedTests(unittest.TestCase): + """An old archive keyed by a non-OP handle must not be reused. + A pre-existing tip-as-root archive of a cross-author conversation + is treated as orphan; the OP-owned emit produces a fresh dir at + the OP handle.""" + + def setUp(self) -> None: + self.tmp = Path(tempfile.mkdtemp(prefix="op_owned_orphan_")) + self.input_dir = self.tmp / "input" + self.input_dir.mkdir() + self.vault = self.tmp / "vault" + (self.vault / "archive" / "threads").mkdir(parents=True) + (self.vault / "assets" / "threads").mkdir(parents=True) + + def tearDown(self) -> None: + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_old_archive_at_non_op_handle_is_not_reused(self) -> None: + """Pre-existing dir keyed by the tip's handle (lottes) must + not be reused when the OP is a different handle (kenpex). + The emit creates a fresh dir at the OP handle.""" + # Simulate a pre-existing tip-as-root archive at NOTimothyLottes + legacy_archive = ( + self.vault + / "archive" + / "threads" + / "NOTimothyLottes" + / "2023-04-26-legacy-slug" + ) + legacy_archive.mkdir(parents=True) + (legacy_archive / "index.md").write_text( + ( + "---\n" + "title: legacy\n" + "handle: NOTimothyLottes\n" + 'post_id: "1651268028795961344"\n' + "draft: false\n" + "---\n\n" + "old single-post archive\n" + ), + encoding="utf-8", + newline="\n", + ) + (legacy_archive.parent / "index.md").write_text( + ( + "---\n" + "title: NOTimothyLottes\n" + "---\n\n" + "- [[archive/threads/NOTimothyLottes/2023-04-26-legacy-slug]]\n" + ), + encoding="utf-8", + newline="\n", + ) + + # Write the cross-author thread data (5 posts, kenpex OP) + thread_data = { + "root_post_id": "1651282559287042048", + "source_url": "https://x.com/i/status/1651282559287042048", + "posts": [ + { + "post_id": "1650678968255913985", + "author": "c0de517e", + "handle": "kenpex", + "text": "Nerds are crazy", + "timestamp": "2023-04-25 01:51:48", + "media_urls": [], + "reply_to_id": None, + "quote_of_id": None, + "metrics": { + "reply_count": 0, + "repost_count": 0, + "like_count": 0, + "view_count": 0, + }, + }, + { + "post_id": "1651056827839180800", + "author": "wadetb", + "handle": "wadetb", + "text": "Forth is asm-level", + "timestamp": "2023-04-26 02:53:17", + "media_urls": [], + "reply_to_id": "1650678968255913985", + "quote_of_id": None, + "metrics": { + "reply_count": 0, + "repost_count": 0, + "like_count": 0, + "view_count": 0, + }, + }, + { + "post_id": "1651253961524142081", + "author": "kenpex", + "handle": "kenpex", + "text": "Yes of course", + "timestamp": "2023-04-26 15:56:37", + "media_urls": [], + "reply_to_id": "1651056827839180800", + "quote_of_id": None, + "metrics": { + "reply_count": 0, + "repost_count": 0, + "like_count": 0, + "view_count": 0, + }, + }, + { + "post_id": "1651268028795961344", + "author": "NOTimothyLottes", + "handle": "NOTimothyLottes", + "text": "Custom forth in few K", + "timestamp": "2023-04-26 16:52:31", + "media_urls": [], + "reply_to_id": "1651253961524142081", + "quote_of_id": None, + "metrics": { + "reply_count": 0, + "repost_count": 0, + "like_count": 0, + "view_count": 0, + }, + }, + { + "post_id": "1651282559287042048", + "author": "wvo", + "handle": "wvo", + "text": "Why not 1K?", + "timestamp": "2023-04-26 17:50:16", + "media_urls": [], + "reply_to_id": "1651268028795961344", + "quote_of_id": None, + "metrics": { + "reply_count": 0, + "repost_count": 0, + "like_count": 0, + "view_count": 0, + }, + }, + ], + } + (self.input_dir / "thread_data.json").write_text( + json.dumps(thread_data, indent=2), + encoding="utf-8", + newline="\n", + ) + + emit( + input_dir=self.input_dir, + vault=self.vault, + slug=None, + archived="2026-08-24", + force=True, + tip="1651282559287042048", + ) + + archive_root = self.vault / "archive" / "threads" + # The OP-owned archive lives under kenpex, not NOTimothyLottes + self.assertTrue( + (archive_root / "kenpex").is_dir(), + "OP-owned archive must live under the OP handle's dir", + ) + kenpex_thread_dirs = sorted( + d.name + for d in (archive_root / "kenpex").iterdir() + if d.is_dir() + ) + self.assertEqual(len(kenpex_thread_dirs), 1) + + # The legacy NOTimothyLottes dir is left as orphan; this + # test does not auto-remove it (manual cleanup is operator + # responsibility). + self.assertTrue( + (archive_root / "NOTimothyLottes" / "2023-04-26-legacy-slug").is_dir(), + ) + + if __name__ == "__main__": unittest.main() \ No newline at end of file diff --git a/scripts/twitter/tests/test_models_boundary.py b/scripts/twitter/tests/test_models_boundary.py new file mode 100644 index 0000000..fe7342b --- /dev/null +++ b/scripts/twitter/tests/test_models_boundary.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +from twitter.models import ( + LegacyManifest, + LegacyThreadData, + MediaItem, + MediaLocation, + MediaManifest, + PostData, +) + + +class MediaLocationTests(unittest.TestCase): + """Tests for the MediaLocation boundary dataclass.""" + + def test_local_round_trip_with_path_bytes_sha(self) -> None: + loc = MediaLocation.from_dict({ + "kind": "local", + "local_path": "asset.png", + "bytes": 100, + "sha256": "abc", + }) + self.assertEqual(loc.kind, "local") + self.assertEqual(loc.local_path, Path("asset.png")) + self.assertEqual(loc.bytes, 100) + self.assertEqual(loc.sha256, "abc") + self.assertIsNone(loc.url) + self.assertIsNone(loc.checked_at) + self.assertIsNone(loc.checked_status) + + def test_remote_round_trip_with_url(self) -> None: + loc = MediaLocation.from_dict({ + "kind": "remote", + "url": "https://example.com/asset.png", + }) + self.assertEqual(loc.kind, "remote") + self.assertEqual(loc.url, "https://example.com/asset.png") + self.assertIsNone(loc.local_path) + self.assertIsNone(loc.bytes) + self.assertIsNone(loc.sha256) + + def test_empty_dict_raises_keyerror(self) -> None: + with self.assertRaises(KeyError): + MediaLocation.from_dict({}) + + def test_bytes_bool_raises_typeerror(self) -> None: + with self.assertRaisesRegex(TypeError, "bytes"): + MediaLocation.from_dict({ + "kind": "local", + "local_path": "/tmp/x", + "bytes": True, + "sha256": "abc", + }) + + +class MediaItemTests(unittest.TestCase): + """Tests for the MediaItem boundary dataclass.""" + + def test_image_round_trip_with_locations_tuple(self) -> None: + item = MediaItem.from_dict({ + "post_id": "100", + "media_id": "AAA", + "kind": "image", + "locations": [ + {"kind": "local", "local_path": "asset.png", "bytes": 100, "sha256": "abc"}, + {"kind": "remote", "url": "https://example.com/asset.png"}, + ], + }) + self.assertEqual(item.post_id, "100") + self.assertEqual(item.media_id, "AAA") + self.assertEqual(item.kind, "image") + self.assertIsInstance(item.locations, tuple) + self.assertEqual(len(item.locations), 2) + self.assertEqual(item.locations[0].kind, "local") + self.assertEqual(item.locations[1].kind, "remote") + self.assertIsNone(item.embed) + self.assertIsNone(item.caption) + + def test_locations_not_list_raises_typeerror(self) -> None: + with self.assertRaisesRegex(TypeError, "locations"): + MediaItem.from_dict({ + "post_id": "p", + "media_id": "m", + "kind": "image", + "locations": "not a list", + }) + + def test_embed_not_bool_raises_typeerror(self) -> None: + with self.assertRaisesRegex(TypeError, "embed"): + MediaItem.from_dict({ + "post_id": "p", + "media_id": "m", + "kind": "image", + "locations": [], + "embed": "true", + }) + + +class MediaManifestTests(unittest.TestCase): + """Tests for the MediaManifest boundary dataclass.""" + + def test_minimal_manifest_round_trip(self) -> None: + manifest = MediaManifest.from_dict({ + "root_post_id": "100", + "items": [ + { + "post_id": "100", + "media_id": "AAA", + "kind": "image", + "locations": [ + {"kind": "local", "local_path": "x.png", "bytes": 1, "sha256": "a"}, + ], + } + ], + }) + self.assertEqual(manifest.root_post_id, "100") + self.assertIsInstance(manifest.items, tuple) + self.assertEqual(len(manifest.items), 1) + self.assertEqual(manifest.items[0].media_id, "AAA") + self.assertIsNone(manifest.captured_at) + + def test_items_not_list_raises_typeerror(self) -> None: + with self.assertRaisesRegex(TypeError, "items"): + MediaManifest.from_dict({"root_post_id": "p", "items": {}}) + + +class LegacyManifestTests(unittest.TestCase): + """Tests for the LegacyManifest boundary dataclass.""" + + def test_round_trip_with_threads(self) -> None: + manifest = LegacyManifest.from_dict({ + "threads": [ + { + "root_post_id": "100", + "posts": [], + "source_url": "https://x.com/example/status/100", + } + ], + }) + self.assertIsInstance(manifest.threads, tuple) + self.assertEqual(len(manifest.threads), 1) + self.assertEqual(manifest.threads[0].root_post_id, "100") + + +class LegacyThreadDataTests(unittest.TestCase): + """Tests for the LegacyThreadData boundary dataclass.""" + + def test_round_trip_with_posts_tuple(self) -> None: + thread = LegacyThreadData.from_dict({ + "root_post_id": "100", + "source_url": "https://x.com/example/status/100", + "posts": [ + { + "post_id": "100", + "author": "Example", + "handle": "example", + "text": "Hello", + "timestamp": "2026-08-24 12:00:00", + "media_urls": [], + "reply_to_id": None, + "quote_of_id": None, + "metrics": { + "reply_count": 0, + "repost_count": 0, + "like_count": 0, + "view_count": None, + }, + } + ], + }) + self.assertEqual(thread.root_post_id, "100") + self.assertEqual(thread.source_url, "https://x.com/example/status/100") + self.assertIsInstance(thread.posts, tuple) + self.assertEqual(len(thread.posts), 1) + self.assertIsInstance(thread.posts[0], PostData) + self.assertEqual(thread.posts[0].post_id, "100") + + +if __name__ == "__main__": + unittest.main()