From 2b96ace3d6deec17db96ae38a78911b5f564ee86 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 9 Nov 2026 15:31:29 -0500 Subject: [PATCH 1/4] feat(chats): add ChatsModel and replay the shared Chats behaviors ChatsModel holds the Chats tab's filter, chip reveal and select mode over ChatListProjection, so the shared behavior files can run against it with swift test on the Mac. ChatListFilter moves to FlipcashCore with it; the app keeps the chip titles and empty messages. The screen doesn't use the model yet. ChatsBehaviorTests replays the synced copy in Fixtures/behaviors/chats/. --- .../Screens/Main/Tips/ChatListChips.swift | 27 +-- .../Models/Conversation/ChatListFilter.swift | 34 +++ .../Models/Conversation/ChatsModel.swift | 87 ++++++++ .../ChatsBehaviorTests.swift | 193 ++++++++++++++++++ .../Fixtures/behaviors/chats/README.md | 50 +++++ .../Fixtures/behaviors/chats/errors.json | 10 + .../Fixtures/behaviors/chats/filters.json | 10 + .../behaviors/chats/initial-state.json | 12 ++ .../Fixtures/behaviors/chats/selection.json | 11 + 9 files changed, 409 insertions(+), 25 deletions(-) create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatListFilter.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatsModel.swift create mode 100644 FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/ChatsBehaviorTests.swift create mode 100644 FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/behaviors/chats/README.md create mode 100644 FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/behaviors/chats/errors.json create mode 100644 FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/behaviors/chats/filters.json create mode 100644 FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/behaviors/chats/initial-state.json create mode 100644 FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/behaviors/chats/selection.json diff --git a/Flipcash/Core/Screens/Main/Tips/ChatListChips.swift b/Flipcash/Core/Screens/Main/Tips/ChatListChips.swift index 21d671848..b06888c0c 100644 --- a/Flipcash/Core/Screens/Main/Tips/ChatListChips.swift +++ b/Flipcash/Core/Screens/Main/Tips/ChatListChips.swift @@ -7,12 +7,8 @@ import SwiftUI import FlipcashCore import FlipcashUI -/// The Chats tab's chip filters. The selection lives in the screen's `@State` and is never -/// persisted: every cold start opens on `.all`. -enum ChatListFilter: CaseIterable, Hashable { - case all - case unread - case groups +/// What each chip reads on screen. +extension ChatListFilter { var title: String { switch self { @@ -22,25 +18,6 @@ enum ChatListFilter: CaseIterable, Hashable { } } - func ids(in projection: ChatListProjection) -> [ID] { - switch self { - case .all: projection.main - case .unread: projection.unreadChip - case .groups: projection.groupsChip - } - } - - /// The number a chip shows: unread chats inside its filter, or nothing when zero. `All` shows - /// none because the tab badge already shows that number. - func badge(in projection: ChatListProjection) -> Int? { - let count: Int = switch self { - case .all: 0 - case .unread: projection.unreadChipCount - case .groups: projection.groupsChipCount - } - return count > 0 ? count : nil - } - /// The one-line empty state for a filter with no chats. `.all` only reaches this when every /// chat is archived. var emptyMessage: String { diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatListFilter.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatListFilter.swift new file mode 100644 index 000000000..48b07bfc6 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatListFilter.swift @@ -0,0 +1,34 @@ +// +// ChatListFilter.swift +// FlipcashCore +// + +import Foundation + +/// The Chats tab's chip filters. Never persisted: every cold start opens on `.all`. The raw value is +/// the name the shared behavior files use. +public enum ChatListFilter: String, CaseIterable, Hashable, Sendable { + case all = "All" + case unread = "Unread" + case groups = "Groups" + + /// The chats this filter shows, in the projection's order. + public func ids(in projection: ChatListProjection) -> [ID] { + switch self { + case .all: projection.main + case .unread: projection.unreadChip + case .groups: projection.groupsChip + } + } + + /// The number a chip shows: unread chats inside its filter, or nothing when zero. `All` shows + /// none because the tab badge already shows that number. + public func badge(in projection: ChatListProjection) -> Int? { + let count: Int = switch self { + case .all: 0 + case .unread: projection.unreadChipCount + case .groups: projection.groupsChipCount + } + return count > 0 ? count : nil + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatsModel.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatsModel.swift new file mode 100644 index 000000000..15643ca58 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatsModel.swift @@ -0,0 +1,87 @@ +// +// ChatsModel.swift +// FlipcashCore +// + +import Foundation + +/// The Chats tab's screen state: the selected filter, whether the chips are in view, and select mode, +/// over the projection the controller hands it. Each action is a plain mutation, so the same rules +/// run in the screen and in the shared behavior tests. +public struct ChatsModel: Sendable { + + /// The rows, chips, Archived row and badge, from ``ChatListProjection/project(_:)``. `nil` until + /// the feed has resolved. + public private(set) var projection: ChatListProjection? + /// The selected chip. + public private(set) var filter: ChatListFilter = .all + /// Set by a pull down that starts at the top of the list. + public private(set) var chipsRevealed = false + /// Multi-select mode. + public private(set) var isSelecting = false + /// The picked chats while ``isSelecting``. + public private(set) var selected: Set = [] + + public init() {} + + /// The list hasn't loaded yet. + public var isLoading: Bool { projection == nil } + + /// A selected filter keeps the chips in view: they explain the list. + public var showsChips: Bool { chipsRevealed || filter != .all } + + /// The rows the current filter shows, in order. + public var visibleIDs: [ID] { + projection.map { filter.ids(in: $0) } ?? [] + } + + /// The Archived row shows, on the All filter only. + public var showsArchivedRow: Bool { + filter == .all && (projection?.archivedRowVisible ?? false) + } + + /// No chats at all, archived included: the empty state shows. + public var hasNoChatsAtAll: Bool { + guard let projection else { return false } + return projection.main.isEmpty && !projection.archivedRowVisible + } + + /// Takes a new projection and drops picks no longer listed, so an archived or left chat isn't + /// acted on. + public mutating func update(_ projection: ChatListProjection) { + self.projection = projection + let kept = selected.intersection(projection.main) + if kept != selected { selected = kept } + } + + /// Selects a chip. + public mutating func select(_ filter: ChatListFilter) { + self.filter = filter + } + + /// Pulls the chips into view. + public mutating func revealChips() { + chipsRevealed = true + } + + /// Enters select mode with nothing selected. + public mutating func beginSelection() { + selected = [] + isSelecting = true + } + + /// Picks or unpicks one chat. + public mutating func toggle(_ id: ID) { + if selected.contains(id) { + selected.remove(id) + } else { + selected.insert(id) + } + } + + /// Leaves select mode. + public mutating func endSelection() { + isSelecting = false + selected = [] + } +} diff --git a/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/ChatsBehaviorTests.swift b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/ChatsBehaviorTests.swift new file mode 100644 index 000000000..29dc24fab --- /dev/null +++ b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/ChatsBehaviorTests.swift @@ -0,0 +1,193 @@ +// +// ChatsBehaviorTests.swift +// FlipcashCoreVectorsTests +// + +import Foundation +import Testing +@testable import FlipcashCore + +/// Replays `test-vectors/behaviors/chats/*.json` against ``ChatsModel`` over the seed in that +/// directory's README. Synced copy: a failure is fixed in the canonical files in the orchestrator or +/// in `ChatsModel`, never by editing the copy here. +@Suite("Chats behaviors") +struct ChatsBehaviorTests { + + @Test("Every Chats behavior file replays as written") + func behaviors() throws { + let urls = try #require( + Bundle.module.urls(forResourcesWithExtension: "json", subdirectory: "Fixtures/behaviors/chats") + ) + #expect(!urls.isEmpty, "no behavior files bundled") + for url in urls.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { + let behavior = try JSONDecoder().decode(Behavior.self, from: Data(contentsOf: url)) + replay(behavior, file: url.lastPathComponent) + } + } + + private func replay(_ behavior: Behavior, file: String) { + var host = ChatsHost() + for (index, step) in behavior.steps.enumerated() { + let label = "\(file): step \(index + 1) (\(step.do ?? "ls"))" + let result = step.do.map { host.perform($0, input: step.input ?? [:]) } ?? .success(()) + switch (result, step.expectError) { + case (.failure(let code), let expected?): + #expect(code.rawValue == expected, "\(label): expected error \(expected), got \(code.rawValue)") + continue + case (.failure(let code), nil): + Issue.record("\(label): unexpected error \(code.rawValue)") + return + case (.success, let expected?): + Issue.record("\(label): expected error \(expected), got success") + continue + case (.success, nil): + break + } + let state = host.node() + for (key, want) in step.expect ?? [:] { + guard let got = state[key] else { + Issue.record("\(label): state.\(key): missing on iOS") + continue + } + #expect(got == want, "\(label): state.\(key): expected \(want), got \(got)") + } + } + } +} + +// MARK: - Host + +/// The seeded ``ChatsModel`` and the protocol around it: action names, input checks, the state node. +private struct ChatsHost { + + enum ErrorCode: String, Error { + case unknownAction = "unknown_action" + case badInput = "bad_input" + } + + private var model = ChatsModel() + + init() { + model.update(.project(Self.seed)) + } + + /// The seed table in `behaviors/chats/README.md`, newest first. + static let seed: [ChatListEntry] = { + func id(_ prefix: String) -> String { String(repeating: prefix, count: 16) } + func entry(_ id: String, _ type: ConversationType, unread: Int, archived: Bool = false, age: Int) -> ChatListEntry { + ChatListEntry( + id: id, + type: type, + lastActivity: Date(timeIntervalSince1970: TimeInterval(1_000_000 - age)), + isArchived: archived, + isMuted: false, + isHidden: false, + unread: .count(unread) + ) + } + return [ + entry(id("01"), .group, unread: 3, age: 1), + entry(id("02"), .tipDm, unread: 0, age: 2), + entry(id("03"), .group, unread: 0, age: 3), + entry(id("04"), .tipDm, unread: 1, age: 4), + entry("old-crew", .group, unread: 2, archived: true, age: 5), + ] + }() + + mutating func perform(_ action: String, input: [String: JSON]) -> Result { + switch action { + case "FilterSelected": + guard case .string(let raw)? = input["filter"], let filter = ChatListFilter(rawValue: raw) else { + return .failure(.badInput) + } + model.select(filter) + case "ChipsRevealed": + model.revealChips() + case "SelectionStarted": + // iOS enters select mode from the More menu with nothing picked; a starting chat isn't + // part of its API, so `chatId` is checked but not applied. + if let chatId = input["chatId"] { + guard case .string = chatId else { return .failure(.badInput) } + } + model.beginSelection() + case "SelectionToggled": + guard case .string(let id)? = input["chatId"] else { return .failure(.badInput) } + model.toggle(id) + case "SelectionEnded": + model.endSelection() + default: + return .failure(.unknownAction) + } + return .success(()) + } + + /// The state keys in `behaviors/chats/README.md` that iOS computes in the model. `chats` and + /// `canReadAll` need `Conversation` and the controller, so they are absent. + func node() -> [String: JSON] { + let p = model.projection + func ids(_ ids: [String]) -> JSON { .array(ids.map(JSON.string)) } + var state: [String: JSON] = [ + "loading": .bool(model.isLoading), + "filter": .string(model.filter.rawValue), + "chipsRevealed": .bool(model.chipsRevealed), + "showsChips": .bool(model.showsChips), + "isSelecting": .bool(model.isSelecting), + "selection": model.isSelecting ? ids(model.selected.sorted()) : .null, + "showsArchivedRow": .bool(model.showsArchivedRow), + "hasNoChatsAtAll": .bool(model.hasNoChatsAtAll), + "visibleChatIds": ids(model.visibleIDs), + ] + if let p { + state["chips"] = .object(Dictionary(uniqueKeysWithValues: ChatListFilter.allCases.map { ($0.rawValue, ids($0.ids(in: p))) })) + state["unreadChipCount"] = .number(Double(p.unreadChipCount)) + state["groupsChipCount"] = .number(Double(p.groupsChipCount)) + state["archivedRowCount"] = .number(Double(p.archivedRowCount)) + state["tabBadge"] = .number(Double(p.tabBadge)) + } + return state + } +} + +// MARK: - File format + +private struct Behavior: Decodable { + struct Step: Decodable { + let `do`: String? + let input: [String: JSON]? + let expect: [String: JSON]? + let expectError: String? + } + let screen: String + let steps: [Step] +} + +/// A JSON value, compared exactly: a boolean never equals a number. +private enum JSON: Decodable, Equatable, CustomStringConvertible { + case null + case bool(Bool) + case number(Double) + case string(String) + case array([JSON]) + case object([String: JSON]) + + init(from decoder: Decoder) throws { + let c = try decoder.singleValueContainer() + if c.decodeNil() { self = .null } + else if let b = try? c.decode(Bool.self) { self = .bool(b) } + else if let n = try? c.decode(Double.self) { self = .number(n) } + else if let s = try? c.decode(String.self) { self = .string(s) } + else if let a = try? c.decode([JSON].self) { self = .array(a) } + else { self = .object(try c.decode([String: JSON].self)) } + } + + var description: String { + switch self { + case .null: "null" + case .bool(let b): "\(b)" + case .number(let n): n == n.rounded() ? "\(Int(n))" : "\(n)" + case .string(let s): "\"\(s)\"" + case .array(let a): "[" + a.map(\.description).joined(separator: ", ") + "]" + case .object(let o): "{" + o.keys.sorted().map { "\($0): \(o[$0]!)" }.joined(separator: ", ") + "}" + } + } +} diff --git a/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/behaviors/chats/README.md b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/behaviors/chats/README.md new file mode 100644 index 000000000..506263c0b --- /dev/null +++ b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/behaviors/chats/README.md @@ -0,0 +1,50 @@ +# Chats + +The chat list. + +## Seed + +| Id | Name | Type | Unread | Archived | +|---|---|---|---|---| +| `0101…` (32 digits) | Ballers | GROUP | 3 | no | +| `0202…` | Ava | TIP_DM | 0 | no | +| `0303…` | Book club | GROUP | 0 | no | +| `0404…` | Sam | TIP_DM | 1 | no | +| — | Old crew | GROUP | 2 | yes | + +Ids repeat their two-digit prefix sixteen times, e.g. `01010101010101010101010101010101`. + +## State keys + +| Key | Type | Means | +|---|---|---| +| `loading` | boolean | The list hasn't loaded yet. | +| `filter` | `All` \| `Unread` \| `Groups` | The selected chip. | +| `chipsRevealed`, `showsChips` | boolean | The filter chips were pulled into view, and whether they show. | +| `isSelecting`, `selection` | boolean, string[] or null | Multi-select mode and the selected ids, sorted. | +| `canReadAll` | boolean | "Read all" is offered. | +| `showsArchivedRow` | boolean | The archived row shows (All filter only). | +| `hasNoChatsAtAll` | boolean | The empty state shows. | +| `chats` | object[] | Every unarchived chat: `id`, `name`, `isGroup`, `chatType`, `unreadCount`, `markedUnread`, `isUnread`. | +| `visibleChatIds` | string[] | The rows the current filter shows, in order. | +| `chips` | object | Each filter's ids. | +| `unreadChipCount`, `groupsChipCount` | number | The chip badges. | +| `archivedRowCount` | number | Unread archived chats (chats, not messages). | +| `tabBadge` | number | The Chats tab badge. | + +## Actions + +| Action | Input | +|---|---| +| `FilterSelected` | `filter` (required) | +| `ChipsRevealed` | none | +| `SelectionStarted` | `chatId` (optional, Android only; see below) | +| `SelectionToggled` | `chatId` (required) | +| `SelectionEnded` | none | + +## Where the platforms differ + +- **Starting a selection on a row.** On Android a long-press on a row opens select mode with that row + picked (`SelectionStarted` with `chatId`). On iOS a long-press opens the row's context menu, and + select mode only opens from the More menu with nothing picked. Shared files send + `SelectionStarted` without `chatId`; Android's `ChatListSelectionTest` covers the row case. diff --git a/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/behaviors/chats/errors.json b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/behaviors/chats/errors.json new file mode 100644 index 000000000..dcda197aa --- /dev/null +++ b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/behaviors/chats/errors.json @@ -0,0 +1,10 @@ +{ + "screen": "Chats", + "description": "Unknown actions and inputs outside the schema are refused without changing state.", + "steps": [ + {"do": "Archive", "expectError": "unknown_action"}, + {"do": "FilterSelected", "input": {"filter": "Bogus"}, "expectError": "bad_input"}, + {"do": "SelectionToggled", "input": {}, "expectError": "bad_input"}, + {"ls": true, "expect": {"filter": "All", "selection": null}} + ] +} diff --git a/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/behaviors/chats/filters.json b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/behaviors/chats/filters.json new file mode 100644 index 000000000..e05b4cc78 --- /dev/null +++ b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/behaviors/chats/filters.json @@ -0,0 +1,10 @@ +{ + "screen": "Chats", + "description": "Each filter narrows the visible list to its chip, and only All shows the archived row.", + "steps": [ + {"do": "ChipsRevealed", "expect": {"chipsRevealed": true, "showsChips": true}}, + {"do": "FilterSelected", "input": {"filter": "Unread"}, "expect": {"filter": "Unread", "visibleChatIds": ["01010101010101010101010101010101", "04040404040404040404040404040404"], "showsArchivedRow": false}}, + {"do": "FilterSelected", "input": {"filter": "Groups"}, "expect": {"filter": "Groups", "visibleChatIds": ["01010101010101010101010101010101", "03030303030303030303030303030303"], "showsArchivedRow": false}}, + {"do": "FilterSelected", "input": {"filter": "All"}, "expect": {"filter": "All", "visibleChatIds": ["01010101010101010101010101010101", "02020202020202020202020202020202", "03030303030303030303030303030303", "04040404040404040404040404040404"], "showsArchivedRow": true}} + ] +} diff --git a/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/behaviors/chats/initial-state.json b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/behaviors/chats/initial-state.json new file mode 100644 index 000000000..48ffaf0a7 --- /dev/null +++ b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/behaviors/chats/initial-state.json @@ -0,0 +1,12 @@ +{ + "screen": "Chats", + "description": "The seeded list opens on All, with chips hidden and the archived row showing.", + "steps": [ + {"ls": true, "expect": { + "loading": false, "filter": "All", "chipsRevealed": false, "showsChips": false, + "isSelecting": false, "selection": null, "showsArchivedRow": true, "hasNoChatsAtAll": false, + "visibleChatIds": ["01010101010101010101010101010101", "02020202020202020202020202020202", "03030303030303030303030303030303", "04040404040404040404040404040404"], + "unreadChipCount": 2, "groupsChipCount": 1, "archivedRowCount": 1, "tabBadge": 2 + }} + ] +} diff --git a/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/behaviors/chats/selection.json b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/behaviors/chats/selection.json new file mode 100644 index 000000000..10befb824 --- /dev/null +++ b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/behaviors/chats/selection.json @@ -0,0 +1,11 @@ +{ + "screen": "Chats", + "description": "Select mode opens from the More menu with nothing picked, toggling adds and removes rows, ending clears it.", + "steps": [ + {"do": "SelectionStarted", "expect": {"isSelecting": true, "selection": []}}, + {"do": "SelectionToggled", "input": {"chatId": "01010101010101010101010101010101"}, "expect": {"selection": ["01010101010101010101010101010101"]}}, + {"do": "SelectionToggled", "input": {"chatId": "03030303030303030303030303030303"}, "expect": {"selection": ["01010101010101010101010101010101", "03030303030303030303030303030303"]}}, + {"do": "SelectionToggled", "input": {"chatId": "01010101010101010101010101010101"}, "expect": {"isSelecting": true, "selection": ["03030303030303030303030303030303"]}}, + {"do": "SelectionEnded", "expect": {"isSelecting": false, "selection": null}} + ] +} From 006abd38bddd33fa40bbad00f7ef130b08e4b799 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 9 Nov 2026 16:38:36 -0500 Subject: [PATCH 2/4] feat(chats): run the Chats Puppet host as a process ChatsPuppetHost moves out of ChatsBehaviorTests into a ChatsPuppet library in FlipcashCoreVectors, so the test and a new chats-puppet-host executable answer the same requests. The executable reads one JSON request per line on stdin (ls, do, reset) for the orchestrator's puppet CLI, in the same shape as the Android host. Built with swift build --product chats-puppet-host, it runs on the Mac with no simulator; the four Chats behavior files take 0.07 s through the CLI. --- FlipcashCoreVectors/Package.swift | 13 + .../Sources/ChatsPuppet/ChatsPuppetHost.swift | 259 ++++++++++++++++++ .../Sources/ChatsPuppetHostMain/main.swift | 18 ++ .../ChatsBehaviorTests.swift | 134 +-------- 4 files changed, 295 insertions(+), 129 deletions(-) create mode 100644 FlipcashCoreVectors/Sources/ChatsPuppet/ChatsPuppetHost.swift create mode 100644 FlipcashCoreVectors/Sources/ChatsPuppetHostMain/main.swift diff --git a/FlipcashCoreVectors/Package.swift b/FlipcashCoreVectors/Package.swift index 8026c97f5..c89d6ab55 100644 --- a/FlipcashCoreVectors/Package.swift +++ b/FlipcashCoreVectors/Package.swift @@ -16,14 +16,27 @@ let package = Package( .macOS("15.0"), // FlipcashCore's transitive deps (BigDecimal 13.3, grpc/nio) need >= 13.3 .iOS("18.0"), ], + products: [ + // The Chats Puppet host for the orchestrator's `puppet` CLI (`docs/puppet/` there). + .executable(name: "chats-puppet-host", targets: ["ChatsPuppetHostMain"]), + ], dependencies: [ .package(path: "../FlipcashCore"), .package(url: "https://github.com/mgriebling/BigDecimal", from: "3.0.2"), // to assert curve values ], targets: [ + .target( + name: "ChatsPuppet", + dependencies: [.product(name: "FlipcashCore", package: "FlipcashCore")] + ), + .executableTarget( + name: "ChatsPuppetHostMain", + dependencies: ["ChatsPuppet"] + ), .testTarget( name: "FlipcashCoreVectorsTests", dependencies: [ + "ChatsPuppet", .product(name: "FlipcashCore", package: "FlipcashCore"), .product(name: "BigDecimal", package: "BigDecimal"), ], diff --git a/FlipcashCoreVectors/Sources/ChatsPuppet/ChatsPuppetHost.swift b/FlipcashCoreVectors/Sources/ChatsPuppet/ChatsPuppetHost.swift new file mode 100644 index 000000000..78e9d85c3 --- /dev/null +++ b/FlipcashCoreVectors/Sources/ChatsPuppet/ChatsPuppetHost.swift @@ -0,0 +1,259 @@ +// +// ChatsPuppetHost.swift +// ChatsPuppet +// + +import Foundation +import FlipcashCore + +/// The Chats screen's Puppet host: the seeded ``ChatsModel`` behind the request and response shapes +/// in the orchestrator's `docs/puppet/PROTOCOL.md`. `ChatsBehaviorTests` replays the shared behavior +/// files through it in-process, and `chats-puppet-host` answers the same requests as lines on stdin +/// for the orchestrator's `puppet` CLI. +/// +/// ``ChatsModel`` is synchronous and reads no services, so there is nothing to settle before a +/// reply and nothing for a fixture guard to catch. +public struct ChatsPuppetHost { + + public enum ErrorCode: String, Error { + case badRequest = "bad_request" + case unknownAction = "unknown_action" + case badInput = "bad_input" + } + + public static let screen = "Chats" + + private var model = ChatsModel() + + public init() { + model.update(.project(Self.seed)) + } + + /// The seed table in `behaviors/chats/README.md`, newest first. + static let seed: [ChatListEntry] = { + func id(_ prefix: String) -> String { String(repeating: prefix, count: 16) } + func entry(_ id: String, _ type: ConversationType, unread: Int, archived: Bool = false, age: Int) -> ChatListEntry { + ChatListEntry( + id: id, + type: type, + lastActivity: Date(timeIntervalSince1970: TimeInterval(1_000_000 - age)), + isArchived: archived, + isMuted: false, + isHidden: false, + unread: .count(unread) + ) + } + return [ + entry(id("01"), .group, unread: 3, age: 1), + entry(id("02"), .tipDm, unread: 0, age: 2), + entry(id("03"), .group, unread: 0, age: 3), + entry(id("04"), .tipDm, unread: 1, age: 4), + entry("old-crew", .group, unread: 2, archived: true, age: 5), + ] + }() + + // MARK: - Actions + + /// Each action's input schema, in the form the Android host lists them. + public static let actions: [(name: String, input: JSON)] = { + func schema(_ properties: [String: JSON], required: [String] = []) -> JSON { + .object([ + "type": .string("object"), + "properties": .object(properties), + "required": .array(required.map(JSON.string)), + ]) + } + let chatId: JSON = .object(["type": .string("string"), "format": .string("ChatId")]) + return [ + ("FilterSelected", schema( + ["filter": .object(["type": .string("string"), "enum": .array(ChatListFilter.allCases.map { .string($0.rawValue) })])], + required: ["filter"] + )), + ("ChipsRevealed", schema([:])), + ("SelectionStarted", schema([:])), + ("SelectionToggled", schema(["chatId": chatId], required: ["chatId"])), + ("SelectionEnded", schema([:])), + ] + }() + + public mutating func perform(_ action: String, input: [String: JSON]) -> Result { + switch action { + case "FilterSelected": + guard case .string(let raw)? = input["filter"], let filter = ChatListFilter(rawValue: raw) else { + return .failure(.badInput) + } + model.select(filter) + case "ChipsRevealed": + model.revealChips() + case "SelectionStarted": + // iOS enters select mode from the More menu with nothing picked; a starting chat isn't + // part of its API, so `chatId` is checked but not applied. + if let chatId = input["chatId"] { + switch chatId { + case .string, .null: break + default: return .failure(.badInput) + } + } + model.beginSelection() + case "SelectionToggled": + guard case .string(let id)? = input["chatId"] else { return .failure(.badInput) } + model.toggle(id) + case "SelectionEnded": + model.endSelection() + default: + return .failure(.unknownAction) + } + return .success(()) + } + + // MARK: - State + + /// The state keys in `behaviors/chats/README.md` that iOS computes in the model. `chats` and + /// `canReadAll` need `Conversation` and the controller, so they are absent. + public func state() -> [String: JSON] { + func ids(_ ids: [String]) -> JSON { .array(ids.map(JSON.string)) } + var state: [String: JSON] = [ + "loading": .bool(model.isLoading), + "filter": .string(model.filter.rawValue), + "chipsRevealed": .bool(model.chipsRevealed), + "showsChips": .bool(model.showsChips), + "isSelecting": .bool(model.isSelecting), + "selection": model.isSelecting ? ids(model.selected.sorted()) : .null, + "showsArchivedRow": .bool(model.showsArchivedRow), + "hasNoChatsAtAll": .bool(model.hasNoChatsAtAll), + "visibleChatIds": ids(model.visibleIDs), + ] + if let p = model.projection { + state["chips"] = .object(Dictionary(uniqueKeysWithValues: ChatListFilter.allCases.map { ($0.rawValue, ids($0.ids(in: p))) })) + state["unreadChipCount"] = .number(Double(p.unreadChipCount)) + state["groupsChipCount"] = .number(Double(p.groupsChipCount)) + state["archivedRowCount"] = .number(Double(p.archivedRowCount)) + state["tabBadge"] = .number(Double(p.tabBadge)) + } + return state + } + + private func node() -> JSON { + .object([ + "name": .string(Self.screen), + "state": .object(state()), + "actions": .array(Self.actions.map { .object(["name": .string($0.name), "input": $0.input]) }), + ]) + } + + // MARK: - Requests + + /// Answers one request object: `ls`, `do` or `reset`. + public mutating func handle(_ request: JSON) -> JSON { + guard case .object(let req) = request, case .string(let cmd)? = req["cmd"] else { + return Self.error(.badRequest, "a request is an object with a cmd") + } + if case .array(let path)? = req["path"], path != [.string(Self.screen)] { + return Self.error(.badRequest, "no node at \(JSON.array(path))") + } + switch cmd { + case "ls": + break + case "reset": + self = ChatsPuppetHost() + case "do": + guard case .string(let action)? = req["action"] else { + return Self.error(.badRequest, "do needs an action") + } + var input: [String: JSON] = [:] + switch req["input"] { + case nil, .null?: break + case .object(let o)?: input = o + default: return Self.error(.badRequest, "input must be an object") + } + if case .failure(let code) = perform(action, input: input) { + switch code { + case .unknownAction: + return .object([ + "ok": .bool(false), + "error": .object([ + "code": .string(code.rawValue), + "message": .string("\(Self.screen) has no action \(action)"), + "actions": .array(Self.actions.map { .string($0.name) }), + ]), + ]) + case .badInput: + let schema = Self.actions.first { $0.name == action }?.input ?? .null + return .object([ + "ok": .bool(false), + "error": .object([ + "code": .string(code.rawValue), + "message": .string("input doesn't match \(action)'s schema"), + "schema": schema, + ]), + ]) + case .badRequest: + return Self.error(code, "bad request") + } + } + default: + return Self.error(.badRequest, "unknown cmd \(cmd)") + } + return .object(["ok": .bool(true), "path": .array([.string(Self.screen)]), "node": node()]) + } + + /// Answers one request line with one response line. + public mutating func handle(line: String) -> String { + let response: JSON + if let request = try? JSONDecoder().decode(JSON.self, from: Data(line.utf8)) { + response = handle(request) + } else { + response = Self.error(.badRequest, "not JSON") + } + let data = (try? JSONEncoder().encode(response)) ?? Data(#"{"ok":false}"#.utf8) + return String(decoding: data, as: UTF8.self) + } + + private static func error(_ code: ErrorCode, _ message: String) -> JSON { + .object(["ok": .bool(false), "error": .object(["code": .string(code.rawValue), "message": .string(message)])]) + } +} + +/// A JSON value, compared exactly: a boolean never equals a number. +public enum JSON: Codable, Equatable, Sendable, CustomStringConvertible { + case null + case bool(Bool) + case number(Double) + case string(String) + case array([JSON]) + case object([String: JSON]) + + public init(from decoder: Decoder) throws { + let c = try decoder.singleValueContainer() + if c.decodeNil() { self = .null } + else if let b = try? c.decode(Bool.self) { self = .bool(b) } + else if let n = try? c.decode(Double.self) { self = .number(n) } + else if let s = try? c.decode(String.self) { self = .string(s) } + else if let a = try? c.decode([JSON].self) { self = .array(a) } + else { self = .object(try c.decode([String: JSON].self)) } + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.singleValueContainer() + switch self { + case .null: try c.encodeNil() + case .bool(let b): try c.encode(b) + case .number(let n): + if n == n.rounded(), abs(n) < 1e15 { try c.encode(Int(n)) } else { try c.encode(n) } + case .string(let s): try c.encode(s) + case .array(let a): try c.encode(a) + case .object(let o): try c.encode(o) + } + } + + public var description: String { + switch self { + case .null: "null" + case .bool(let b): "\(b)" + case .number(let n): n == n.rounded() ? "\(Int(n))" : "\(n)" + case .string(let s): "\"\(s)\"" + case .array(let a): "[" + a.map(\.description).joined(separator: ", ") + "]" + case .object(let o): "{" + o.keys.sorted().map { "\($0): \(o[$0]!)" }.joined(separator: ", ") + "}" + } + } +} diff --git a/FlipcashCoreVectors/Sources/ChatsPuppetHostMain/main.swift b/FlipcashCoreVectors/Sources/ChatsPuppetHostMain/main.swift new file mode 100644 index 000000000..1823b4c1d --- /dev/null +++ b/FlipcashCoreVectors/Sources/ChatsPuppetHostMain/main.swift @@ -0,0 +1,18 @@ +// +// main.swift +// chats-puppet-host +// +// The Chats Puppet host as a process: one JSON request per line on stdin, one response per line +// on stdout. The orchestrator's `puppet` CLI starts it and keeps it running across behavior files, +// sending `reset` between them. +// + +import Foundation +import ChatsPuppet + +setvbuf(stdout, nil, _IOLBF, 0) +var host = ChatsPuppetHost() +while let line = readLine() { + guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { continue } + print(host.handle(line: line)) +} diff --git a/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/ChatsBehaviorTests.swift b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/ChatsBehaviorTests.swift index 29dc24fab..dc23fd483 100644 --- a/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/ChatsBehaviorTests.swift +++ b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/ChatsBehaviorTests.swift @@ -5,10 +5,10 @@ import Foundation import Testing -@testable import FlipcashCore +import ChatsPuppet -/// Replays `test-vectors/behaviors/chats/*.json` against ``ChatsModel`` over the seed in that -/// directory's README. Synced copy: a failure is fixed in the canonical files in the orchestrator or +/// Replays `test-vectors/behaviors/chats/*.json` through ``ChatsPuppetHost``, the seeded +/// ``ChatsModel`` the `chats-puppet-host` process also serves. Synced copy: a failure is fixed in the canonical files in the orchestrator or /// in `ChatsModel`, never by editing the copy here. @Suite("Chats behaviors") struct ChatsBehaviorTests { @@ -26,7 +26,7 @@ struct ChatsBehaviorTests { } private func replay(_ behavior: Behavior, file: String) { - var host = ChatsHost() + var host = ChatsPuppetHost() for (index, step) in behavior.steps.enumerated() { let label = "\(file): step \(index + 1) (\(step.do ?? "ls"))" let result = step.do.map { host.perform($0, input: step.input ?? [:]) } ?? .success(()) @@ -43,7 +43,7 @@ struct ChatsBehaviorTests { case (.success, nil): break } - let state = host.node() + let state = host.state() for (key, want) in step.expect ?? [:] { guard let got = state[key] else { Issue.record("\(label): state.\(key): missing on iOS") @@ -55,99 +55,6 @@ struct ChatsBehaviorTests { } } -// MARK: - Host - -/// The seeded ``ChatsModel`` and the protocol around it: action names, input checks, the state node. -private struct ChatsHost { - - enum ErrorCode: String, Error { - case unknownAction = "unknown_action" - case badInput = "bad_input" - } - - private var model = ChatsModel() - - init() { - model.update(.project(Self.seed)) - } - - /// The seed table in `behaviors/chats/README.md`, newest first. - static let seed: [ChatListEntry] = { - func id(_ prefix: String) -> String { String(repeating: prefix, count: 16) } - func entry(_ id: String, _ type: ConversationType, unread: Int, archived: Bool = false, age: Int) -> ChatListEntry { - ChatListEntry( - id: id, - type: type, - lastActivity: Date(timeIntervalSince1970: TimeInterval(1_000_000 - age)), - isArchived: archived, - isMuted: false, - isHidden: false, - unread: .count(unread) - ) - } - return [ - entry(id("01"), .group, unread: 3, age: 1), - entry(id("02"), .tipDm, unread: 0, age: 2), - entry(id("03"), .group, unread: 0, age: 3), - entry(id("04"), .tipDm, unread: 1, age: 4), - entry("old-crew", .group, unread: 2, archived: true, age: 5), - ] - }() - - mutating func perform(_ action: String, input: [String: JSON]) -> Result { - switch action { - case "FilterSelected": - guard case .string(let raw)? = input["filter"], let filter = ChatListFilter(rawValue: raw) else { - return .failure(.badInput) - } - model.select(filter) - case "ChipsRevealed": - model.revealChips() - case "SelectionStarted": - // iOS enters select mode from the More menu with nothing picked; a starting chat isn't - // part of its API, so `chatId` is checked but not applied. - if let chatId = input["chatId"] { - guard case .string = chatId else { return .failure(.badInput) } - } - model.beginSelection() - case "SelectionToggled": - guard case .string(let id)? = input["chatId"] else { return .failure(.badInput) } - model.toggle(id) - case "SelectionEnded": - model.endSelection() - default: - return .failure(.unknownAction) - } - return .success(()) - } - - /// The state keys in `behaviors/chats/README.md` that iOS computes in the model. `chats` and - /// `canReadAll` need `Conversation` and the controller, so they are absent. - func node() -> [String: JSON] { - let p = model.projection - func ids(_ ids: [String]) -> JSON { .array(ids.map(JSON.string)) } - var state: [String: JSON] = [ - "loading": .bool(model.isLoading), - "filter": .string(model.filter.rawValue), - "chipsRevealed": .bool(model.chipsRevealed), - "showsChips": .bool(model.showsChips), - "isSelecting": .bool(model.isSelecting), - "selection": model.isSelecting ? ids(model.selected.sorted()) : .null, - "showsArchivedRow": .bool(model.showsArchivedRow), - "hasNoChatsAtAll": .bool(model.hasNoChatsAtAll), - "visibleChatIds": ids(model.visibleIDs), - ] - if let p { - state["chips"] = .object(Dictionary(uniqueKeysWithValues: ChatListFilter.allCases.map { ($0.rawValue, ids($0.ids(in: p))) })) - state["unreadChipCount"] = .number(Double(p.unreadChipCount)) - state["groupsChipCount"] = .number(Double(p.groupsChipCount)) - state["archivedRowCount"] = .number(Double(p.archivedRowCount)) - state["tabBadge"] = .number(Double(p.tabBadge)) - } - return state - } -} - // MARK: - File format private struct Behavior: Decodable { @@ -160,34 +67,3 @@ private struct Behavior: Decodable { let screen: String let steps: [Step] } - -/// A JSON value, compared exactly: a boolean never equals a number. -private enum JSON: Decodable, Equatable, CustomStringConvertible { - case null - case bool(Bool) - case number(Double) - case string(String) - case array([JSON]) - case object([String: JSON]) - - init(from decoder: Decoder) throws { - let c = try decoder.singleValueContainer() - if c.decodeNil() { self = .null } - else if let b = try? c.decode(Bool.self) { self = .bool(b) } - else if let n = try? c.decode(Double.self) { self = .number(n) } - else if let s = try? c.decode(String.self) { self = .string(s) } - else if let a = try? c.decode([JSON].self) { self = .array(a) } - else { self = .object(try c.decode([String: JSON].self)) } - } - - var description: String { - switch self { - case .null: "null" - case .bool(let b): "\(b)" - case .number(let n): n == n.rounded() ? "\(Int(n))" : "\(n)" - case .string(let s): "\"\(s)\"" - case .array(let a): "[" + a.map(\.description).joined(separator: ", ") + "]" - case .object(let o): "{" + o.keys.sorted().map { "\($0): \(o[$0]!)" }.joined(separator: ", ") + "}" - } - } -} From ed2be005cbbe2422706858afab6ba57b0e98cf46 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 9 Nov 2026 16:49:11 -0500 Subject: [PATCH 3/4] fix(vectors): build the renamed vectors scheme in CI Adding the ChatsPuppet library and host targets renames the package's Xcode scheme from FlipcashCoreVectors-Package to FlipcashCoreVectors, so the vectors job's xcodebuild test call would fail to find its scheme. SelectionStarted's input schema now lists the optional chatId, matching what perform accepts and what the Android host reports. --- .github/workflows/vectors.yml | 2 +- FlipcashCoreVectors/Sources/ChatsPuppet/ChatsPuppetHost.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/vectors.yml b/.github/workflows/vectors.yml index 88ac70780..419c278eb 100644 --- a/.github/workflows/vectors.yml +++ b/.github/workflows/vectors.yml @@ -87,7 +87,7 @@ jobs: - name: FlipcashCore vectors working-directory: FlipcashCoreVectors run: | - xcodebuild test -scheme FlipcashCoreVectors-Package \ + xcodebuild test -scheme FlipcashCoreVectors \ -destination "platform=iOS Simulator,id=${{ steps.sim.outputs.udid }}" \ -derivedDataPath "$GITHUB_WORKSPACE/.derived/core" \ -quiet diff --git a/FlipcashCoreVectors/Sources/ChatsPuppet/ChatsPuppetHost.swift b/FlipcashCoreVectors/Sources/ChatsPuppet/ChatsPuppetHost.swift index 78e9d85c3..882ed751f 100644 --- a/FlipcashCoreVectors/Sources/ChatsPuppet/ChatsPuppetHost.swift +++ b/FlipcashCoreVectors/Sources/ChatsPuppet/ChatsPuppetHost.swift @@ -70,7 +70,7 @@ public struct ChatsPuppetHost { required: ["filter"] )), ("ChipsRevealed", schema([:])), - ("SelectionStarted", schema([:])), + ("SelectionStarted", schema(["chatId": chatId])), ("SelectionToggled", schema(["chatId": chatId], required: ["chatId"])), ("SelectionEnded", schema([:])), ] From 5df2718b2a02700d2ff34ba92eee3a0edb3f0318 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 9 Nov 2026 18:34:40 -0500 Subject: [PATCH 4/4] feat(chats): drive the chat list from ChatsModel TipConversationsScreen ran its filter, chip and selection rules itself, so the Puppet host tested a copy the app never used. The screen now reads ChatsModel's state and sends it actions: - Opening a chat, the archived list and a new chat go through ChatsDestination; the screen maps each to the existing router push. - ChatSelection wraps ChatListSelectionMode from FlipcashCore, so the tab bar's select actions and the host share one set of rules. - The Puppet host answers launch, keeps a stack of pushed screens and lends Back above the root. ChatsBehaviorTests replays files through the host's handle(), the same path the CLI uses. navigation.json is the synced copy from the orchestrator. --- .../Screens/Main/Tips/ChatReadActions.swift | 30 ++---- .../Main/Tips/TipConversationsScreen.swift | 45 +++++---- .../Models/Conversation/ChatsModel.swift | 99 ++++++++++++++++--- .../Sources/ChatsPuppet/ChatsPuppetHost.swift | 91 +++++++++++++---- .../ChatsBehaviorTests.swift | 50 +++++++--- .../Fixtures/behaviors/chats/README.md | 14 +++ .../Fixtures/behaviors/chats/navigation.json | 17 ++++ 7 files changed, 263 insertions(+), 83 deletions(-) create mode 100644 FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/behaviors/chats/navigation.json diff --git a/Flipcash/Core/Screens/Main/Tips/ChatReadActions.swift b/Flipcash/Core/Screens/Main/Tips/ChatReadActions.swift index 87de72def..4582f69e2 100644 --- a/Flipcash/Core/Screens/Main/Tips/ChatReadActions.swift +++ b/Flipcash/Core/Screens/Main/Tips/ChatReadActions.swift @@ -61,34 +61,22 @@ enum ChatReadSource: Equatable { @Observable final class ChatSelection { - private(set) var isActive = false - private(set) var selected: Set = [] + /// The rules, shared with ``ChatsModel`` so the Puppet host checks the same ones. + private var mode = ChatListSelectionMode() + + var isActive: Bool { mode.isActive } + var selected: Set { mode.selected } /// Enters select mode with nothing selected. - func begin() { - selected = [] - isActive = true - } + func begin() { mode.begin() } /// Leaves select mode. - func end() { - isActive = false - selected = [] - } + func end() { mode.end() } - func toggle(_ id: ConversationID) { - if selected.contains(id) { - selected.remove(id) - } else { - selected.insert(id) - } - } + func toggle(_ id: ConversationID) { mode.toggle(id) } /// Drops picks that are no longer in the list, so an archived or left chat isn't acted on. - func retain(_ ids: Set) { - let kept = selected.intersection(ids) - if kept != selected { selected = kept } - } + func retain(_ ids: Set) { mode.retain(ids) } } /// What the select-mode read capsule does. diff --git a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift index 0d2ff29ba..a68b08220 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift @@ -18,12 +18,12 @@ struct TipConversationsScreen: View { var showsSearchButton = true @State private var muteTarget: MuteTarget? - @State private var filter: ChatListFilter = .all + /// The filter, chip reveal and where a tap goes. Shared with the Puppet host, which replays the + /// Chats behavior files against it. + @State private var model = ChatsModel() @Environment(ToastController.self) private var toasts @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.floatingTabBar) private var floatingTabBar - /// Set by a pull down that starts at the top of the list; cleared when the screen goes away. - @State private var chipsRevealed = false @State private var isTabBarOnSide = false /// The chip row's height: while hidden, the list rests scrolled down by exactly this much. @State private var chipsHeight: CGFloat = 0 @@ -48,16 +48,29 @@ struct TipConversationsScreen: View { conversationController.chatListConversations.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first } ) - return filter.ids(in: projection).compactMap { byID[$0] } + return model.visibleIDs(in: projection).compactMap { byID[$0] } } /// A selected filter keeps the chips in view: they explain the list. - private var showsChips: Bool { chipsRevealed || filter != .all } + private var showsChips: Bool { model.showsChips } + + private var filter: Binding { + Binding(get: { model.filter }, set: { model.select($0) }) + } + + /// Pushes the screen ``ChatsModel`` sends the user to. + private func open(_ destination: ChatsDestination) { + switch destination { + case .chat(let id): router.push(.tipConversation(id)) + case .archived: router.push(.archivedChats) + case .newChat: router.push(.newChat) + } + } var body: some View { let projection = self.projection let conversations = rows(projection) - let nothingAtAll = projection.main.isEmpty && !projection.archivedRowVisible + let nothingAtAll = ChatsModel.hasNoChatsAtAll(in: projection) Background(color: .backgroundMain) { if nothingAtAll { @@ -73,7 +86,7 @@ struct TipConversationsScreen: View { // finger. The chips use a custom button style, which keeps each chip's tap // its own inside the row. FadingChips(fade: chipFade, isShown: showsChips) { - ChatListChips(selection: $filter, projection: projection) + ChatListChips(selection: filter, projection: projection) } .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { chipsHeight = $0 @@ -83,16 +96,16 @@ struct TipConversationsScreen: View { chipsHeight: chipsHeight, isRevealed: showsChips, fade: chipFade, - reveal: { chipsRevealed = true } + reveal: { model.revealChips() } ) } .listRowInsets(EdgeInsets()) .listRowSeparator(.hidden) .listRowBackground(Color.clear) // Only under All: the other filters narrow the list, and archived chats aren't in it. - if projection.archivedRowVisible, filter == .all { + if model.showsArchivedRow(in: projection) { ArchivedRow(count: projection.archivedRowCount) { - router.push(.archivedChats) + open(model.openArchived()) } .listRowSeparator(.hidden, edges: .top) .listRowBackground(Color.clear) @@ -105,7 +118,7 @@ struct TipConversationsScreen: View { if isSelecting { selection.toggle(conversation.id) } else { - router.push(.tipConversation(conversation.id)) + open(model.openChat(conversation.id)) } } // Separators divide rows from each other; the first @@ -165,7 +178,7 @@ struct TipConversationsScreen: View { // Over the List rather than a row in it, so it centres in the whole // screen and the List keeps the pull that reveals the chips. if conversations.isEmpty { - Text(filter.emptyMessage) + Text(model.filter.emptyMessage) .font(.appTextMedium) .foregroundStyle(Color.textSecondary) .allowsHitTesting(false) @@ -312,7 +325,7 @@ struct TipConversationsScreen: View { } } ToolbarItem(placement: .topBarTrailing) { - NewChatButton() + NewChatButton { open(model.openNewChat()) } } } @@ -401,12 +414,10 @@ private struct MuteTarget: Identifiable { /// under it. private struct NewChatButton: View { - @Environment(AppRouter.self) private var router + let action: () -> Void var body: some View { - Button { - router.push(.newChat) - } label: { + Button(action: action) { // Bar items get their glass from the system on iOS 26, so this // carries no button style of its own. Image.system(.plus) diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatsModel.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatsModel.swift index 15643ca58..5d3f94f82 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatsModel.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatsModel.swift @@ -17,13 +17,16 @@ public struct ChatsModel: Sendable { public private(set) var filter: ChatListFilter = .all /// Set by a pull down that starts at the top of the list. public private(set) var chipsRevealed = false - /// Multi-select mode. - public private(set) var isSelecting = false - /// The picked chats while ``isSelecting``. - public private(set) var selected: Set = [] + /// Multi-select mode and its picks. + public private(set) var selection = ChatListSelectionMode() public init() {} + /// Multi-select mode is on. + public var isSelecting: Bool { selection.isActive } + /// The picked chats while ``isSelecting``. + public var selected: Set { selection.selected } + /// The list hasn't loaded yet. public var isLoading: Bool { projection == nil } @@ -32,26 +35,42 @@ public struct ChatsModel: Sendable { /// The rows the current filter shows, in order. public var visibleIDs: [ID] { - projection.map { filter.ids(in: $0) } ?? [] + projection.map(visibleIDs(in:)) ?? [] } /// The Archived row shows, on the All filter only. public var showsArchivedRow: Bool { - filter == .all && (projection?.archivedRowVisible ?? false) + projection.map(showsArchivedRow(in:)) ?? false } /// No chats at all, archived included: the empty state shows. public var hasNoChatsAtAll: Bool { - guard let projection else { return false } - return projection.main.isEmpty && !projection.archivedRowVisible + projection.map(Self.hasNoChatsAtAll(in:)) ?? false + } + + // The same rules over a projection the caller holds. The screen reads the controller's live + // projection each body pass, so it asks these rather than the stored copy. + + /// The rows the current filter shows in `projection`, in order. + public func visibleIDs(in projection: ChatListProjection) -> [ID] { + filter.ids(in: projection) + } + + /// The Archived row shows in `projection`, on the All filter only. + public func showsArchivedRow(in projection: ChatListProjection) -> Bool { + filter == .all && projection.archivedRowVisible + } + + /// `projection` has no chats at all, archived included. + public static func hasNoChatsAtAll(in projection: ChatListProjection) -> Bool { + projection.main.isEmpty && !projection.archivedRowVisible } /// Takes a new projection and drops picks no longer listed, so an archived or left chat isn't /// acted on. public mutating func update(_ projection: ChatListProjection) { self.projection = projection - let kept = selected.intersection(projection.main) - if kept != selected { selected = kept } + selection.retain(Set(projection.main)) } /// Selects a chip. @@ -66,8 +85,58 @@ public struct ChatsModel: Sendable { /// Enters select mode with nothing selected. public mutating func beginSelection() { + selection.begin() + } + + /// Picks or unpicks one chat. + public mutating func toggle(_ id: ID) { + selection.toggle(id) + } + + /// Leaves select mode. + public mutating func endSelection() { + selection.end() + } + + // MARK: - Leaving the list + + /// The chat a row opens. + public func openChat(_ id: ID) -> ChatsDestination { .chat(id) } + + /// The Archived row's screen. + public func openArchived() -> ChatsDestination { .archived } + + /// The compose button's screen. + public func openNewChat() -> ChatsDestination { .newChat } +} + +/// Where the Chats list sends the user. The screen maps each case to the app's router, so the +/// shared behavior tests check the choice without the app target. +public enum ChatsDestination: Hashable, Sendable { + case chat(ID) + case archived + case newChat +} + +/// The Chats list's select mode: whether it is on, and which chats are picked. ``ChatsModel`` holds +/// one, and so does the app's `ChatSelection`, which the tab view owns so it can swap the tab bar +/// for the select actions. +public struct ChatListSelectionMode: Equatable, Sendable { + public private(set) var isActive = false + public private(set) var selected: Set = [] + + public init() {} + + /// Enters select mode with nothing selected. + public mutating func begin() { + selected = [] + isActive = true + } + + /// Leaves select mode. + public mutating func end() { + isActive = false selected = [] - isSelecting = true } /// Picks or unpicks one chat. @@ -79,9 +148,9 @@ public struct ChatsModel: Sendable { } } - /// Leaves select mode. - public mutating func endSelection() { - isSelecting = false - selected = [] + /// Drops picks that are no longer in `ids`, so an archived or left chat isn't acted on. + public mutating func retain(_ ids: Set) { + let kept = selected.intersection(ids) + if kept != selected { selected = kept } } } diff --git a/FlipcashCoreVectors/Sources/ChatsPuppet/ChatsPuppetHost.swift b/FlipcashCoreVectors/Sources/ChatsPuppet/ChatsPuppetHost.swift index 882ed751f..b312eda8d 100644 --- a/FlipcashCoreVectors/Sources/ChatsPuppet/ChatsPuppetHost.swift +++ b/FlipcashCoreVectors/Sources/ChatsPuppet/ChatsPuppetHost.swift @@ -23,8 +23,17 @@ public struct ChatsPuppetHost { public static let screen = "Chats" + /// The only route `launch` accepts: the Chats tab, in the form both apps' deep links use. + public static let route = "/tab/chats" + private var model = ChatsModel() + /// Screens pushed over the list. They aren't hosted: each shows its name and lends `Back`. + private var stack: [ChatsDestination] = [] + + /// The screen names from the list to the top screen. + public var path: [String] { [Self.screen] + stack.map(Self.name) } + public init() { model.update(.project(Self.seed)) } @@ -54,15 +63,19 @@ public struct ChatsPuppetHost { // MARK: - Actions - /// Each action's input schema, in the form the Android host lists them. + private static func schema(_ properties: [String: JSON], required: [String] = []) -> JSON { + .object([ + "type": .string("object"), + "properties": .object(properties), + "required": .array(required.map(JSON.string)), + ]) + } + + /// `Back`, which the host lends to every screen above the list. + public static let back: (name: String, input: JSON) = ("Back", schema([:])) + + /// The list's actions and their input schemas, in the form the Android host lists them. public static let actions: [(name: String, input: JSON)] = { - func schema(_ properties: [String: JSON], required: [String] = []) -> JSON { - .object([ - "type": .string("object"), - "properties": .object(properties), - "required": .array(required.map(JSON.string)), - ]) - } let chatId: JSON = .object(["type": .string("string"), "format": .string("ChatId")]) return [ ("FilterSelected", schema( @@ -73,10 +86,29 @@ public struct ChatsPuppetHost { ("SelectionStarted", schema(["chatId": chatId])), ("SelectionToggled", schema(["chatId": chatId], required: ["chatId"])), ("SelectionEnded", schema([:])), + ("ChatOpened", schema(["chatId": chatId], required: ["chatId"])), + ("ArchivedOpened", schema([:])), + ("NewChatOpened", schema([:])), ] }() + /// The top screen's actions: the list's own, or only `Back` on a pushed screen. + public var currentActions: [(name: String, input: JSON)] { stack.isEmpty ? Self.actions : [Self.back] } + + private static func name(_ destination: ChatsDestination) -> String { + switch destination { + case .chat: "Chat" + case .archived: "ArchivedChats" + case .newChat: "NewChat" + } + } + public mutating func perform(_ action: String, input: [String: JSON]) -> Result { + guard stack.isEmpty else { + guard action == Self.back.name else { return .failure(.unknownAction) } + stack.removeLast() + return .success(()) + } switch action { case "FilterSelected": guard case .string(let raw)? = input["filter"], let filter = ChatListFilter(rawValue: raw) else { @@ -100,6 +132,13 @@ public struct ChatsPuppetHost { model.toggle(id) case "SelectionEnded": model.endSelection() + case "ChatOpened": + guard case .string(let id)? = input["chatId"] else { return .failure(.badInput) } + stack.append(model.openChat(id)) + case "ArchivedOpened": + stack.append(model.openArchived()) + case "NewChatOpened": + stack.append(model.openNewChat()) default: return .failure(.unknownAction) } @@ -134,26 +173,40 @@ public struct ChatsPuppetHost { } private func node() -> JSON { - .object([ - "name": .string(Self.screen), - "state": .object(state()), - "actions": .array(Self.actions.map { .object(["name": .string($0.name), "input": $0.input]) }), + let state: [String: JSON] + switch stack.last { + case nil: state = self.state() + case .chat(let id)?: state = ["chatId": .string(id)] + case .archived?, .newChat?: state = [:] + } + return .object([ + "name": .string(path.last!), + "state": .object(state), + "actions": .array(currentActions.map { .object(["name": .string($0.name), "input": $0.input]) }), ]) } // MARK: - Requests - /// Answers one request object: `ls`, `do` or `reset`. + /// Answers one request object: `ls`, `do`, `launch` or `reset`. public mutating func handle(_ request: JSON) -> JSON { guard case .object(let req) = request, case .string(let cmd)? = req["cmd"] else { return Self.error(.badRequest, "a request is an object with a cmd") } - if case .array(let path)? = req["path"], path != [.string(Self.screen)] { - return Self.error(.badRequest, "no node at \(JSON.array(path))") + if case .array(let requested)? = req["path"], requested != path.map(JSON.string) { + return Self.error(.badRequest, "no node at \(JSON.array(requested))") } switch cmd { case "ls": break + case "launch": + switch req["route"] { + case nil, .null?: break + case .string(Self.route)?: break + case .string(let other)?: return Self.error(.badInput, "This host launches only \(Self.route), not \(other)") + default: return Self.error(.badRequest, "route must be a string") + } + self = ChatsPuppetHost() case "reset": self = ChatsPuppetHost() case "do": @@ -173,12 +226,12 @@ public struct ChatsPuppetHost { "ok": .bool(false), "error": .object([ "code": .string(code.rawValue), - "message": .string("\(Self.screen) has no action \(action)"), - "actions": .array(Self.actions.map { .string($0.name) }), + "message": .string("\(path.last!) has no action \(action)"), + "actions": .array(currentActions.map { .string($0.name) }), ]), ]) case .badInput: - let schema = Self.actions.first { $0.name == action }?.input ?? .null + let schema = currentActions.first { $0.name == action }?.input ?? .null return .object([ "ok": .bool(false), "error": .object([ @@ -194,7 +247,7 @@ public struct ChatsPuppetHost { default: return Self.error(.badRequest, "unknown cmd \(cmd)") } - return .object(["ok": .bool(true), "path": .array([.string(Self.screen)]), "node": node()]) + return .object(["ok": .bool(true), "path": .array(path.map(JSON.string)), "node": node()]) } /// Answers one request line with one response line. diff --git a/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/ChatsBehaviorTests.swift b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/ChatsBehaviorTests.swift index dc23fd483..b7b2a3e5b 100644 --- a/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/ChatsBehaviorTests.swift +++ b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/ChatsBehaviorTests.swift @@ -25,25 +25,51 @@ struct ChatsBehaviorTests { } } + /// Sends each step to ``ChatsPuppetHost/handle(_:)``, the same request path the CLI uses: + /// `launch` first, then `ls` or `do` per step, matching `expectPath` exactly and `expect` as a + /// subset of the top node's state. private func replay(_ behavior: Behavior, file: String) { var host = ChatsPuppetHost() + var launch: [String: JSON] = ["cmd": .string("launch")] + if let route = behavior.route { launch["route"] = .string(route) } + guard case .object(let first) = host.handle(.object(launch)), first["ok"] == .bool(true), + case .object(let node)? = first["node"], node["name"] == .string(behavior.screen) else { + Issue.record("\(file): launch didn't open \(behavior.screen)") + return + } for (index, step) in behavior.steps.enumerated() { let label = "\(file): step \(index + 1) (\(step.do ?? "ls"))" - let result = step.do.map { host.perform($0, input: step.input ?? [:]) } ?? .success(()) - switch (result, step.expectError) { - case (.failure(let code), let expected?): - #expect(code.rawValue == expected, "\(label): expected error \(expected), got \(code.rawValue)") - continue - case (.failure(let code), nil): - Issue.record("\(label): unexpected error \(code.rawValue)") + var request: [String: JSON] = ["cmd": .string(step.do == nil ? "ls" : "do")] + if let action = step.do { + request["action"] = .string(action) + request["input"] = .object(step.input ?? [:]) + } + guard case .object(let response) = host.handle(.object(request)) else { + Issue.record("\(label): response isn't an object") return - case (.success, let expected?): + } + if response["ok"] != .bool(true) { + var code = "?" + if case .object(let error)? = response["error"], case .string(let c)? = error["code"] { code = c } + if let expected = step.expectError { + #expect(code == expected, "\(label): expected error \(expected), got \(code)") + continue + } + Issue.record("\(label): unexpected error \(code)") + return + } + if let expected = step.expectError { Issue.record("\(label): expected error \(expected), got success") continue - case (.success, nil): - break } - let state = host.state() + if let want = step.expectPath { + let got = response["path"] ?? .null + #expect(got == .array(want.map(JSON.string)), "\(label): path: expected \(want), got \(got)") + } + guard case .object(let top)? = response["node"], case .object(let state)? = top["state"] else { + Issue.record("\(label): response has no node state") + return + } for (key, want) in step.expect ?? [:] { guard let got = state[key] else { Issue.record("\(label): state.\(key): missing on iOS") @@ -63,7 +89,9 @@ private struct Behavior: Decodable { let input: [String: JSON]? let expect: [String: JSON]? let expectError: String? + let expectPath: [String]? } let screen: String + let route: String? let steps: [Step] } diff --git a/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/behaviors/chats/README.md b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/behaviors/chats/README.md index 506263c0b..cef899671 100644 --- a/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/behaviors/chats/README.md +++ b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/behaviors/chats/README.md @@ -41,6 +41,20 @@ Ids repeat their two-digit prefix sixteen times, e.g. `0101010101010101010101010 | `SelectionStarted` | `chatId` (optional, Android only; see below) | | `SelectionToggled` | `chatId` (required) | | `SelectionEnded` | none | +| `ChatOpened` | `chatId` (required). Pushes `Chat`. | +| `ArchivedOpened` | none. Pushes `ArchivedChats`. | +| `NewChatOpened` | none. Pushes `NewChat`. | +| `Back` | none. Lent by the host on every screen above Chats; Chats itself has no `Back`. | + +## Route and screens + +`launch` opens `/tab/chats`, the form both apps' deep links already use; hosts refuse any other +route with `bad_input`. Opening a chat, the archive or a new chat pushes a screen the host doesn't +drive yet. Its node names the screen, shows what it was opened with (`Chat`: `chatId`), and offers +only `Back`. Chats stays alive underneath, so `Back` returns to the list as it was. + +Not covered yet: switching tabs (Android's tab tap clears the stack, iOS keeps a stack per tab), +muting from a row, and tapping a row in select mode. ## Where the platforms differ diff --git a/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/behaviors/chats/navigation.json b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/behaviors/chats/navigation.json new file mode 100644 index 000000000..9397f58f7 --- /dev/null +++ b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/behaviors/chats/navigation.json @@ -0,0 +1,17 @@ +{ + "screen": "Chats", + "route": "/tab/chats", + "description": "Opening a chat, the archive and a new chat pushes that screen; Back returns to the list as it was.", + "steps": [ + {"ls": true, "expectPath": ["Chats"]}, + {"do": "Back", "expectError": "unknown_action"}, + {"do": "FilterSelected", "input": {"filter": "Groups"}, "expect": {"filter": "Groups"}}, + {"do": "ChatOpened", "input": {"chatId": "03030303030303030303030303030303"}, "expectPath": ["Chats", "Chat"], "expect": {"chatId": "03030303030303030303030303030303"}}, + {"do": "FilterSelected", "input": {"filter": "All"}, "expectError": "unknown_action"}, + {"do": "Back", "expectPath": ["Chats"], "expect": {"filter": "Groups", "visibleChatIds": ["01010101010101010101010101010101", "03030303030303030303030303030303"]}}, + {"do": "ArchivedOpened", "expectPath": ["Chats", "ArchivedChats"]}, + {"do": "Back", "expectPath": ["Chats"]}, + {"do": "NewChatOpened", "expectPath": ["Chats", "NewChat"]}, + {"do": "Back", "expectPath": ["Chats"], "expect": {"filter": "Groups"}} + ] +}