import XCTest @testable import LChatCore // 🤖 Media, Links & Docs: extractor parity with the web's pure extractor // (web/static/threadmedia.js, pinned by test/threadmedia.test.mjs — these // cases mirror that suite, the cross-platform discipline the spec names in // docs/superpowers/specs/2026-08-05-media-links-docs-design.md § Native). // No event vectors change: the feature adds no event types. final class ThreadMediaTests: XCTestCase { private func payload(_ json: String) -> MessagePayload { try! JSONDecoder().decode(MessagePayload.self, from: Data(json.utf8)) } func testClassifiesImageGifVideoAsMediaOtherFilesAsDocs() { let p = payload(""" {"uid":"u1","name":"Ada","text":"","ts":1754000000000,"attachments":[ {"id":"b1","type":"image/jpeg","w":100,"h":50}, {"id":"b2","type":"image/gif"}, {"id":"b3","type":"video/mp4","poster":"p3","dur":12}, {"id":"b4","type":"application/pdf","name":"notes.pdf","size":900}]} """) let out = ThreadMedia.extractEntries(p, sid: 42, at: 1754000000) XCTAssertEqual(out?.media.count, 3, "image + gif + video are media") XCTAssertEqual(out?.docs.count, 1, "the pdf is a doc") XCTAssertEqual(out?.docs.first?.att?.name, "notes.pdf") XCTAssertEqual(out?.media.first?.sid, 42) } func testExtractsEveryLinkNotJustTheFirst() { let p = payload(#"{"uid":"u1","name":"Ada","ts":1,"text":"see https://a.example and https://b.example"}"#) let out = ThreadMedia.extractEntries(p, sid: 1, at: 1) XCTAssertEqual(out?.links.map { $0.url?.absoluteString }, ["https://a.example", "https://b.example"]) } func testLinksInsideFencedCodeBlocksDontExtract() { let p = payload(#"{"uid":"u1","name":"A","ts":1,"text":"```\nhttps://code.example\n```\nhttps://real.example"}"#) let out = ThreadMedia.extractEntries(p, sid: 1, at: 1) XCTAssertEqual(out?.links.count, 1, "the fenced URL is code, not a shared link") XCTAssertEqual(out?.links.first?.url?.absoluteString, "https://real.example") } func testNonContributingMessagesReturnNil() { // Typed app events (web parity — native stores them projected, but the // raw shape must still be rejected). XCTAssertNil(ThreadMedia.extractEntries( payload(#"{"type":"todo.add","text":"https://x.example"}"#), sid: 1, at: 1)) // Plain words, no attachments. XCTAssertNil(ThreadMedia.extractEntries( payload(#"{"uid":"u","name":"A","text":"plain words","ts":1}"#), sid: 1, at: 1)) // Native nuance: a stored app-event projection is type "chat" with // action:true — invisible here (see ThreadMedia's header note). XCTAssertNil(ThreadMedia.extractEntries( payload(#"{"type":"chat","action":true,"uid":"u","name":"A","text":"added https://x.example","ts":1}"#), sid: 1, at: 1)) } func testAttachmentWithoutABlobIdIsSkipped() { let p = payload(#"{"uid":"u","name":"A","text":"","ts":1,"attachments":[{"id":"","type":"image/png"}]}"#) XCTAssertNil(ThreadMedia.extractEntries(p, sid: 1, at: 1)) } func testSenderTimestampFallsBackToServerTime() { let p = payload(#"{"uid":"u","name":"A","text":"https://a.example"}"#) let out = ThreadMedia.extractEntries(p, sid: 1, at: 1_754_000_000) XCTAssertEqual(out?.links.first?.ts, 1_754_000_000_000, "created_at seconds → ms, the web's meta.ts rule") } func testExtractAllSortsNewestFirst() { let rows: [MessageStore.StoredMessage] = [ .init(id: 1, at: 100, edited: false, reactions: [], payload: payload(#"{"uid":"a","name":"A","ts":100000,"text":"","attachments":[{"id":"x","type":"image/png"}]}"#)), .init(id: 3, at: 300, edited: false, reactions: [], payload: payload(#"{"uid":"c","name":"C","ts":300000,"text":"","attachments":[{"id":"z","type":"image/png"}]}"#)), .init(id: 2, at: 200, edited: false, reactions: [], payload: payload(#"{"uid":"b","name":"B","ts":200000,"text":"","attachments":[{"id":"y","type":"image/png"}]}"#)), ] let tabs = ThreadMedia.extractAll(rows) XCTAssertEqual(tabs.media.map { $0.sid }, [3, 2, 1]) } func testFilteredHidesMutedSendersAtQueryTime() { let rows: [MessageStore.StoredMessage] = [ .init(id: 1, at: 100, edited: false, reactions: [], payload: payload(#"{"uid":"muted","name":"M","ts":100000,"text":"","attachments":[{"id":"a","type":"image/png"}]}"#)), .init(id: 2, at: 200, edited: false, reactions: [], payload: payload(#"{"uid":"ok","name":"O","ts":200000,"text":"","attachments":[{"id":"b","type":"image/png"}]}"#)), ] let tabs = ThreadMedia.extractAll(rows) XCTAssertEqual(ThreadMedia.filtered(tabs.media, hiddenUids: []).map { $0.sid }, [2, 1]) XCTAssertEqual(ThreadMedia.filtered(tabs.media, hiddenUids: ["muted"]).map { $0.sid }, [2]) } func testBucketByMonthGroupsConsecutiveSameMonthEntriesKeepsOrder() { var comps = DateComponents(); comps.year = 2026; comps.month = 8; comps.day = 10 let augMs = Int64(Calendar.current.date(from: comps)!.timeIntervalSince1970 * 1000) comps.month = 7; comps.day = 1 let julMs = Int64(Calendar.current.date(from: comps)!.timeIntervalSince1970 * 1000) let mk = { (sid: Int64, ts: Int64) in ThreadMedia.Entry(sid: sid, ts: ts, uid: "u", name: "A", att: nil, url: URL(string: "https://x.example")) } let buckets = ThreadMedia.bucketByMonth([mk(3, augMs), mk(2, augMs), mk(1, julMs)]) XCTAssertEqual(buckets.count, 2) XCTAssertEqual(buckets[0].items.count, 2) XCTAssertEqual(buckets[0].label, ThreadMedia.monthLabel(augMs)) XCTAssertEqual(buckets[1].items.first?.sid, 1) } func testQueryFilterMatchesSenderURLTitleAndFilename() { // Mirrors test/threadmedia.test.mjs "filterEntries matches sender, // URL, fetched unfurl title, and filename". let mk = { (sid: Int64, name: String, url: String?, file: String?) -> ThreadMedia.Entry in ThreadMedia.Entry(sid: sid, ts: 1, uid: "u", name: name, att: file.map { Attachment(id: "b\(sid)", name: $0, type: nil, w: nil, h: nil, size: nil, poster: nil, dur: nil, bh: nil) }, url: url.flatMap { URL(string: $0) }) } let entries = [ mk(1, "Ada", "https://example.com/Report", nil), mk(2, "Bob", "https://news.example/x", nil), mk(3, "Cy", nil, "Budget-2026.xlsx"), mk(4, "Ada", nil, nil), ] let titles = ["https://news.example/x": "Rain expected Friday"] let titleFor = { (u: URL) in titles[u.absoluteString] } let ids = { (q: String) in ThreadMedia.filtered(entries, query: q, titleFor: titleFor).map { $0.sid } } XCTAssertEqual(ids(""), [1, 2, 3, 4], "blank query = everything") XCTAssertEqual(ids(" "), [1, 2, 3, 4]) XCTAssertEqual(ids("ada"), [1, 4], "sender name, any tab") XCTAssertEqual(ids("REPORT"), [1], "URL substring, case-insensitive") XCTAssertEqual(ids("friday"), [2], "an already-fetched unfurl title") XCTAssertEqual(ids("budget"), [3], "doc filename") XCTAssertEqual(ids("zzz"), []) XCTAssertEqual(ThreadMedia.filtered(entries, query: "friday").map { $0.sid }, [], "no titleFor → titles don't match") } func testQueryFilterMatchesIndexedPhotoContent() { // A photo matches by what's IN it (ImageSearchIndex haystack) — and a // video by its poster still's index, never its own blob id. let photo = ThreadMedia.Entry(sid: 1, ts: 1, uid: "u", name: "Ada", att: Attachment(id: "p1", name: nil, type: "image/jpeg", w: nil, h: nil, size: nil, poster: nil, dur: nil, bh: nil), url: nil) let video = ThreadMedia.Entry(sid: 2, ts: 1, uid: "u", name: "Bob", att: Attachment(id: "v1", name: nil, type: "video/mp4", w: nil, h: nil, size: nil, poster: "v1-poster", dur: 3, bh: nil), url: nil) let index = ["p1": ImageSearchIndex.haystack(labels: ["dog", "beach"], text: "Wi-Fi: seaside2026"), "v1-poster": ImageSearchIndex.haystack(labels: ["birthday cake"], text: "")] let ids = { (q: String) in ThreadMedia.filtered([photo, video], query: q, imageTextFor: { index[$0] }).map { $0.sid } } XCTAssertEqual(ids("dog"), [1], "a scene label") XCTAssertEqual(ids("SEASIDE"), [1], "recognised text, case-insensitive") XCTAssertEqual(ids("birthday cake"), [2], "the video's poster index, keyed by the poster blob") XCTAssertEqual(ids("v1"), [], "blob ids are never searchable text") XCTAssertEqual(video.stillAttachment?.id, "v1-poster") XCTAssertEqual(video.stillAttachment?.type, "image/jpeg") XCTAssertEqual(ImageSearchIndex.normaliseLabel("Birthday_Cake"), "birthday cake") XCTAssertEqual(ImageSearchIndex.progressLabel(reading: false, done: 12, total: 1340), "Indexing photos… 12 of 1,340") XCTAssertEqual(ImageSearchIndex.progressLabel(reading: true, done: 0, total: 3), "Reading text in photos… 0 of 3") } func testAllLinksMatchesFirstLinkSemantics() { // allLinks (RichText) backs the extractor: every href, in order, // fenced code skipped, quote blocks included — firstLink's rules. let text = "> quoted https://q.example\n```\nhttps://code.example\n```\nwww.plain.example and go.dev/x" XCTAssertEqual(allLinks(text).map { $0.absoluteString }, ["https://q.example", "https://www.plain.example", "https://go.dev/x"]) XCTAssertEqual(firstLink(text)?.absoluteString, "https://q.example", "first of allLinks is firstLink") } }