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/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/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/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..5d3f94f82 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatsModel.swift @@ -0,0 +1,156 @@ +// +// 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 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 } + + /// 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(visibleIDs(in:)) ?? [] + } + + /// The Archived row shows, on the All filter only. + public var showsArchivedRow: Bool { + projection.map(showsArchivedRow(in:)) ?? false + } + + /// No chats at all, archived included: the empty state shows. + public var hasNoChatsAtAll: Bool { + 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 + selection.retain(Set(projection.main)) + } + + /// 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() { + 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 = [] + } + + /// Picks or unpicks one chat. + public mutating func toggle(_ id: ID) { + if selected.contains(id) { + selected.remove(id) + } else { + selected.insert(id) + } + } + + /// 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/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..b312eda8d --- /dev/null +++ b/FlipcashCoreVectors/Sources/ChatsPuppet/ChatsPuppetHost.swift @@ -0,0 +1,312 @@ +// +// 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" + + /// 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)) + } + + /// 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 + + 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)] = { + 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(["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 { + 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() + 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) + } + 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 { + 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`, `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 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": + 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("\(path.last!) has no action \(action)"), + "actions": .array(currentActions.map { .string($0.name) }), + ]), + ]) + case .badInput: + let schema = currentActions.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(path.map(JSON.string)), "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 new file mode 100644 index 000000000..b7b2a3e5b --- /dev/null +++ b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/ChatsBehaviorTests.swift @@ -0,0 +1,97 @@ +// +// ChatsBehaviorTests.swift +// FlipcashCoreVectorsTests +// + +import Foundation +import Testing +import ChatsPuppet + +/// 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 { + + @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) + } + } + + /// 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"))" + 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 + } + 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 + } + 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") + continue + } + #expect(got == want, "\(label): state.\(key): expected \(want), got \(got)") + } + } + } +} + +// 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 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 new file mode 100644 index 000000000..cef899671 --- /dev/null +++ b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/behaviors/chats/README.md @@ -0,0 +1,64 @@ +# 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 | +| `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 + +- **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/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"}} + ] +} 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}} + ] +}