| 1 | import XCTest |
| 2 | @testable import LChatCore |
| 3 | |
| 4 | // 🤖 The registry (docs/native-apps.md Layer 1): the bundled seed must load |
| 5 | // and answer every question the collapsed lookup tables used to — titles, |
| 6 | // glyphs, blurbs, picker lists, the install read-filter, and surface |
| 7 | // presentation — and live reports must merge over it. The seed itself is |
| 8 | // drift-gated against the web framework by test/appcatalog-seed.test.mjs; |
| 9 | // these tests pin the Swift reads of it. |
| 10 | @MainActor |
| 11 | final class AppRegistryTests: XCTestCase { |
| 12 | // Distinct origins per test: registries are per-origin process singletons. |
| 13 | private func fresh(_ tag: String) -> AppRegistry { |
| 14 | AppRegistry.shared(origin: "https://\(tag).registry-tests.example") |
| 15 | } |
| 16 | |
| 17 | func testSeedLoadsBuiltins() { |
| 18 | let r = fresh("seed") |
| 19 | XCTAssertEqual(r.title("todo"), "To-do") |
| 20 | XCTAssertEqual(r.glyph("vault"), "🔒") |
| 21 | XCTAssertEqual(r.title("connect"), "Integrations") |
| 22 | XCTAssertEqual(r.blurb("board"), "Sketch on a whiteboard") |
| 23 | // The doc's first-class-citizen guarantee: unknown apps still answer. |
| 24 | XCTAssertEqual(r.title("mystery-app"), "Mystery-App") |
| 25 | XCTAssertEqual(r.glyph("mystery-app"), "▦") |
| 26 | XCTAssertNil(r.blurb("mystery-app")) |
| 27 | } |
| 28 | |
| 29 | func testPickerLists() { |
| 30 | let r = fresh("pickers") |
| 31 | let personal = r.personalApps.map(\.name) |
| 32 | let group = r.groupApps.map(\.name) |
| 33 | // The web's personal catalogue (places ∋ "you", listed) — todo yes, |
| 34 | // board no (board is chat-only on the web too). |
| 35 | XCTAssertTrue(personal.contains("todo")) |
| 36 | XCTAssertTrue(personal.contains("bookmarks")) |
| 37 | XCTAssertFalse(personal.contains("board")) |
| 38 | XCTAssertFalse(personal.contains("admin")) // places: [] — offered by the admin row, not the catalogue |
| 39 | // The group list gains the apps the old 4-entry catalog was missing — |
| 40 | // the "board can't be added to a group" fix. |
| 41 | XCTAssertTrue(group.contains("board")) |
| 42 | XCTAssertTrue(group.contains("poll")) |
| 43 | XCTAssertTrue(group.contains("vault")) |
| 44 | XCTAssertFalse(group.contains("connect")) // personal-only |
| 45 | } |
| 46 | |
| 47 | func testKnownFiltersInstallsByPlace() { |
| 48 | let r = fresh("known") |
| 49 | let items = [ |
| 50 | GroupApps.Item(id: "board", by: "Ana", ts: 1), |
| 51 | GroupApps.Item(id: "evil-app", by: "Mallory", ts: 2), |
| 52 | GroupApps.Item(id: "connect", by: "Ana", ts: 3), // real app, but not chat-capable |
| 53 | ] |
| 54 | XCTAssertEqual(r.known(items).map(\.id), ["board"]) |
| 55 | let space = [ |
| 56 | SpaceApps.Item(id: "gif", by: "Ana", ts: 1, uid: "u1"), |
| 57 | SpaceApps.Item(id: "todo", by: "Ana", ts: 2, uid: "u1"), // not space-capable |
| 58 | ] |
| 59 | XCTAssertEqual(r.knownSpace(space).map(\.id), ["gif"]) |
| 60 | } |
| 61 | |
| 62 | func testPresentation() { |
| 63 | let r = fresh("presentation") |
| 64 | XCTAssertEqual(r.presentation("setup"), .flow) |
| 65 | XCTAssertEqual(r.presentation("welcome"), .flow) |
| 66 | XCTAssertEqual(r.presentation("todo"), .schema(companion: true)) |
| 67 | XCTAssertEqual(r.presentation("remind"), .schema(companion: true)) |
| 68 | // Admin: a companion whose first view is its console (the Mac Chat|View |
| 69 | // / iOS opens-as-the-app fix); its Chat view is the read-only update |
| 70 | // stream, hence chatReadOnly. |
| 71 | XCTAssertEqual(r.presentation("admin"), |
| 72 | .companion(defaultView: "interface", chatOnly: false, chatReadOnly: true)) |
| 73 | // Takeover is the universal fallback: vault, and any unknown app. |
| 74 | XCTAssertEqual(r.presentation("vault"), .takeover) |
| 75 | XCTAssertEqual(r.presentation("mystery-app"), .takeover) |
| 76 | } |
| 77 | |
| 78 | func testLiveReportMergesOverSeed() { |
| 79 | let r = fresh("live-report") |
| 80 | XCTAssertFalse(r.hasLiveReport) |
| 81 | let report = """ |
| 82 | [{"name":"habits","title":"Habit tracker","glyph":"✅","blurb":"Track a habit", |
| 83 | "places":["you"],"listed":true,"quiet":false,"member":true, |
| 84 | "companion":true,"chatOnly":false,"chatReadOnly":false, |
| 85 | "defaultView":"chat","defaultCommand":"","commands":[]}, |
| 86 | {"name":"todo","title":"Tasks","glyph":"✓","blurb":"","places":["you","chat"], |
| 87 | "listed":true,"quiet":false,"member":false,"companion":true,"chatOnly":false, |
| 88 | "chatReadOnly":false,"defaultView":"split","defaultCommand":"/add","commands":[]}] |
| 89 | """.data(using: .utf8)! |
| 90 | r.update(reportJSON: report) |
| 91 | XCTAssertTrue(r.hasLiveReport) |
| 92 | // The member app registered and is personal-pickable. |
| 93 | XCTAssertEqual(r.title("habits"), "Habit tracker") |
| 94 | XCTAssertTrue(r.personalApps.map(\.name).contains("habits")) |
| 95 | XCTAssertEqual(r.presentation("habits"), |
| 96 | .companion(defaultView: "chat", chatOnly: false, chatReadOnly: false)) |
| 97 | // The live report wins per app (a renamed todo renames here)… |
| 98 | XCTAssertEqual(r.title("todo"), "Tasks") |
| 99 | // …but the seed still floors every built-in the report didn't carry. |
| 100 | XCTAssertEqual(r.title("vault"), "Vault") |
| 101 | // An empty/undecodable report changes nothing. |
| 102 | r.update(reportJSON: Data("null".utf8)) |
| 103 | XCTAssertEqual(r.title("habits"), "Habit tracker") |
| 104 | } |
| 105 | |
| 106 | // Service-gated apps (manifest.requiresService — GitHub, DNSimple…) hide |
| 107 | // from the pickers until the space's connected set names their service: |
| 108 | // the web's matchingApps rule (newchat.js), so admin-native and member-web |
| 109 | // show the same list. |
| 110 | func testServiceGatedAppsHideUntilConnected() { |
| 111 | let r = fresh("services") |
| 112 | XCTAssertFalse(r.personalApps.map(\.name).contains("github")) |
| 113 | XCTAssertFalse(r.personalApps.map(\.name).contains("dnsimple")) |
| 114 | XCTAssertTrue(r.personalApps.map(\.name).contains("todo")) // ungated |
| 115 | r.updateConnected(["github"]) |
| 116 | XCTAssertTrue(r.personalApps.map(\.name).contains("github")) |
| 117 | XCTAssertFalse(r.personalApps.map(\.name).contains("dnsimple")) |
| 118 | } |
| 119 | |
| 120 | // Decoration flows from the apps' own manifests (symbol/mark in each JS |
| 121 | // spec → the report → the registry → AppIdentity): nothing hand-kept |
| 122 | // natively. The seed alone must dress the built-ins. |
| 123 | func testDecorationComesFromManifests() { |
| 124 | _ = fresh("decoration") // init pushes the seed's decoration |
| 125 | XCTAssertEqual(AppIdentity.symbol(for: "todo"), "checkmark.square") |
| 126 | XCTAssertEqual(AppIdentity.symbol(for: "admin"), "wrench.adjustable") |
| 127 | XCTAssertNil(AppIdentity.symbol(for: "github")) // brand mark, not a symbol |
| 128 | XCTAssertNil(AppIdentity.symbol(for: "some-member-app")) // emoji is content |
| 129 | } |
| 130 | } |
| 131 | |