Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,7 @@ Types: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`
| Calling `router.present(.x)` next to a viewmodel mutator that may *block* the flow | A viewmodel that surfaces a blocking error via `session.dialogItem` (e.g. `GiveViewModel.showNoBalanceError`) does not stop the router — `DialogWindow` renders the dialog above the sheet, but the sheet is still presented underneath and reappears once the dialog is dismissed. Gate the router on the precondition: expose `attemptPresent() -> Bool` on the viewmodel and write `if vm.attemptPresent() { router.present(.x) }`. Putting the check inside an `isPresented` `didSet` is not enough — the router call still runs unconditionally on the next line. |
| Parsing keypad-emitted amounts with `Decimal(string:)` or `NumberFormatter.decimal(from:)` | `KeyPadView`'s decimal key inserts `AmountValidator.localizedDecimalSeparator`, so on comma-decimal locales the bound string contains ",". `Decimal(string:)` stops at the comma and silently drops the fraction; `NumberFormatter.decimal(from:)` only parses the device locale's format. **Parse keypad strings with `AmountValidator`** (in FlipcashCore's Validation family) — it normalizes the locale separator before parsing. `NumberFormatter.decimal(from:)` remains appropriate for currency-formatted strings (already through a formatter, locale-correct). |
| Injecting shared DI via a custom keyPath `@Environment(\.key)` with a trapping default | SwiftUI resolves keyPath env **eagerly** during dynamic-list/transition `DynamicProperty` updates (against a placeholder environment), so a `fatalError`/`preconditionFailure` default fires and crashes at launch (`<dep> was not injected`). Inject shared DI as **type-based `@Environment(Type.self)` on an `@Observable`** — the trap is deferred to body access, so it survives the eager pass. That's why `Container`/`SessionContainer` are `@Observable`. Safe-value `@Entry` keyPath defaults (e.g. `nestedSheetDepth = 0`) are unaffected — the hazard is specifically a *crashing* default. |
| Touching the chat transcript's diff/batch pipeline without the fence | ChatLayout's `restoreContentOffset` answers attribute queries with nil while it re-anchors; if that forced layout pass overlaps animated layout work (a settling batch spring, an inset write inside an animation context), UIKit throws `NSInternalInconsistencyException: missing final attributes for cell`. Every animated transaction in `ChatViewController` brackets itself on `ChatAnimationFence`, and every mutation that must not overlap one (transcript push, `setBottomInset`, offset restores, `freezeInset`/`restoreInset`) defers through `fence.whenIdle` and applies insets via the upstream recipe (`performWithoutAnimation` + empty-batch interrupt + write inside `performBatchUpdates` + restore in the same transaction). Also: **never set transforms on `ChatLayoutAttributes`** — the layout round-trips attribute frames through its keep-at-bottom compensation, so a scale gets baked into stored frames; entrance motion goes on the *cell* in `willDisplay` (`playEntranceIfNeeded`). Contract pinned by `ChatAnimationFenceTests`, the fence section of `ChatViewControllerTests`, and the baseline-attributes tests in `ChatMotionTests`. |

---

Expand Down
80 changes: 80 additions & 0 deletions FlipcashTests/Chat/ChatAnimationFenceTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
//
// ChatAnimationFenceTests.swift
// FlipcashTests
//
// Copyright © 2026 Code Inc. All rights reserved.
//

import Testing
@testable import FlipcashUI

/// The fence is the transcript's crash guard: ChatLayout's `restoreContentOffset` answers
/// attribute queries with nil while it re-anchors, so inset writes, offset restores, and
/// transcript pushes must never overlap an animated transaction — UIKit throws
/// `NSInternalInconsistencyException: missing final attributes for cell` when they collide.
/// These tests pin the serialization contract every chat-transcript mutation relies on.
@MainActor
@Suite("Chat animation fence")
struct ChatAnimationFenceTests {

@Test("Work runs immediately while idle")
func idle_runsImmediately() {
let fence = ChatAnimationFence()
var ran = false
fence.whenIdle { ran = true }
#expect(ran)
}

@Test("Work defers while any transaction is active and replays when the last settles")
func active_defersUntilLastEnd() {
let fence = ChatAnimationFence()
fence.begin()
fence.begin()
var ran = false
fence.whenIdle { ran = true }
fence.end()
#expect(!ran, "one transaction is still in flight")
fence.end()
#expect(ran)
}

@Test("Deferred work replays in registration order")
func drain_replaysInOrder() {
let fence = ChatAnimationFence()
fence.begin()
var order: [Int] = []
fence.whenIdle { order.append(1) }
fence.whenIdle { order.append(2) }
fence.end()
#expect(order == [1, 2])
}

@Test("A replay that begins a new transaction re-defers the work queued behind it")
func drain_replayBeginningTransaction_reDefersRemainder() {
// The crash shape: a held transcript push replays and starts a new batch; a held inset
// write queued behind it must wait for that batch too — never run inside it.
let fence = ChatAnimationFence()
fence.begin()
var insetApplied = false
fence.whenIdle { fence.begin() } // the replayed push opens its own transaction
fence.whenIdle { insetApplied = true } // the inset write queued behind it
fence.end()
#expect(!insetApplied, "the remainder must wait behind the replay's transaction")
fence.end()
#expect(insetApplied)
}

@Test("Work registered during a drain joins the queue instead of jumping it")
func drain_nestedWhenIdle_runsAfterCurrentQueue() {
let fence = ChatAnimationFence()
fence.begin()
var order: [Int] = []
fence.whenIdle {
order.append(1)
fence.whenIdle { order.append(3) } // registered mid-drain
}
fence.whenIdle { order.append(2) }
fence.end()
#expect(order == [1, 2, 3])
}
}
53 changes: 18 additions & 35 deletions FlipcashTests/Chat/ChatChangesetFlatteningTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,39 +19,22 @@ import FlipcashCore
@Suite("Chat changeset flattening")
struct ChatChangesetFlatteningTests {

private func message(
_ id: String,
sender: ChatMessage.Sender = .me,
continuedByNext: Bool = false,
continuationFromPrevious: Bool = false,
receipt: String? = nil
) -> ChatItem {
.message(ChatMessage(
id: id,
text: "text-\(id)",
sender: sender,
isContinuationFromPrevious: continuationFromPrevious,
isContinuedByNext: continuedByNext,
receipt: receipt
))
}

@Test("A send (previous-row update + insert) flattens to a single changeset")
func updateAndInsert_flattensToOne() {
func updateAndInsert_flattensToOne() throws {
// A new own message migrates the receipt off the previous row and flips its grouping —
// an update — while the new row is an insert. DifferenceKit stages these separately.
let before = [message("a", receipt: "Delivered")]
let after = [
message("a", continuedByNext: true),
message("b", continuationFromPrevious: true, receipt: "Delivered"),
let before: [ChatItem] = [.text("a", receipt: "Delivered")]
let after: [ChatItem] = [
.text("a", continuedByNext: true),
.text("b", continuationFromPrevious: true, receipt: "Delivered"),
]

let staged = StagedChangeset(source: before, target: after)
#expect(staged.count == 2, "premise: DifferenceKit stages [updates]+[inserts] separately")

let flattened = staged.flattenIfPossible()
#expect(flattened.count == 1)
guard let merged = flattened.first else { return }
let merged = try #require(flattened.first)
#expect(merged.elementUpdated == [ElementPath(element: 0, section: 0)])
#expect(merged.elementInserted == [ElementPath(element: 1, section: 0)])
#expect(merged.elementDeleted.isEmpty)
Expand All @@ -60,21 +43,21 @@ struct ChatChangesetFlatteningTests {
}

@Test("An arrival while typing (update + delete + insert) flattens to a single changeset")
func updateDeleteAndInsert_flattensToOne() {
func updateDeleteAndInsert_flattensToOne() throws {
// The typing indicator clears (delete), the reply lands (insert), and the previous row's
// grouping flips (update) — three DifferenceKit stages in one push.
let before = [message("a", sender: .other), .typingIndicator]
let after = [
message("a", sender: .other, continuedByNext: true),
message("b", sender: .other, continuationFromPrevious: true),
let before: [ChatItem] = [.text("a", sender: .other), .typingIndicator]
let after: [ChatItem] = [
.text("a", sender: .other, continuedByNext: true),
.text("b", sender: .other, continuationFromPrevious: true),
]

let staged = StagedChangeset(source: before, target: after)
#expect(staged.count == 3, "premise: DifferenceKit stages [updates]+[deletes]+[inserts] separately")

let flattened = staged.flattenIfPossible()
#expect(flattened.count == 1)
guard let merged = flattened.first else { return }
let merged = try #require(flattened.first)
#expect(merged.elementUpdated == [ElementPath(element: 0, section: 0)])
#expect(merged.elementDeleted == [ElementPath(element: 1, section: 0)])
#expect(merged.elementInserted == [ElementPath(element: 1, section: 0)])
Expand All @@ -83,14 +66,14 @@ struct ChatChangesetFlatteningTests {
}

@Test("A pure delete + insert pair still flattens to a single changeset")
func deleteAndInsert_stillFlattensToOne() {
func deleteAndInsert_stillFlattensToOne() throws {
// The pre-existing behavior (typing indicator swaps for a message with no other change).
let before = [message("a", sender: .other), .typingIndicator]
let after = [message("a", sender: .other), message("b", sender: .other)]
let before: [ChatItem] = [.text("a", sender: .other), .typingIndicator]
let after: [ChatItem] = [.text("a", sender: .other), .text("b", sender: .other)]

let flattened = StagedChangeset(source: before, target: after).flattenIfPossible()
#expect(flattened.count == 1)
guard let merged = flattened.first else { return }
let merged = try #require(flattened.first)
#expect(merged.elementDeleted == [ElementPath(element: 1, section: 0)])
#expect(merged.elementInserted == [ElementPath(element: 1, section: 0)])
#expect(merged.data == after)
Expand All @@ -100,8 +83,8 @@ struct ChatChangesetFlatteningTests {
func moves_areNotFlattened() {
// A move's source index is relative to the post-delete stage, not the original source, so
// merging would corrupt indices. Reorders never happen in a transcript; keep them staged.
let before = [message("a"), message("b")]
let after = [message("b"), .message(ChatMessage(id: "a", text: "edited", sender: .me))]
let before: [ChatItem] = [.text("a"), .text("b")]
let after: [ChatItem] = [.text("b"), .message(ChatMessage(id: "a", text: "edited", sender: .me))]

let staged = StagedChangeset(source: before, target: after)
#expect(staged.contains { !$0.elementMoved.isEmpty }, "premise: a reorder diffs to a move")
Expand Down
5 changes: 1 addition & 4 deletions FlipcashTests/Chat/ChatMessageCopyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,7 @@ import FlipcashCore
struct ChatMessageCopyTests {

private func loadedController(_ items: [ChatItem]) -> ChatViewController {
let controller = ChatViewController()
controller.loadViewIfNeeded()
controller.update(items: items)
return controller
.loaded(items: items)
}

private func configuration(_ controller: ChatViewController, at index: Int) -> UIContextMenuConfiguration? {
Expand Down
Loading