eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1import XCTest
2@testable import LChatCore
3
4// 🤖 Media, Links & Docs: extractor parity with the web's pure extractor
5// (web/static/threadmedia.js, pinned by test/threadmedia.test.mjs — these
6// cases mirror that suite, the cross-platform discipline the spec names in
7// docs/superpowers/specs/2026-08-05-media-links-docs-design.md § Native).
8// No event vectors change: the feature adds no event types.
9final class ThreadMediaTests: XCTestCase {
10 private func payload(_ json: String) -> MessagePayload {
11 try! JSONDecoder().decode(MessagePayload.self, from: Data(json.utf8))
12 }
13
14 func testClassifiesImageGifVideoAsMediaOtherFilesAsDocs() {
15 let p = payload("""
16 {"uid":"u1","name":"Ada","text":"","ts":1754000000000,"attachments":[
17 {"id":"b1","type":"image/jpeg","w":100,"h":50},
18 {"id":"b2","type":"image/gif"},
19 {"id":"b3","type":"video/mp4","poster":"p3","dur":12},
20 {"id":"b4","type":"application/pdf","name":"notes.pdf","size":900}]}
21 """)
22 let out = ThreadMedia.extractEntries(p, sid: 42, at: 1754000000)
23 XCTAssertEqual(out?.media.count, 3, "image + gif + video are media")
24 XCTAssertEqual(out?.docs.count, 1, "the pdf is a doc")
25 XCTAssertEqual(out?.docs.first?.att?.name, "notes.pdf")
26 XCTAssertEqual(out?.media.first?.sid, 42)
27 }
28
29 func testExtractsEveryLinkNotJustTheFirst() {
30 let p = payload(#"{"uid":"u1","name":"Ada","ts":1,"text":"see https://a.example and https://b.example"}"#)
31 let out = ThreadMedia.extractEntries(p, sid: 1, at: 1)
32 XCTAssertEqual(out?.links.map { $0.url?.absoluteString },
33 ["https://a.example", "https://b.example"])
34 }
35
36 func testLinksInsideFencedCodeBlocksDontExtract() {
37 let p = payload(#"{"uid":"u1","name":"A","ts":1,"text":"```\nhttps://code.example\n```\nhttps://real.example"}"#)
38 let out = ThreadMedia.extractEntries(p, sid: 1, at: 1)
39 XCTAssertEqual(out?.links.count, 1, "the fenced URL is code, not a shared link")
40 XCTAssertEqual(out?.links.first?.url?.absoluteString, "https://real.example")
41 }
42
43 func testNonContributingMessagesReturnNil() {
44 // Typed app events (web parity — native stores them projected, but the
45 // raw shape must still be rejected).
46 XCTAssertNil(ThreadMedia.extractEntries(
47 payload(#"{"type":"todo.add","text":"https://x.example"}"#), sid: 1, at: 1))
48 // Plain words, no attachments.
49 XCTAssertNil(ThreadMedia.extractEntries(
50 payload(#"{"uid":"u","name":"A","text":"plain words","ts":1}"#), sid: 1, at: 1))
51 // Native nuance: a stored app-event projection is type "chat" with
52 // action:true — invisible here (see ThreadMedia's header note).
53 XCTAssertNil(ThreadMedia.extractEntries(
54 payload(#"{"type":"chat","action":true,"uid":"u","name":"A","text":"added https://x.example","ts":1}"#),
55 sid: 1, at: 1))
56 }
57
58 func testAttachmentWithoutABlobIdIsSkipped() {
59 let p = payload(#"{"uid":"u","name":"A","text":"","ts":1,"attachments":[{"id":"","type":"image/png"}]}"#)
60 XCTAssertNil(ThreadMedia.extractEntries(p, sid: 1, at: 1))
61 }
62
63 func testSenderTimestampFallsBackToServerTime() {
64 let p = payload(#"{"uid":"u","name":"A","text":"https://a.example"}"#)
65 let out = ThreadMedia.extractEntries(p, sid: 1, at: 1_754_000_000)
66 XCTAssertEqual(out?.links.first?.ts, 1_754_000_000_000, "created_at seconds → ms, the web's meta.ts rule")
67 }
68
69 func testExtractAllSortsNewestFirst() {
70 let rows: [MessageStore.StoredMessage] = [
71 .init(id: 1, at: 100, edited: false, reactions: [],
72 payload: payload(#"{"uid":"a","name":"A","ts":100000,"text":"","attachments":[{"id":"x","type":"image/png"}]}"#)),
73 .init(id: 3, at: 300, edited: false, reactions: [],
74 payload: payload(#"{"uid":"c","name":"C","ts":300000,"text":"","attachments":[{"id":"z","type":"image/png"}]}"#)),
75 .init(id: 2, at: 200, edited: false, reactions: [],
76 payload: payload(#"{"uid":"b","name":"B","ts":200000,"text":"","attachments":[{"id":"y","type":"image/png"}]}"#)),
77 ]
78 let tabs = ThreadMedia.extractAll(rows)
79 XCTAssertEqual(tabs.media.map { $0.sid }, [3, 2, 1])
80 }
81
82 func testFilteredHidesMutedSendersAtQueryTime() {
83 let rows: [MessageStore.StoredMessage] = [
84 .init(id: 1, at: 100, edited: false, reactions: [],
85 payload: payload(#"{"uid":"muted","name":"M","ts":100000,"text":"","attachments":[{"id":"a","type":"image/png"}]}"#)),
86 .init(id: 2, at: 200, edited: false, reactions: [],
87 payload: payload(#"{"uid":"ok","name":"O","ts":200000,"text":"","attachments":[{"id":"b","type":"image/png"}]}"#)),
88 ]
89 let tabs = ThreadMedia.extractAll(rows)
90 XCTAssertEqual(ThreadMedia.filtered(tabs.media, hiddenUids: []).map { $0.sid }, [2, 1])
91 XCTAssertEqual(ThreadMedia.filtered(tabs.media, hiddenUids: ["muted"]).map { $0.sid }, [2])
92 }
93
94 func testBucketByMonthGroupsConsecutiveSameMonthEntriesKeepsOrder() {
95 var comps = DateComponents(); comps.year = 2026; comps.month = 8; comps.day = 10
96 let augMs = Int64(Calendar.current.date(from: comps)!.timeIntervalSince1970 * 1000)
97 comps.month = 7; comps.day = 1
98 let julMs = Int64(Calendar.current.date(from: comps)!.timeIntervalSince1970 * 1000)
99 let mk = { (sid: Int64, ts: Int64) in
100 ThreadMedia.Entry(sid: sid, ts: ts, uid: "u", name: "A", att: nil,
101 url: URL(string: "https://x.example"))
102 }
103 let buckets = ThreadMedia.bucketByMonth([mk(3, augMs), mk(2, augMs), mk(1, julMs)])
104 XCTAssertEqual(buckets.count, 2)
105 XCTAssertEqual(buckets[0].items.count, 2)
106 XCTAssertEqual(buckets[0].label, ThreadMedia.monthLabel(augMs))
107 XCTAssertEqual(buckets[1].items.first?.sid, 1)
108 }
109
110 func testQueryFilterMatchesSenderURLTitleAndFilename() {
111 // Mirrors test/threadmedia.test.mjs "filterEntries matches sender,
112 // URL, fetched unfurl title, and filename".
113 let mk = { (sid: Int64, name: String, url: String?, file: String?) -> ThreadMedia.Entry in
114 ThreadMedia.Entry(sid: sid, ts: 1, uid: "u", name: name,
115 att: file.map { Attachment(id: "b\(sid)", name: $0, type: nil, w: nil, h: nil,
116 size: nil, poster: nil, dur: nil, bh: nil) },
117 url: url.flatMap { URL(string: $0) })
118 }
119 let entries = [
120 mk(1, "Ada", "https://example.com/Report", nil),
121 mk(2, "Bob", "https://news.example/x", nil),
122 mk(3, "Cy", nil, "Budget-2026.xlsx"),
123 mk(4, "Ada", nil, nil),
124 ]
125 let titles = ["https://news.example/x": "Rain expected Friday"]
126 let titleFor = { (u: URL) in titles[u.absoluteString] }
127 let ids = { (q: String) in ThreadMedia.filtered(entries, query: q, titleFor: titleFor).map { $0.sid } }
128 XCTAssertEqual(ids(""), [1, 2, 3, 4], "blank query = everything")
129 XCTAssertEqual(ids(" "), [1, 2, 3, 4])
130 XCTAssertEqual(ids("ada"), [1, 4], "sender name, any tab")
131 XCTAssertEqual(ids("REPORT"), [1], "URL substring, case-insensitive")
132 XCTAssertEqual(ids("friday"), [2], "an already-fetched unfurl title")
133 XCTAssertEqual(ids("budget"), [3], "doc filename")
134 XCTAssertEqual(ids("zzz"), [])
135 XCTAssertEqual(ThreadMedia.filtered(entries, query: "friday").map { $0.sid }, [],
136 "no titleFor → titles don't match")
137 }
138
139 func testQueryFilterMatchesIndexedPhotoContent() {
140 // A photo matches by what's IN it (ImageSearchIndex haystack) — and a
141 // video by its poster still's index, never its own blob id.
142 let photo = ThreadMedia.Entry(sid: 1, ts: 1, uid: "u", name: "Ada",
143 att: Attachment(id: "p1", name: nil, type: "image/jpeg", w: nil, h: nil,
144 size: nil, poster: nil, dur: nil, bh: nil), url: nil)
145 let video = ThreadMedia.Entry(sid: 2, ts: 1, uid: "u", name: "Bob",
146 att: Attachment(id: "v1", name: nil, type: "video/mp4", w: nil, h: nil,
147 size: nil, poster: "v1-poster", dur: 3, bh: nil), url: nil)
148 let index = ["p1": ImageSearchIndex.haystack(labels: ["dog", "beach"], text: "Wi-Fi: seaside2026"),
149 "v1-poster": ImageSearchIndex.haystack(labels: ["birthday cake"], text: "")]
150 let ids = { (q: String) in
151 ThreadMedia.filtered([photo, video], query: q, imageTextFor: { index[$0] }).map { $0.sid }
152 }
153 XCTAssertEqual(ids("dog"), [1], "a scene label")
154 XCTAssertEqual(ids("SEASIDE"), [1], "recognised text, case-insensitive")
155 XCTAssertEqual(ids("birthday cake"), [2], "the video's poster index, keyed by the poster blob")
156 XCTAssertEqual(ids("v1"), [], "blob ids are never searchable text")
157 XCTAssertEqual(video.stillAttachment?.id, "v1-poster")
158 XCTAssertEqual(video.stillAttachment?.type, "image/jpeg")
159 XCTAssertEqual(ImageSearchIndex.normaliseLabel("Birthday_Cake"), "birthday cake")
160 XCTAssertEqual(ImageSearchIndex.progressLabel(reading: false, done: 12, total: 1340),
161 "Indexing photos… 12 of 1,340")
162 XCTAssertEqual(ImageSearchIndex.progressLabel(reading: true, done: 0, total: 3),
163 "Reading text in photos… 0 of 3")
164 }
165
166 func testAllLinksMatchesFirstLinkSemantics() {
167 // allLinks (RichText) backs the extractor: every href, in order,
168 // fenced code skipped, quote blocks included — firstLink's rules.
169 let text = "> quoted https://q.example\n```\nhttps://code.example\n```\nwww.plain.example and go.dev/x"
170 XCTAssertEqual(allLinks(text).map { $0.absoluteString },
171 ["https://q.example", "https://www.plain.example", "https://go.dev/x"])
172 XCTAssertEqual(firstLink(text)?.absoluteString, "https://q.example",
173 "first of allLinks is firstLink")
174 }
175}
176