| 1 | import 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. |
| 9 | final 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 | |