@onekeyfe/react-native-native-list 6.7.0-alpha.375 → 6.7.0-alpha.382

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/android/src/main/java/com/onekey/nativelist/HybridNativeList.kt +14 -1
  2. package/android/src/main/java/com/onekey/nativelist/NativeListAccessoryStack.kt +240 -11
  3. package/android/src/main/java/com/onekey/nativelist/NativeListActivityDetailsView.kt +7 -4
  4. package/android/src/main/java/com/onekey/nativelist/NativeListActivityRenderer.kt +6 -3
  5. package/android/src/main/java/com/onekey/nativelist/NativeListButtonGroupRenderer.kt +102 -0
  6. package/android/src/main/java/com/onekey/nativelist/NativeListContainerSlotAdapter.kt +12 -0
  7. package/android/src/main/java/com/onekey/nativelist/NativeListDataRowRenderer.kt +87 -7
  8. package/android/src/main/java/com/onekey/nativelist/NativeListIdentityRenderer.kt +177 -1
  9. package/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt +43 -14
  10. package/android/src/main/java/com/onekey/nativelist/NativeListMessageRenderer.kt +25 -0
  11. package/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +33 -2
  12. package/android/src/main/java/com/onekey/nativelist/NativeListPrimitives.kt +22 -0
  13. package/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt +2 -0
  14. package/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt +2 -0
  15. package/android/src/main/java/com/onekey/nativelist/NativeListResolvedText.kt +3 -2
  16. package/android/src/main/java/com/onekey/nativelist/NativeListSectionHeaderRenderer.kt +69 -0
  17. package/android/src/main/java/com/onekey/nativelist/NativeListTextView.kt +55 -1
  18. package/android/src/main/java/com/onekey/nativelist/NativeListView.kt +1912 -107
  19. package/android/src/test/java/com/margelo/nitro/nativelist/NativeListPressPolicyTest.kt +1 -0
  20. package/docs/ROW_TEMPLATES.md +60 -9
  21. package/docs/SPEC.md +450 -5
  22. package/docs/STYLE_SPEC.md +6 -0
  23. package/ios/HybridNativeList.swift +21 -2
  24. package/ios/NativeListAccessoryStack.swift +227 -39
  25. package/ios/NativeListActivityDetailsView.swift +18 -8
  26. package/ios/NativeListActivityRenderer.swift +8 -2
  27. package/ios/NativeListButtonGroupRenderer.swift +89 -0
  28. package/ios/NativeListDataRowRenderer.swift +155 -27
  29. package/ios/NativeListDesignAssets.swift +3 -0
  30. package/ios/NativeListIdentityRenderer.swift +166 -30
  31. package/ios/NativeListLeadingVisual.swift +49 -14
  32. package/ios/NativeListMessageRenderer.swift +34 -1
  33. package/ios/NativeListModels.swift +42 -2
  34. package/ios/NativeListRendererRegistry.swift +1 -0
  35. package/ios/NativeListResolvedText.swift +2 -1
  36. package/ios/NativeListRowHost.swift +14 -0
  37. package/ios/NativeListSectionHeaderRenderer.swift +88 -0
  38. package/ios/RNCNativeListView.swift +1013 -55
  39. package/ios/Resources/NativeListIcons.xcassets/onekey_crypto_coin_outline.imageset/Contents.json +15 -0
  40. package/ios/Resources/NativeListIcons.xcassets/onekey_crypto_coin_outline.imageset/icon.svg +4 -0
  41. package/ios/Resources/NativeListIcons.xcassets/onekey_gas_solid.imageset/Contents.json +15 -0
  42. package/ios/Resources/NativeListIcons.xcassets/onekey_gas_solid.imageset/icon.svg +3 -0
  43. package/ios/Resources/NativeListIcons.xcassets/onekey_refresh_ccw_outline.imageset/Contents.json +15 -0
  44. package/ios/Resources/NativeListIcons.xcassets/onekey_refresh_ccw_outline.imageset/icon.svg +3 -0
  45. package/ios/tests/NativeListActivityPresentationTests.swift +56 -0
  46. package/ios/tests/NativeListLifecycleTests.swift +2720 -3
  47. package/lib/module/NativeList.js +175 -28
  48. package/lib/module/NativeList.web.js +129 -30
  49. package/lib/module/index.js +1 -1
  50. package/lib/module/models.js +1 -0
  51. package/lib/module/selection.js +1 -1
  52. package/lib/module/validation.js +421 -8
  53. package/lib/module/web/NativeListWebEngine.js +507 -19
  54. package/lib/module/web/templates/ActivityRowRenderer.js +8 -7
  55. package/lib/module/web/templates/ButtonGroupRowRenderer.js +47 -0
  56. package/lib/module/web/templates/DataRowRenderer.js +40 -5
  57. package/lib/module/web/templates/IdentityRowRenderer.js +90 -4
  58. package/lib/module/web/templates/MessageRowRenderer.js +18 -2
  59. package/lib/module/web/templates/RowContainerStyle.js +1 -0
  60. package/lib/module/web/templates/RowRendererRegistry.js +3 -0
  61. package/lib/module/web/templates/SectionHeaderRowRenderer.js +36 -2
  62. package/lib/nitrogen/generated/android/c++/JHybridNativeListSpec.cpp +26 -0
  63. package/lib/nitrogen/generated/android/c++/JHybridNativeListSpec.hpp +4 -0
  64. package/lib/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.cpp +10 -0
  65. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/HybridNativeListSpec.kt +20 -0
  66. package/lib/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.hpp +14 -0
  67. package/lib/nitrogen/generated/ios/c++/views/HybridNativeListComponent.mm +12 -0
  68. package/lib/nitrogen/generated/ios/swift/HybridNativeListSpec.swift +2 -0
  69. package/lib/nitrogen/generated/ios/swift/HybridNativeListSpec_cxx.swift +43 -0
  70. package/lib/nitrogen/generated/shared/c++/HybridNativeListSpec.cpp +4 -0
  71. package/lib/nitrogen/generated/shared/c++/HybridNativeListSpec.hpp +4 -0
  72. package/lib/nitrogen/generated/shared/c++/views/HybridNativeListComponent.cpp +4 -0
  73. package/lib/nitrogen/generated/shared/c++/views/HybridNativeListComponent.hpp +6 -0
  74. package/lib/nitrogen/generated/shared/json/NativeListConfig.json +2 -0
  75. package/lib/typescript/src/NativeList.d.ts +1 -1
  76. package/lib/typescript/src/NativeList.nitro.d.ts +2 -0
  77. package/lib/typescript/src/NativeList.types.d.ts +18 -0
  78. package/lib/typescript/src/index.d.ts +2 -2
  79. package/lib/typescript/src/models.d.ts +105 -9
  80. package/lib/typescript/src/validation.d.ts +12 -0
  81. package/lib/typescript/src/web/NativeListWebEngine.d.ts +32 -0
  82. package/lib/typescript/src/web/templates/ButtonGroupRowRenderer.d.ts +14 -0
  83. package/lib/typescript/src/web/templates/IdentityRowRenderer.d.ts +1 -1
  84. package/lib/typescript/src/web/templates/MessageRowRenderer.d.ts +9 -3
  85. package/nitrogen/generated/android/c++/JHybridNativeListSpec.cpp +26 -0
  86. package/nitrogen/generated/android/c++/JHybridNativeListSpec.hpp +4 -0
  87. package/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.cpp +10 -0
  88. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/HybridNativeListSpec.kt +20 -0
  89. package/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.hpp +14 -0
  90. package/nitrogen/generated/ios/c++/views/HybridNativeListComponent.mm +12 -0
  91. package/nitrogen/generated/ios/swift/HybridNativeListSpec.swift +2 -0
  92. package/nitrogen/generated/ios/swift/HybridNativeListSpec_cxx.swift +43 -0
  93. package/nitrogen/generated/shared/c++/HybridNativeListSpec.cpp +4 -0
  94. package/nitrogen/generated/shared/c++/HybridNativeListSpec.hpp +4 -0
  95. package/nitrogen/generated/shared/c++/views/HybridNativeListComponent.cpp +4 -0
  96. package/nitrogen/generated/shared/c++/views/HybridNativeListComponent.hpp +6 -0
  97. package/nitrogen/generated/shared/json/NativeListConfig.json +2 -0
  98. package/package.json +3 -3
  99. package/react-native-native-list.podspec +1 -0
  100. package/src/NativeList.nitro.ts +2 -0
  101. package/src/NativeList.tsx +313 -44
  102. package/src/NativeList.types.ts +22 -0
  103. package/src/NativeList.web.tsx +204 -48
  104. package/src/index.ts +2 -0
  105. package/src/models.ts +117 -3
  106. package/src/selection.ts +1 -0
  107. package/src/validation.ts +852 -6
  108. package/src/web/NativeListWebEngine.ts +803 -20
  109. package/src/web/templates/ActivityRowRenderer.ts +14 -6
  110. package/src/web/templates/ButtonGroupRowRenderer.ts +58 -0
  111. package/src/web/templates/DataRowRenderer.ts +93 -20
  112. package/src/web/templates/IdentityRowRenderer.ts +150 -5
  113. package/src/web/templates/MessageRowRenderer.ts +23 -7
  114. package/src/web/templates/RowContainerStyle.ts +2 -0
  115. package/src/web/templates/RowRendererRegistry.ts +11 -1
  116. package/src/web/templates/SectionHeaderRowRenderer.ts +47 -4
@@ -4,6 +4,81 @@ import XCTest
4
4
 
5
5
  @MainActor
6
6
  final class NativeListLifecycleTests: XCTestCase {
7
+ func testAcceptedRowIndexReusesStableShapeAndRejectsLateInvalidPatch() throws {
8
+ let view = NativeListView(frame: CGRect(x: 0, y: 0, width: 320, height: 480))
9
+ func snapshot(_ keys: [String], generation: Int, title: String = "Old") throws -> String {
10
+ let rows: [[String: Any]] = keys.map { key in
11
+ ["key": key, "type": "action", "title": title, "actionKey": "open"]
12
+ }
13
+ let value: [String: Any] = ["schemaVersion": 1, "generation": generation,
14
+ "layout": ["kind": "linear"], "rows": rows]
15
+ return String(decoding: try JSONSerialization.data(withJSONObject: value), as: UTF8.self)
16
+ }
17
+ func index() throws -> (ObjectIdentifier, [String: Int]) {
18
+ let optional = try XCTUnwrap(Mirror(reflecting: view).descendant("acceptedRowIndex"))
19
+ let value = try XCTUnwrap(Mirror(reflecting: optional).children.first?.value)
20
+ let byKey = try XCTUnwrap(Mirror(reflecting: value).descendant("byKey") as? [String: Int])
21
+ return (ObjectIdentifier(value as AnyObject), byKey)
22
+ }
23
+ func title(_ key: String) throws -> String {
24
+ let items = try XCTUnwrap(Mirror(reflecting: view).descendant("itemsByKey")
25
+ as? [String: NativeListItem])
26
+ return try XCTUnwrap(items[key]?.data["title"] as? String)
27
+ }
28
+ func patch(_ rows: [[String: Any]]) throws {
29
+ view.applyPatchesJson(String(decoding: try JSONSerialization.data(withJSONObject: rows),
30
+ as: UTF8.self))
31
+ }
32
+
33
+ view.applySnapshotJson(try snapshot(["alpha", "beta"], generation: 1))
34
+ let initial = try index()
35
+ XCTAssertEqual(initial.1, ["alpha": 0, "beta": 1])
36
+ view.applySnapshotJson(try snapshot(["alpha", "beta"], generation: 1, title: "New"))
37
+ XCTAssertEqual(try index().0, initial.0)
38
+ try patch([["key": "alpha", "type": "action", "changes": ["title": "Patched"]],
39
+ ["key": "missing", "type": "action", "changes": ["title": "Invalid"]]])
40
+ XCTAssertEqual(try title("alpha"), "New")
41
+ XCTAssertEqual(try title("beta"), "New")
42
+ XCTAssertEqual(try index().0, initial.0)
43
+ try patch([["key": "alpha", "type": "action", "changes": ["title": "Patched"]]])
44
+ XCTAssertEqual(try title("alpha"), "Patched")
45
+ XCTAssertEqual(try index().0, initial.0)
46
+
47
+ view.applySnapshotJson(try snapshot(["beta", "alpha"], generation: 1))
48
+ let reordered = try index()
49
+ XCTAssertNotEqual(reordered.0, initial.0)
50
+ XCTAssertEqual(reordered.1, ["beta": 0, "alpha": 1])
51
+ try patch([["key": "alpha", "type": "action", "changes": ["title": "After reorder"]]])
52
+ XCTAssertEqual(try title("alpha"), "After reorder")
53
+ view.applySnapshotJson(try snapshot(["beta", "gamma", "alpha"], generation: 1))
54
+ let inserted = try index()
55
+ XCTAssertNotEqual(inserted.0, reordered.0)
56
+ XCTAssertEqual(inserted.1, ["beta": 0, "gamma": 1, "alpha": 2])
57
+ try patch([["key": "alpha", "type": "action", "changes": ["title": "After insert"]]])
58
+ XCTAssertEqual(try title("alpha"), "After insert")
59
+ view.applySnapshotJson(try snapshot(["beta", "alpha"], generation: 1))
60
+ let deleted = try index()
61
+ XCTAssertNotEqual(deleted.0, inserted.0)
62
+ XCTAssertEqual(deleted.1, ["beta": 0, "alpha": 1])
63
+ try patch([["key": "alpha", "type": "action", "changes": ["title": "After delete"]]])
64
+ XCTAssertEqual(try title("alpha"), "After delete")
65
+ view.applySnapshotJson(try snapshot(["beta", "alpha"], generation: 2))
66
+ XCTAssertNotEqual(try index().0, deleted.0)
67
+ let beforeTypeChange = try index().0
68
+ let typeChanged: [String: Any] = ["schemaVersion": 1, "generation": 2,
69
+ "layout": ["kind": "linear"], "rows": [
70
+ ["key": "beta", "type": "identity", "title": "Beta",
71
+ "leading": ["kind": "icon", "name": "coin"]],
72
+ ["key": "alpha", "type": "action", "title": "Alpha", "actionKey": "open"],
73
+ ]]
74
+ view.applySnapshotJson(String(decoding: try JSONSerialization.data(withJSONObject: typeChanged),
75
+ as: UTF8.self))
76
+ XCTAssertNotEqual(try index().0, beforeTypeChange)
77
+ view.dispose()
78
+ XCTAssertEqual(Mirror(reflecting: try XCTUnwrap(Mirror(reflecting: view)
79
+ .descendant("acceptedRowIndex"))).children.count, 0)
80
+ }
81
+
7
82
  func testScrollEdgeStylesPreserveOverlayGeometryAndRestoreOriginalStyle() throws {
8
83
  guard #available(iOS 26.0, *) else {
9
84
  throw XCTSkip("Scroll-edge styles require iOS 26 or newer")
@@ -15,7 +90,8 @@ final class NativeListLifecycleTests: XCTestCase {
15
90
  let originalStyle = list.topEdgeEffect.style
16
91
  let originalInset = list.contentInset.top
17
92
  let overlay = UIView(frame: CGRect(x: 0, y: 0, width: 320, height: 64))
18
- view.mountContainerSlot(overlay, atIndex: 3)
93
+ for index in 0..<6 { view.mountContainerSlot(UIView(), atIndex: index) }
94
+ view.mountContainerSlot(overlay, atIndex: 6)
19
95
  view.setTopScrollEdge(effect: .hard, overlayHeight: 64)
20
96
  XCTAssertEqual(list.topEdgeEffect.style, .hard)
21
97
  XCTAssertEqual(list.contentInset.top, originalInset + 64, accuracy: 0.01)
@@ -153,6 +229,723 @@ final class NativeListLifecycleTests: XCTestCase {
153
229
  XCTAssertNotEqual(cell.bindingEpoch, changedEpoch)
154
230
  }
155
231
 
232
+ func testRiskIdentityTitleIconAndButtonResetOnProductionCellBind() throws {
233
+ let cell = NativeListIdentityCell(frame: CGRect(x: 0, y: 0, width: 320, height: 56))
234
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 200))
235
+ window.addSubview(cell)
236
+ let titleIcon = try XCTUnwrap(
237
+ Mirror(reflecting: cell).children.first { $0.label == "titleIconView" }?.value as? UIImageView)
238
+ let titleStack = try XCTUnwrap(
239
+ Mirror(reflecting: cell).children.first { $0.label == "titleRowStack" }?.value as? UIStackView)
240
+ let titleLabel = try XCTUnwrap(
241
+ Mirror(reflecting: cell).children.first { $0.label == "titleLabel" }?.value as? UILabel)
242
+ let trailing = try XCTUnwrap(
243
+ Mirror(reflecting: cell).children.first { $0.label == "trailingStack" }?.value
244
+ as? NativeListAccessoryStack)
245
+ func bind(_ data: [String: Any]) throws {
246
+ let item = try NativeListItem(data: data)
247
+ cell.bind(item: item, theme: nil, layout: "linear", selected: false,
248
+ checkboxState: { _, _, fallback in fallback }, fonts: NativeListFontFamilies())
249
+ cell.layoutIfNeeded()
250
+ }
251
+ let base: [String: Any] = ["key": "risk", "type": "identity", "title": "Risk",
252
+ "height": 56, "leading": ["kind": "token",
253
+ "fallbackIcon": ["name": "CryptoCoinOutline"]]]
254
+ try bind(base)
255
+ XCTAssertTrue(titleIcon.isHidden)
256
+ XCTAssertLessThanOrEqual(titleIcon.bounds.width, 0.5)
257
+ try bind(base.merging(["titleIcon": ["name": "GasSolid"],
258
+ "trailing": [["kind": "button", "label": "Show", "actionKey": "show",
259
+ "backgroundColor": "#DDDDDD", "pressedBackgroundColor": "#AAAAAA"]]]) { _, newer in newer })
260
+ XCTAssertFalse(titleIcon.isHidden)
261
+ XCTAssertNotNil(titleIcon.image)
262
+ XCTAssertEqual(titleIcon.bounds.width, 20, accuracy: 0.5)
263
+ XCTAssertEqual(titleStack.customSpacing(after: titleLabel), 4, accuracy: 0.1)
264
+ let button = try XCTUnwrap(trailing.arrangedSubviews.compactMap { $0 as? NativeListAccessoryButton }
265
+ .first { !$0.isHidden })
266
+ XCTAssertEqual(button.intrinsicContentSize.height, 30, accuracy: 0.5)
267
+ let normal = button.backgroundColor
268
+ button.isHighlighted = true
269
+ XCTAssertNotEqual(button.backgroundColor, normal)
270
+ button.isHighlighted = false
271
+ XCTAssertEqual(button.backgroundColor, normal)
272
+ try bind(base)
273
+ XCTAssertTrue(titleIcon.isHidden)
274
+ XCTAssertLessThanOrEqual(titleIcon.bounds.width, 0.5)
275
+ XCTAssertTrue(trailing.arrangedSubviews.compactMap { $0 as? NativeListAccessoryButton }
276
+ .allSatisfy { $0.isHidden })
277
+ }
278
+
279
+ func testIdentityTitleSuffixesUsePlainLabelsAndClearOnRebind() throws {
280
+ let cell = NativeListIdentityCell(frame: CGRect(x: 0, y: 0, width: 320, height: 60))
281
+ let titleStack = try XCTUnwrap(
282
+ Mirror(reflecting: cell).children.first { $0.label == "titleRowStack" }?.value as? UIStackView)
283
+ func bind(_ suffixes: [[String: Any]]?) throws {
284
+ var data: [String: Any] = [
285
+ "key": "recent", "type": "identity", "title": "Alice",
286
+ "leading": ["kind": "account", "fallbackText": "A"],
287
+ ]
288
+ if let suffixes { data["titleSuffixes"] = suffixes }
289
+ let item = try NativeListItem(data: data)
290
+ cell.bind(item: item, theme: nil, layout: "linear", selected: false,
291
+ checkboxState: { _, _, fallback in fallback }, fonts: NativeListFontFamilies())
292
+ cell.layoutIfNeeded()
293
+ }
294
+ try bind([
295
+ ["text": "EVM", "tone": "secondary", "maxWidth": 128],
296
+ ["text": "2 days ago", "tone": "disabled"],
297
+ ])
298
+ let suffixes = titleStack.arrangedSubviews.compactMap { $0 as? UILabel }
299
+ .filter { ["EVM", "2 days ago"].contains($0.text ?? "") }
300
+ XCTAssertEqual(suffixes.map(\.text), ["EVM", "2 days ago"])
301
+ XCTAssertTrue(suffixes.allSatisfy { $0.font.pointSize == 12 && $0.numberOfLines == 1 })
302
+ XCTAssertFalse(suffixes.contains { $0 is NativeListInsetLabel })
303
+ try bind(nil)
304
+ XCTAssertFalse(titleStack.arrangedSubviews.compactMap { $0 as? UILabel }
305
+ .contains { ["EVM", "2 days ago"].contains($0.text ?? "") })
306
+ }
307
+
308
+ func testDataRowTitleIconsClearWhenCellIsRebound() throws {
309
+ let cell = NativeListDataRowCell(frame: CGRect(x: 0, y: 0, width: 380, height: 60))
310
+ func bind(_ column: [String: Any]) throws {
311
+ let item = try NativeListItem(data: [
312
+ "key": "asset", "type": "dataRow", "columns": [column],
313
+ ])
314
+ cell.bind(item: item, theme: nil, layout: "table", selected: false,
315
+ checkboxState: { _, _, fallback in fallback }, fonts: NativeListFontFamilies())
316
+ cell.layoutIfNeeded()
317
+ }
318
+ func titleIcons(in view: UIView) -> [UIImageView] {
319
+ let current = (view as? UIImageView).map { [$0] } ?? []
320
+ return current + view.subviews.flatMap(titleIcons)
321
+ }
322
+ try bind([
323
+ "key": "asset", "text": "ETH",
324
+ "titleIcon": ["name": "GasSolid", "accessibilityLabel": "Gas help"],
325
+ "secondaryTitleIcon": ["name": "InfoCircleOutline", "accessibilityLabel": "Info help"],
326
+ ])
327
+ let visible = titleIcons(in: cell).filter { !$0.isHidden && $0.accessibilityLabel != nil }
328
+ XCTAssertEqual(visible.compactMap(\.accessibilityLabel), ["Gas help", "Info help"])
329
+ XCTAssertTrue(visible.allSatisfy { $0.image != nil })
330
+
331
+ try bind(["key": "asset", "text": "USDC"])
332
+ XCTAssertTrue(titleIcons(in: cell).allSatisfy {
333
+ $0.accessibilityLabel != "Gas help" && $0.accessibilityLabel != "Info help"
334
+ })
335
+ }
336
+
337
+ func testDataRowLinearRebindKeepsSizeConstraintsAndClearsOldContent() throws {
338
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 700, height: 100))
339
+ let cell = NativeListDataRowCell(frame: CGRect(x: 0, y: 0, width: 700, height: 60))
340
+ window.addSubview(cell)
341
+ window.isHidden = false
342
+ defer { window.isHidden = true }
343
+ func bind(_ title: String, secondary: String, layout: String = "linear",
344
+ visualWidth: Int, trailing: String?, icon: Bool = false, badge: Bool = false,
345
+ key: String = "asset") throws {
346
+ var column: [String: Any] = ["key": "asset", "text": title, "secondaryText": secondary]
347
+ if icon { column["titleIcon"] = ["name": "GasSolid", "accessibilityLabel": "Gas help"] }
348
+ let item = try NativeListItem(data: [
349
+ "key": key, "type": "dataRow", "height": 60,
350
+ "leading": ["kind": "token", "fallbackIcon": ["name": "CryptoCoinOutline"]],
351
+ "columns": [column], "badges": badge ? [["text": "Badge"]] : [],
352
+ "trailing": trailing.map { [["kind": "button", "label": $0, "actionKey": "swap"]] } ?? [],
353
+ "style": ["image": ["width": visualWidth, "height": visualWidth]],
354
+ ])
355
+ cell.bind(item: item, theme: nil, layout: layout, selected: false,
356
+ checkboxState: { _, _, fallback in fallback }, fonts: NativeListFontFamilies())
357
+ window.layoutIfNeeded()
358
+ cell.layoutIfNeeded()
359
+ }
360
+ func property<T>(_ name: String, in object: Any, as type: T.Type) throws -> T {
361
+ try XCTUnwrap(Mirror(reflecting: object).children.first { $0.label == name }?.value as? T)
362
+ }
363
+ let columns = try XCTUnwrap(cell.root.arrangedSubviews[3] as? UIStackView)
364
+ let firstColumn = columns.arrangedSubviews[0]
365
+ let primary: UILabel = try property("primaryLabel", in: firstColumn, as: UILabel.self)
366
+ let secondary: UILabel = try property("secondaryLabel", in: firstColumn, as: UILabel.self)
367
+ let secondaryLine: UIStackView = try property("secondaryLine", in: firstColumn,
368
+ as: UIStackView.self)
369
+ let titleIcon: UIImageView = try property("titleIconView", in: firstColumn,
370
+ as: UIImageView.self)
371
+ let badges: UIStackView = try property("badgesStack", in: firstColumn,
372
+ as: UIStackView.self)
373
+
374
+ try bind("ETH", secondary: "Ethereum", visualWidth: 32, trailing: "Swap",
375
+ icon: true, badge: true)
376
+ let sizeConstraints: [NSLayoutConstraint] = try property("rowConstraints", in: cell,
377
+ as: [NSLayoutConstraint].self)
378
+ XCTAssertEqual(sizeConstraints.count, 4)
379
+ let trailingStack = cell.root.arrangedSubviews[4]
380
+ let trailingWidth = try XCTUnwrap(trailingStack.constraints.first {
381
+ $0.firstAttribute == .width && ($0.firstItem as? UIView) === trailingStack
382
+ })
383
+ XCTAssertTrue(trailingWidth.isActive)
384
+ let shortTrailingWidth = trailingWidth.constant
385
+ XCTAssertEqual(sizeConstraints[2].constant, 32, accuracy: 0.1)
386
+ XCTAssertTrue(primary.attributedText?.string.contains("Badge") ?? false)
387
+ XCTAssertFalse(titleIcon.isHidden)
388
+ XCTAssertEqual(badges.arrangedSubviews.count, 0)
389
+
390
+ try bind("ETH", secondary: "Ethereum", visualWidth: 32, trailing: "Manage asset")
391
+ XCTAssertGreaterThan(trailingWidth.constant, shortTrailingWidth + 1)
392
+ let columnsFrame = columns.convert(columns.bounds, to: cell)
393
+ let trailingFrame = trailingStack.convert(trailingStack.bounds, to: cell)
394
+ XCTAssertGreaterThanOrEqual(trailingFrame.minX, columnsFrame.maxX - 0.5)
395
+ try bind("ETH", secondary: "Ethereum", visualWidth: 32, trailing: "Swap")
396
+ XCTAssertEqual(trailingWidth.constant, shortTrailingWidth, accuracy: 1)
397
+
398
+ try bind("", secondary: "", visualWidth: 48, trailing: nil)
399
+ let reboundConstraints: [NSLayoutConstraint] = try property("rowConstraints", in: cell,
400
+ as: [NSLayoutConstraint].self)
401
+ XCTAssertEqual(reboundConstraints.count, sizeConstraints.count)
402
+ XCTAssertTrue(zip(sizeConstraints, reboundConstraints).allSatisfy { $0 === $1 })
403
+ XCTAssertEqual(sizeConstraints[2].constant, 48, accuracy: 0.1)
404
+ XCTAssertFalse(trailingWidth.isActive)
405
+ XCTAssertEqual(primary.attributedText?.string, "")
406
+ XCTAssertNil(secondary.attributedText)
407
+ XCTAssertTrue(secondaryLine.isHidden)
408
+ XCTAssertTrue(titleIcon.isHidden)
409
+ XCTAssertNil(titleIcon.accessibilityLabel)
410
+ XCTAssertTrue(badges.arrangedSubviews.isEmpty)
411
+
412
+ try bind("Table", secondary: "New", layout: "table", visualWidth: 40, trailing: "Swap")
413
+ XCTAssertTrue(trailingWidth.isActive)
414
+ XCTAssertEqual(sizeConstraints[2].constant, 40, accuracy: 0.1)
415
+ XCTAssertEqual(primary.attributedText?.string, "Table")
416
+ XCTAssertEqual(secondary.attributedText?.string, "New")
417
+ try bind("Linear", secondary: "", visualWidth: 40, trailing: nil)
418
+ XCTAssertEqual(primary.attributedText?.string, "Linear")
419
+ XCTAssertNil(secondary.attributedText)
420
+ cell.prepareForReuse()
421
+ XCTAssertNil(primary.attributedText)
422
+ XCTAssertTrue(titleIcon.isHidden)
423
+ try bind("Replacement", secondary: "", visualWidth: 40, trailing: nil, key: "next")
424
+ XCTAssertEqual(primary.attributedText?.string, "Replacement")
425
+ }
426
+
427
+ func testIdentityTrailingActionUsesDescriptorSlotAfterCheckboxAndRebind() throws {
428
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 386, height: 200))
429
+ let controller = UIViewController()
430
+ window.rootViewController = controller
431
+ window.isHidden = false
432
+ defer { window.isHidden = true }
433
+ let cell = NativeListIdentityCell(frame: CGRect(x: 0, y: 0, width: 386, height: 56))
434
+ controller.view.addSubview(cell)
435
+ let trailing = try XCTUnwrap(
436
+ Mirror(reflecting: cell).children.first { $0.label == "trailingStack" }?.value
437
+ as? NativeListAccessoryStack)
438
+ var actions: [(rowKey: String, key: String, source: String, slot: Int?)] = []
439
+ cell.onAction = { item, key, _, origin in
440
+ actions.append((item.key, key, origin?.source ?? "", origin?.slot))
441
+ }
442
+ func bind(_ trailingData: [[String: Any]], key: String = "row") throws {
443
+ let item = try NativeListItem(data: [
444
+ "key": key, "type": "identity", "title": "Row", "trailing": trailingData,
445
+ ])
446
+ cell.bind(item: item, theme: nil, layout: "linear", selected: false,
447
+ checkboxState: { _, _, fallback in fallback }, fonts: NativeListFontFamilies())
448
+ window.layoutIfNeeded()
449
+ cell.layoutIfNeeded()
450
+ }
451
+ func visibleButtons() -> [NativeListAccessoryButton] {
452
+ trailing.arrangedSubviews.compactMap { $0 as? NativeListAccessoryButton }
453
+ .filter { !$0.isHidden }
454
+ }
455
+ let checkbox: [String: Any] = [
456
+ "kind": "checkbox", "state": "unchecked", "actionKey": "selection",
457
+ ]
458
+ let first: [String: Any] = ["kind": "button", "label": "First", "actionKey": "first"]
459
+ let second: [String: Any] = ["kind": "button", "label": "Second", "actionKey": "second"]
460
+
461
+ try bind([checkbox, second])
462
+ XCTAssertEqual(visibleButtons().count, 1)
463
+ XCTAssertTrue(visibleButtons()[0].isEnabled)
464
+ XCTAssertTrue(visibleButtons()[0].isUserInteractionEnabled)
465
+ XCTAssertTrue(visibleButtons()[0].allTargets.contains(trailing))
466
+ XCTAssertNotNil(trailing.onAction)
467
+ try dispatchAccessoryTouchUpInside(visibleButtons()[0], on: trailing)
468
+ XCTAssertEqual(actions.last?.rowKey, "row")
469
+ XCTAssertEqual(actions.last?.key, "second")
470
+ XCTAssertEqual(actions.last?.source, "trailingAccessory")
471
+ XCTAssertEqual(actions.last?.slot, 1)
472
+ let selection = try XCTUnwrap(trailing.arrangedSubviews.compactMap { $0 as? UIButton }
473
+ .first { !($0 is NativeListAccessoryButton) && !$0.isHidden })
474
+ try dispatchAccessoryTouchUpInside(selection, on: trailing)
475
+ XCTAssertEqual(actions.last?.key, "selection")
476
+ XCTAssertNil(actions.last?.slot)
477
+
478
+ try bind([first, second])
479
+ XCTAssertEqual(visibleButtons().count, 2)
480
+ try dispatchAccessoryTouchUpInside(visibleButtons()[0], on: trailing)
481
+ XCTAssertEqual(actions.last?.key, "first")
482
+ XCTAssertEqual(actions.last?.slot, 0)
483
+ try dispatchAccessoryTouchUpInside(visibleButtons()[1], on: trailing)
484
+ XCTAssertEqual(actions.last?.key, "second")
485
+ XCTAssertEqual(actions.last?.slot, 1)
486
+
487
+ try bind([second], key: "replacement")
488
+ XCTAssertEqual(visibleButtons().count, 1)
489
+ try dispatchAccessoryTouchUpInside(visibleButtons()[0], on: trailing)
490
+ XCTAssertEqual(actions.last?.rowKey, "replacement")
491
+ XCTAssertEqual(actions.last?.key, "second")
492
+ XCTAssertEqual(actions.last?.slot, 0)
493
+ XCTAssertEqual(actions.count, 5)
494
+ }
495
+
496
+ func testIdentityTertiaryIconAnchorInsetSurvivesAccessoryRebind() throws {
497
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 386, height: 200))
498
+ let controller = UIViewController()
499
+ window.rootViewController = controller
500
+ window.isHidden = false
501
+ defer { window.isHidden = true }
502
+ let cell = NativeListIdentityCell(frame: CGRect(x: 0, y: 0, width: 386, height: 88))
503
+ controller.view.addSubview(cell)
504
+ let trailing = try XCTUnwrap(
505
+ Mirror(reflecting: cell).children.first { $0.label == "trailingStack" }?.value
506
+ as? NativeListAccessoryStack)
507
+ var actions: [(rowKey: String, source: String, slot: Int?, inset: CGFloat,
508
+ width: CGFloat)] = []
509
+ cell.onAction = { item, _, _, origin in
510
+ actions.append((item.key, origin?.source ?? "", origin?.slot, origin?.anchorInset ?? -1,
511
+ origin?.sourceView?.bounds.width ?? -1))
512
+ }
513
+ func bind(_ descriptors: [[String: Any]], key: String,
514
+ presentation: String? = nil) throws -> NativeListAccessoryButton {
515
+ var data: [String: Any] = [
516
+ "key": key, "type": "identity", "title": "Row", "height": 88,
517
+ "trailing": descriptors,
518
+ ]
519
+ if let presentation { data["presentation"] = presentation }
520
+ cell.bind(item: try NativeListItem(data: data), theme: nil, layout: "linear",
521
+ selected: false, checkboxState: { _, _, fallback in fallback },
522
+ fonts: NativeListFontFamilies())
523
+ window.layoutIfNeeded()
524
+ cell.layoutIfNeeded()
525
+ return try XCTUnwrap(trailing.firstVisibleControl as? NativeListAccessoryButton)
526
+ }
527
+ let tertiary: [String: Any] = [
528
+ "kind": "icon", "name": "CryptoCoinOutline", "buttonStyle": "tertiary",
529
+ "actionKey": "menu",
530
+ ]
531
+ let ordinary: [String: Any] = [
532
+ "kind": "icon", "name": "CryptoCoinOutline", "actionKey": "menu",
533
+ ]
534
+
535
+ let first = try bind([
536
+ ["kind": "checkbox", "state": "unchecked", "actionKey": "selection"], tertiary,
537
+ ], key: "tertiary-first")
538
+ XCTAssertEqual(first.bounds.width, 38, accuracy: 0.5)
539
+ try dispatchAccessoryTouchUpInside(first, on: trailing)
540
+ XCTAssertEqual(actions.last?.rowKey, "tertiary-first")
541
+ XCTAssertEqual(actions.last?.source, "trailingAccessory")
542
+ XCTAssertEqual(actions.last?.slot, 1)
543
+ XCTAssertEqual(actions.last?.inset, 7)
544
+ let firstOrigin = try XCTUnwrap(actions.last)
545
+ XCTAssertEqual(firstOrigin.width - 2 * firstOrigin.inset, 24, accuracy: 0.5)
546
+
547
+ let normal = try bind([ordinary], key: "ordinary")
548
+ XCTAssertEqual(normal.bounds.width, 36, accuracy: 0.5)
549
+ try dispatchAccessoryTouchUpInside(normal, on: trailing)
550
+ XCTAssertEqual(actions.last?.rowKey, "ordinary")
551
+ XCTAssertEqual(actions.last?.slot, 0)
552
+ XCTAssertEqual(actions.last?.inset, 0)
553
+ XCTAssertEqual(actions.last?.width, 36)
554
+
555
+ let value = try bind([["kind": "value", "text": "Value"]], key: "value")
556
+ value.bounds.size.width = 38
557
+ XCTAssertEqual(trailing.anchorInset(for: value), 0)
558
+
559
+ let rebound = try bind([tertiary], key: "tertiary-again")
560
+ try dispatchAccessoryTouchUpInside(rebound, on: trailing)
561
+ XCTAssertEqual(actions.last?.rowKey, "tertiary-again")
562
+ XCTAssertEqual(actions.last?.slot, 0)
563
+ XCTAssertEqual(actions.last?.inset, 7)
564
+ let reboundOrigin = try XCTUnwrap(actions.last)
565
+ XCTAssertEqual(reboundOrigin.width - 2 * reboundOrigin.inset, 24, accuracy: 0.5)
566
+
567
+ let account = try bind([ordinary], key: "account", presentation: "accountSelector")
568
+ XCTAssertEqual(account.bounds.width, 38, accuracy: 0.5)
569
+ try dispatchAccessoryTouchUpInside(account, on: trailing)
570
+ XCTAssertEqual(actions.last?.rowKey, "account")
571
+ XCTAssertEqual(actions.last?.slot, 0)
572
+ XCTAssertEqual(actions.last?.inset, 7)
573
+ let accountOrigin = try XCTUnwrap(actions.last)
574
+ XCTAssertEqual(accountOrigin.width - 2 * accountOrigin.inset, 24, accuracy: 0.5)
575
+
576
+ let finalOrdinary = try bind([ordinary], key: "ordinary-again")
577
+ try dispatchAccessoryTouchUpInside(finalOrdinary, on: trailing)
578
+ XCTAssertEqual(actions.last?.rowKey, "ordinary-again")
579
+ XCTAssertEqual(actions.last?.inset, 0)
580
+ XCTAssertEqual(actions.count, 5)
581
+ }
582
+
583
+ func testValuePairSegmentsStyleAndPlainRebindOnProductionAccessoryStack() throws {
584
+ let stack = NativeListAccessoryStack(frame: CGRect(x: 0, y: 0, width: 180, height: 48))
585
+ let item = try NativeListItem(data: [
586
+ "key": "pair", "type": "identity", "title": "Pair",
587
+ "leading": ["kind": "icon", "name": "coin"],
588
+ ])
589
+ let pair: [String: Any] = [
590
+ "kind": "valuePair", "primary": "0.001", "secondary": "$0.01",
591
+ "primaryTextSegments": [["text": "0.00"], ["text": "1", "style": "subscript"]],
592
+ "secondaryTextSegments": [["text": "$0.0"], ["text": "1", "style": "subscript"]],
593
+ ]
594
+ func bind(_ descriptor: [String: Any], size: Int? = nil) {
595
+ let style: [String: Any] = size.map { ["value": ["fontSize": $0]] } ?? [:]
596
+ stack.bind(item, descriptors: [descriptor], theme: nil, style: style,
597
+ checkboxState: { _, _, fallback in fallback }, fonts: NativeListFontFamilies())
598
+ }
599
+ func fontSize(_ title: NSAttributedString, at index: Int) throws -> CGFloat {
600
+ try XCTUnwrap(title.attribute(.font, at: index, effectiveRange: nil) as? UIFont).pointSize
601
+ }
602
+ bind(pair)
603
+ let button = try XCTUnwrap(stack.arrangedSubviews.compactMap { $0 as? UIButton }
604
+ .first { !$0.isHidden })
605
+ let rich = try XCTUnwrap(button.attributedTitle(for: .normal))
606
+ XCTAssertEqual(rich.string, "0.001\n$0.01")
607
+ XCTAssertEqual(try fontSize(rich, at: 0), 16)
608
+ XCTAssertEqual(try fontSize(rich, at: 4), 10)
609
+ XCTAssertEqual(try fontSize(rich, at: 6), 14)
610
+ XCTAssertEqual(try fontSize(rich, at: 10), 9)
611
+ bind(pair, size: 20)
612
+ let styled = try XCTUnwrap(button.attributedTitle(for: .normal))
613
+ XCTAssertEqual(try fontSize(styled, at: 0), 20)
614
+ XCTAssertEqual(try fontSize(styled, at: 4), 12)
615
+ XCTAssertEqual(try fontSize(styled, at: 10), 12)
616
+ bind(["kind": "valuePair", "primary": "10", "secondary": "$2"])
617
+ let plain = try XCTUnwrap(button.attributedTitle(for: .normal))
618
+ XCTAssertEqual(plain.string, "10\n$2")
619
+ XCTAssertEqual(try fontSize(plain, at: 0), 16)
620
+ XCTAssertEqual(try fontSize(plain, at: 3), 14)
621
+ bind(["kind": "valuePair", "primary": "10", "secondary": "$2",
622
+ "primaryTextSegments": [], "secondaryTextSegments": []])
623
+ XCTAssertEqual(button.attributedTitle(for: .normal)?.string, "10\n$2")
624
+ stack.reset()
625
+ XCTAssertNil(button.title(for: .normal))
626
+ XCTAssertNil(button.attributedTitle(for: .normal))
627
+ XCTAssertTrue(button.isHidden)
628
+ bind(["kind": "value", "text": "Updated"])
629
+ XCTAssertEqual(button.title(for: .normal), "Updated")
630
+ XCTAssertEqual(button.titleLabel?.text, "Updated")
631
+ XCTAssertNil(button.attributedTitle(for: .normal))
632
+ }
633
+
634
+ func testDecorativeRefreshKeepsSixteenPointFrameAcrossActionRebind() throws {
635
+ let cell = NativeListIdentityCell(frame: CGRect(x: 0, y: 0, width: 386, height: 56))
636
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 386, height: 80))
637
+ window.addSubview(cell)
638
+ let stack = try XCTUnwrap(Mirror(reflecting: cell).children.first {
639
+ $0.label == "trailingStack"
640
+ }?.value as? NativeListAccessoryStack)
641
+ func bind(actionKey: String? = nil) throws {
642
+ var icon: [String: Any] = ["kind": "icon", "name": "RefreshCcwOutline"]
643
+ if let actionKey { icon["actionKey"] = actionKey }
644
+ let item = try NativeListItem(data: [
645
+ "key": "refresh", "type": "identity", "title": "Network", "height": 56,
646
+ "leading": ["kind": "icon", "name": "coin"], "trailing": [icon],
647
+ ])
648
+ cell.bind(item: item, theme: nil, layout: "linear", selected: false,
649
+ checkboxState: { _, _, fallback in fallback }, fonts: NativeListFontFamilies())
650
+ window.layoutIfNeeded()
651
+ cell.layoutIfNeeded()
652
+ }
653
+ try bind()
654
+ let button = try XCTUnwrap(stack.firstVisibleControl as? UIButton)
655
+ XCTAssertEqual(button.bounds.width, 16, accuracy: 0.5)
656
+ XCTAssertEqual(button.bounds.height, 16, accuracy: 0.5)
657
+ XCTAssertNotNil(button.image(for: .normal))
658
+ XCTAssertEqual(button.image(for: .normal)?.size.width, 16)
659
+ XCTAssertEqual(button.image(for: .normal)?.size.height, 16)
660
+ XCTAssertFalse(button.isUserInteractionEnabled)
661
+ try bind(actionKey: "refresh")
662
+ XCTAssertEqual(button.bounds.width, 36, accuracy: 0.5)
663
+ XCTAssertEqual(button.bounds.height, 36, accuracy: 0.5)
664
+ XCTAssertTrue(button.isUserInteractionEnabled)
665
+ try bind()
666
+ XCTAssertEqual(button.bounds.width, 16, accuracy: 0.5)
667
+ XCTAssertEqual(button.bounds.height, 16, accuracy: 0.5)
668
+ XCTAssertEqual(button.image(for: .normal)?.size.width, 16)
669
+ XCTAssertEqual(button.image(for: .normal)?.size.height, 16)
670
+ XCTAssertFalse(button.isUserInteractionEnabled)
671
+ }
672
+
673
+ func testLowValueAndOverviewAccessoryLayoutsOnProductionIdentityCell() throws {
674
+ let cell = NativeListIdentityCell(frame: CGRect(x: 0, y: 0, width: 386, height: 60))
675
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 386, height: 180))
676
+ window.addSubview(cell)
677
+ let main = try XCTUnwrap(Mirror(reflecting: cell).children.first { $0.label == "mainStack" }?
678
+ .value as? UIStackView)
679
+ let trailing = try XCTUnwrap(Mirror(reflecting: cell).children.first {
680
+ $0.label == "trailingStack"
681
+ }?.value as? NativeListAccessoryStack)
682
+ func bind(_ data: [String: Any], height: CGFloat) throws {
683
+ cell.frame.size.height = height
684
+ cell.bind(item: try NativeListItem(data: data), theme: nil, layout: "linear",
685
+ selected: false, checkboxState: { _, _, fallback in fallback },
686
+ fonts: NativeListFontFamilies())
687
+ window.layoutIfNeeded()
688
+ cell.layoutIfNeeded()
689
+ }
690
+ let pair: [String: Any] = ["kind": "valuePair", "primary": "1.0193B",
691
+ "secondary": "$1,018,849,363.70"]
692
+ let lowValue: [String: Any] = ["key": "low", "type": "identity", "height": 60,
693
+ "title": "FRAX", "subtitleSegments": [
694
+ ["text": "$0.9918", "tone": "secondary", "textSegments": [
695
+ ["text": "$0.99"], ["text": "18", "style": "subscript"]]],
696
+ ["text": "\u{00a0}+0.05%", "tone": "positive"]],
697
+ "leading": ["kind": "token", "fallbackIcon": ["name": "CryptoCoinOutline"]],
698
+ "trailing": [pair], "style": ["horizontalPadding": 12, "verticalPadding": 8,
699
+ "leadingGap": 12, "image": ["width": 40, "height": 40]]]
700
+ try bind(lowValue, height: 60)
701
+ let priceLabels = try XCTUnwrap(Mirror(reflecting: cell).children.first {
702
+ $0.label == "semanticSubtitleLabels"
703
+ }?.value as? [UILabel])
704
+ XCTAssertEqual(priceLabels.count, 2)
705
+ XCTAssertGreaterThan(main.bounds.width, 150)
706
+ XCTAssertTrue(priceLabels.allSatisfy { $0.bounds.width > 0 && !$0.isHidden })
707
+ XCTAssertLessThanOrEqual(priceLabels.last?.frame.maxX ?? .infinity, main.bounds.width + 0.5)
708
+ XCTAssertEqual(trailing.bounds.width, trailing.firstVisibleControl?.bounds.width ?? -1,
709
+ accuracy: 0.5)
710
+ let detail: [String: Any] = ["key": "detail", "type": "identity", "height": 60,
711
+ "title": "Ethereum", "subtitle": "100.0%",
712
+ "leading": ["kind": "network", "fallbackIcon": ["name": "GlobusOutline"]],
713
+ "trailing": [pair, ["kind": "chevron"]],
714
+ "style": ["horizontalPadding": 12, "verticalPadding": 8, "leadingGap": 12,
715
+ "trailingGap": 12, "image": ["width": 32, "height": 32]]]
716
+ try bind(detail, height: 60)
717
+ XCTAssertEqual(trailing.axis, .horizontal)
718
+ let detailControls = trailing.arrangedSubviews.filter { !$0.isHidden }
719
+ XCTAssertEqual(detailControls.count, 2)
720
+ let detailRects = detailControls.map { $0.convert($0.bounds, to: cell) }
721
+ XCTAssertTrue(detailRects.allSatisfy { $0.minY >= 0 && $0.maxY <= 60 })
722
+ XCTAssertEqual(detailRects[0].midY, detailRects[1].midY, accuracy: 1)
723
+ XCTAssertEqual(detailRects[1].minX - detailRects[0].maxX, 12, accuracy: 0.5)
724
+ XCTAssertEqual(detailRects[1].maxX, cell.bounds.maxX - 12, accuracy: 0.5)
725
+ let missing: [String: Any] = ["key": "missing", "type": "identity", "height": 48,
726
+ "title": "Ethereum", "leading": ["kind": "network",
727
+ "fallbackIcon": ["name": "GlobusOutline"]],
728
+ "trailing": [["kind": "icon", "name": "RefreshCcwOutline"], ["kind": "chevron"]],
729
+ "style": ["horizontalPadding": 12, "verticalPadding": 8, "leadingGap": 12,
730
+ "trailingGap": 12, "image": ["width": 32, "height": 32]]]
731
+ try bind(missing, height: 48)
732
+ XCTAssertEqual(trailing.axis, .horizontal)
733
+ let missingControls = trailing.arrangedSubviews.filter { !$0.isHidden }
734
+ XCTAssertEqual(missingControls.count, 2)
735
+ XCTAssertEqual(missingControls[0].bounds.width, 16, accuracy: 0.5)
736
+ XCTAssertEqual(missingControls[1].bounds.width, 24, accuracy: 0.5)
737
+ let missingRects = missingControls.map { $0.convert($0.bounds, to: cell) }
738
+ XCTAssertTrue(missingRects.allSatisfy { $0.minY >= 0 && $0.maxY <= 48 })
739
+ XCTAssertEqual(missingRects[0].midY, missingRects[1].midY, accuracy: 1)
740
+ XCTAssertEqual(missingRects[1].minX - missingRects[0].maxX, 12, accuracy: 0.5)
741
+ XCTAssertEqual(missingRects[1].maxX, cell.bounds.maxX - 12, accuracy: 0.5)
742
+ try bind(detail, height: 60)
743
+ XCTAssertEqual(trailing.axis, .horizontal)
744
+ try bind(["key": "plain", "type": "identity", "title": "Plain", "height": 56,
745
+ "leading": ["kind": "token", "fallbackIcon": ["name": "CryptoCoinOutline"]]],
746
+ height: 56)
747
+ let remainingConstraints = try XCTUnwrap(Mirror(reflecting: cell).children.first {
748
+ $0.label == "selectorConstraints"
749
+ }?.value as? [NSLayoutConstraint])
750
+ XCTAssertTrue(remainingConstraints.isEmpty)
751
+ }
752
+
753
+ func testSwapValuePairAndTertiaryMenuIconFitAndKeepActionState() throws {
754
+ let cell = NativeListIdentityCell(frame: CGRect(x: 0, y: 0, width: 386, height: 60))
755
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 386, height: 120))
756
+ window.addSubview(cell)
757
+ defer { cell.removeFromSuperview() }
758
+ let trailing = try XCTUnwrap(Mirror(reflecting: cell).children.first {
759
+ $0.label == "trailingStack"
760
+ }?.value as? NativeListAccessoryStack)
761
+ var actions: [(String, Int?)] = []
762
+ cell.onAction = { _, action, _, origin in actions.append((action, origin?.slot)) }
763
+ func bind(disabled: Bool) throws {
764
+ let item = try NativeListItem(data: [
765
+ "key": "swap-token", "type": "identity", "height": 60,
766
+ "title": "ETH", "subtitle": "Ethereum",
767
+ "leading": ["kind": "token", "fallbackIcon": ["name": "CryptoCoinOutline"]],
768
+ "trailing": [
769
+ ["kind": "valuePair", "primary": "0.001328", "secondary": "$3.08"],
770
+ ["kind": "icon", "name": "DotVerSolid", "buttonStyle": "tertiary",
771
+ "actionKey": "menu", "disabled": disabled],
772
+ ],
773
+ "style": ["horizontalPadding": 20, "verticalPadding": 8,
774
+ "leadingGap": 12, "trailingGap": 8,
775
+ "image": ["width": 40, "height": 40]],
776
+ ])
777
+ cell.bind(item: item, theme: nil, layout: "linear", selected: false,
778
+ checkboxState: { _, _, fallback in fallback }, fonts: NativeListFontFamilies())
779
+ window.layoutIfNeeded()
780
+ cell.layoutIfNeeded()
781
+ }
782
+ try bind(disabled: false)
783
+ XCTAssertEqual(trailing.axis, .horizontal)
784
+ let controls = trailing.arrangedSubviews.compactMap { !$0.isHidden ? $0 as? UIButton : nil }
785
+ XCTAssertEqual(controls.count, 2)
786
+ let rects = controls.map { $0.convert($0.bounds, to: cell) }
787
+ XCTAssertTrue(rects.allSatisfy { $0.minY >= 0 && $0.maxY <= cell.bounds.height })
788
+ let amountLabel = try XCTUnwrap(controls[0].titleLabel)
789
+ let amountRect = amountLabel.convert(amountLabel.bounds, to: cell)
790
+ XCTAssertEqual(amountLabel.numberOfLines, 2)
791
+ XCTAssertGreaterThan(amountRect.height, 30)
792
+ XCTAssertGreaterThanOrEqual(amountRect.minY, 0)
793
+ XCTAssertLessThanOrEqual(amountRect.maxY, cell.bounds.height)
794
+ XCTAssertEqual(rects[0].midY, rects[1].midY, accuracy: 1)
795
+ XCTAssertEqual(rects[1].minX - rects[0].maxX, 8, accuracy: 0.5)
796
+ XCTAssertEqual(controls[1].bounds.height, 38, accuracy: 0.5)
797
+ XCTAssertTrue(controls[1].isEnabled)
798
+ try dispatchAccessoryTouchUpInside(controls[1], on: trailing)
799
+ XCTAssertEqual(actions.count, 1)
800
+ let action = try XCTUnwrap(actions.first)
801
+ XCTAssertEqual(action.0, "menu")
802
+ XCTAssertEqual(action.1, 1)
803
+
804
+ try bind(disabled: true)
805
+ let disabledIcon = try XCTUnwrap(trailing.arrangedSubviews.filter { !$0.isHidden }.last as? UIButton)
806
+ XCTAssertFalse(disabledIcon.isEnabled)
807
+ try dispatchAccessoryTouchUpInside(disabledIcon, on: trailing)
808
+ XCTAssertEqual(actions.count, 1)
809
+ }
810
+
811
+ func testRiskIdentityRatioKeepsTitleVisibleWithAndWithoutButton() throws {
812
+ let cell = NativeListIdentityCell(frame: CGRect(x: 0, y: 0, width: 386, height: 56))
813
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 386, height: 200))
814
+ window.addSubview(cell)
815
+ let titleLabel = try XCTUnwrap(
816
+ Mirror(reflecting: cell).children.first { $0.label == "titleLabel" }?.value as? UILabel)
817
+ let mainStack = try XCTUnwrap(
818
+ Mirror(reflecting: cell).children.first { $0.label == "mainStack" }?.value as? UIStackView)
819
+ let trailing = try XCTUnwrap(
820
+ Mirror(reflecting: cell).children.first { $0.label == "trailingStack" }?.value
821
+ as? NativeListAccessoryStack)
822
+ let base: [String: Any] = [
823
+ "key": "risk", "type": "identity", "title": "XSR", "height": 56,
824
+ "leading": ["kind": "token", "fallbackIcon": ["name": "CryptoCoinOutline"]],
825
+ "style": [
826
+ "horizontalPadding": 12, "verticalPadding": 8, "leadingGap": 12,
827
+ "leadingContentMaxWidthRatio": 0.6,
828
+ "image": ["width": 40, "height": 40, "shape": "circle"],
829
+ "title": ["fontSize": 16, "lineHeight": 24, "fontWeight": "medium", "lines": 1],
830
+ ],
831
+ ]
832
+
833
+ func bind(_ data: [String: Any], capped: Bool = true) throws {
834
+ cell.bind(item: try NativeListItem(data: data), theme: nil, layout: "linear",
835
+ selected: false, checkboxState: { _, _, fallback in fallback },
836
+ fonts: NativeListFontFamilies())
837
+ window.layoutIfNeeded()
838
+ cell.layoutIfNeeded()
839
+ XCTAssertFalse(titleLabel.isHidden)
840
+ XCTAssertGreaterThan(titleLabel.bounds.width, 0.5,
841
+ "A visible token title must not collapse when the row reserves trailing space")
842
+ XCTAssertEqual(titleLabel.bounds.height, 24, accuracy: 0.5)
843
+ let intrinsicWidth = titleLabel.intrinsicContentSize.width
844
+ let capWidth = cell.root.bounds.width * 0.6 - 52
845
+ let requiredTitleWidth = capped ? min(intrinsicWidth, capWidth) : intrinsicWidth
846
+ XCTAssertGreaterThanOrEqual(titleLabel.bounds.width, requiredTitleWidth - 0.5,
847
+ "title=\(data["title"] ?? "") intrinsic=\(intrinsicWidth) "
848
+ + "rendered=\(titleLabel.bounds.width) root=\(cell.root.bounds.width) "
849
+ + "trailing=\(trailing.bounds.width)")
850
+ XCTAssertGreaterThanOrEqual(mainStack.bounds.width, titleLabel.bounds.width - 0.5)
851
+ XCTAssertEqual(trailing.frame.maxX, cell.root.bounds.maxX, accuracy: 0.5)
852
+ if capped {
853
+ XCTAssertLessThanOrEqual(mainStack.bounds.width, cell.root.bounds.width * 0.6 - 52 + 0.5)
854
+ }
855
+ }
856
+
857
+ try bind(base)
858
+ XCTAssertNil(trailing.firstVisibleControl)
859
+ try bind(base.merging(["trailing": [[
860
+ "kind": "button", "label": "Show on home", "actionKey": "show",
861
+ ]]]) { _, newer in newer })
862
+ let button = try XCTUnwrap(trailing.firstVisibleControl)
863
+ XCTAssertGreaterThan(button.bounds.width, 100)
864
+ XCTAssertEqual(trailing.frame.minX + button.frame.maxX, cell.root.bounds.maxX, accuracy: 0.5)
865
+ cell.frame.size.height = 88
866
+ try bind(base.merging(["trailing": [
867
+ ["kind": "button", "label": "A", "actionKey": "first"],
868
+ ["kind": "button", "label": "Longer action", "actionKey": "second"],
869
+ ], "height": 88]) { _, newer in newer })
870
+ let visibleButtons = trailing.arrangedSubviews.compactMap { $0 as? NativeListAccessoryButton }
871
+ .filter { !$0.isHidden }
872
+ XCTAssertEqual(visibleButtons.count, 2)
873
+ XCTAssertGreaterThan(visibleButtons[1].bounds.width, visibleButtons[0].bounds.width)
874
+ XCTAssertEqual(trailing.frame.minX + visibleButtons[1].frame.maxX,
875
+ cell.root.bounds.maxX, accuracy: 0.5)
876
+ cell.frame.size.height = 56
877
+ try bind(base.merging(["title": "A very long token symbol that must truncate within the cap"])
878
+ { _, newer in newer })
879
+ XCTAssertLessThan(titleLabel.bounds.width, titleLabel.intrinsicContentSize.width)
880
+ try bind(base)
881
+ XCTAssertNil(trailing.firstVisibleControl)
882
+ try bind(base.merging(["key": "searched-risk", "title": "rkl"]) { _, newer in newer })
883
+ XCTAssertNil(trailing.firstVisibleControl)
884
+ try bind(base)
885
+ var unrestricted = base
886
+ var unrestrictedStyle = try XCTUnwrap(base["style"] as? [String: Any])
887
+ unrestrictedStyle.removeValue(forKey: "leadingContentMaxWidthRatio")
888
+ unrestricted["style"] = unrestrictedStyle
889
+ try bind(unrestricted, capped: false)
890
+ XCTAssertEqual(cell.root.arrangedSubviews.count, 3)
891
+ XCTAssertEqual(mainStack.contentHuggingPriority(for: .horizontal).rawValue, 1, accuracy: 0.1)
892
+ let spacing = cell.root.customSpacing(after: mainStack)
893
+ XCTAssertTrue(spacing == UIStackView.spacingUseDefault || spacing == cell.root.spacing)
894
+ }
895
+
896
+ func testAutoHeightIdentityReservesMeasuredTrailingWidth() throws {
897
+ let subtitle = "A long recipient address that must wrap within the space left beside its values and controls"
898
+ func item(
899
+ trailing: [[String: Any]], horizontalPadding: Double? = nil,
900
+ presentation: String = ""
901
+ ) throws -> NativeListItem {
902
+ var style: [String: Any] = ["image": ["width": 40, "height": 40]]
903
+ if let horizontalPadding { style["horizontalPadding"] = horizontalPadding }
904
+ return try NativeListItem(data: [
905
+ "key": "recipient", "type": "identity", "title": "Recipient", "subtitle": subtitle,
906
+ "subtitleLines": 0, "contentHeight": "auto", "style": style,
907
+ "presentation": presentation,
908
+ "trailing": trailing,
909
+ ])
910
+ }
911
+ func height(width: CGFloat, item: NativeListItem) throws -> CGFloat {
912
+ return try XCTUnwrap(NativeListIdentityCell.measure(
913
+ item, width: width, theme: nil, layout: "linear", fonts: NativeListFontFamilies()))
914
+ }
915
+
916
+ let pair: [[String: Any]] = [[
917
+ "kind": "valuePair", "primary": "$123,456.78", "secondary": "12345 TOKEN",
918
+ ]]
919
+ let pairAndMenu = pair + [["kind": "menu", "actionKey": "menu"]]
920
+ let pairItem = try item(trailing: pair)
921
+ let pairHeight = try height(width: 240, item: pairItem)
922
+ XCTAssertGreaterThan(pairHeight, try height(width: 240, item: item(trailing: [])))
923
+ XCTAssertGreaterThanOrEqual(try height(width: 240, item: item(trailing: pairAndMenu)),
924
+ pairHeight)
925
+ let cell = NativeListIdentityCell(frame: CGRect(x: 0, y: 0, width: 240, height: pairHeight))
926
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 240, height: pairHeight))
927
+ window.addSubview(cell)
928
+ cell.bind(item: pairItem, theme: nil, layout: "linear", selected: false,
929
+ checkboxState: { _, _, fallback in fallback }, fonts: NativeListFontFamilies())
930
+ window.layoutIfNeeded()
931
+ cell.layoutIfNeeded()
932
+ let subtitleLabel = try XCTUnwrap(Mirror(reflecting: cell).children.first {
933
+ $0.label == "subtitleLabel"
934
+ }?.value as? UILabel)
935
+ XCTAssertGreaterThan(subtitleLabel.bounds.width, 0.5)
936
+ XCTAssertGreaterThanOrEqual(subtitleLabel.bounds.height + 0.5,
937
+ subtitleLabel.sizeThatFits(CGSize(
938
+ width: subtitleLabel.bounds.width, height: .greatestFiniteMagnitude)).height)
939
+ let accountIcon: [[String: Any]] = [[
940
+ "kind": "icon", "name": "DotHorOutline", "actionKey": "menu",
941
+ ]]
942
+ XCTAssertGreaterThanOrEqual(
943
+ try height(width: 220, item: item(trailing: accountIcon,
944
+ horizontalPadding: 28, presentation: "accountSelector")),
945
+ try height(width: 220, item: item(trailing: accountIcon,
946
+ presentation: "accountSelector")))
947
+ }
948
+
156
949
  func testFixedFooterReplacesRendererAndKeepsActionRouting() throws {
157
950
  let view = NativeListView(frame: CGRect(x: 0, y: 0, width: 320, height: 480))
158
951
  var actions: [[String: Any]] = []
@@ -246,6 +1039,35 @@ final class NativeListLifecycleTests: XCTestCase {
246
1039
  XCTAssertEqual(footer.accessibilityLabel, "Continue")
247
1040
  }
248
1041
 
1042
+ func testPaddedSquareNetworkOverlayRoundsInnerImageAndResetsOnRebind() throws {
1043
+ let visual = NativeListLeadingVisual(frame: CGRect(x: 0, y: 0, width: 40, height: 40))
1044
+ func bind(_ overlay: [String: Any]) {
1045
+ visual.bind(["kind": "token", "overlays": [overlay]], style: [:], key: "overlay",
1046
+ theme: nil, isUnread: false, fonts: NativeListFontFamilies())
1047
+ visual.layoutIfNeeded()
1048
+ }
1049
+ let image: [String: Any] = ["uri": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9xvY0AAAAASUVORK5CYII="]
1050
+ bind(["position": "bottomRight", "size": 20, "width": 20, "height": 20,
1051
+ "padding": 2, "image": image, "fallbackIcon": ["name": "GlobusOutline"]])
1052
+ let slots = try XCTUnwrap(Mirror(reflecting: visual).children.first {
1053
+ $0.label == "overlaySlots"
1054
+ }?.value as? [NativeListImageSlot])
1055
+ let slot = try XCTUnwrap(slots.first?.view)
1056
+ XCTAssertEqual(slot.bounds.size, CGSize(width: 16, height: 16))
1057
+ XCTAssertEqual(slot.layer.cornerRadius, 8, accuracy: 0.5)
1058
+ XCTAssertTrue(slot.clipsToBounds)
1059
+ bind(["position": "bottomRight", "width": 20, "height": 14,
1060
+ "padding": 2, "image": image])
1061
+ XCTAssertEqual(slot.layer.cornerRadius, 0)
1062
+ XCTAssertFalse(slot.clipsToBounds)
1063
+ bind(["position": "bottomRight", "size": 20, "padding": 2, "text": "E"])
1064
+ XCTAssertEqual(slot.layer.cornerRadius, 0)
1065
+ XCTAssertFalse(slot.clipsToBounds)
1066
+ bind(["position": "bottomRight", "size": 20, "padding": 2, "image": image])
1067
+ XCTAssertEqual(slot.layer.cornerRadius, 8, accuracy: 0.5)
1068
+ visual.recycle()
1069
+ }
1070
+
249
1071
  func testNestedWalletMembersAndDragBadgeFollowEachBindMapping() throws {
250
1072
  let group = NativeListWalletGroupCell(frame: CGRect(x: 0, y: 0, width: 320, height: 200))
251
1073
  let item = try NativeListItem(data: [
@@ -348,7 +1170,7 @@ final class NativeListLifecycleTests: XCTestCase {
348
1170
  XCTAssertTrue(header.clipsToBounds)
349
1171
  XCTAssertEqual(header.bounds.height, 0, "An unmeasured header cannot draw over the first rows")
350
1172
 
351
- view.setContainerSlotHeightsJson("[44,44,60]")
1173
+ view.setContainerSlotHeightsJson("[44,44,60,0]")
352
1174
  view.layoutIfNeeded()
353
1175
  list.layoutIfNeeded()
354
1176
  XCTAssertEqual(slots[0].convert(CGPoint.zero, to: list), CGPoint(x: 16, y: 0))
@@ -363,10 +1185,1905 @@ final class NativeListLifecycleTests: XCTestCase {
363
1185
  XCTAssertNil(slots[2].superview)
364
1186
  }
365
1187
 
1188
+ func testSidecarCentersRowsWhenConfiguredBeforeFirstWidth() throws {
1189
+ try assertHomeSidecarGeometry(sidecarBeforeFirstLayout: true)
1190
+ }
1191
+
1192
+ func testSidecarCentersRowsWhenConfiguredAfterFirstWidth() throws {
1193
+ try assertHomeSidecarGeometry(sidecarBeforeFirstLayout: false)
1194
+ }
1195
+
1196
+ func testHomeLinearColumnsStayInsideTheirStackAcrossEmptyMiddleAndReuse() throws {
1197
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 1376, height: 900))
1198
+ let controller = UIViewController()
1199
+ window.rootViewController = controller
1200
+ let view = NativeListView(frame: window.bounds)
1201
+ controller.view.addSubview(view)
1202
+ window.isHidden = false
1203
+ let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
1204
+ defer {
1205
+ view.dispose()
1206
+ view.removeFromSuperview()
1207
+ window.isHidden = true
1208
+ window.rootViewController = nil
1209
+ }
1210
+ for index in 0..<4 { view.mountContainerSlot(UIView(), atIndex: index) }
1211
+ view.setContainerSlotHeightsJson("[0,0,0,1200]")
1212
+ let sidecar = "{\"width\":400,\"gap\":24,\"maxContentWidth\":1140}"
1213
+ view.setSidecarLayoutJson(sidecar)
1214
+
1215
+ func columns(middle: String?, two: Bool = false) -> [[String: Any]] {
1216
+ let first: [String: Any] = ["key": "asset", "text": "USD1",
1217
+ "secondaryText": "Public fixture", "weight": 1]
1218
+ let balance: [String: Any] = ["key": "balance", "text": middle ?? "",
1219
+ "secondaryText": middle == nil ? "" : "$0.01", "alignment": "end", "weight": 1]
1220
+ let price: [String: Any] = ["key": "price", "text": "$1.00",
1221
+ "secondaryText": "0.01%", "alignment": "end", "weight": 1]
1222
+ return two ? [first, price] : [first, balance, price]
1223
+ }
1224
+ let rows: [[String: Any]] = (0..<30).map { index in
1225
+ ["key": "row-\(index)", "type": "dataRow", "height": 60,
1226
+ "leading": ["kind": "token", "fallbackIcon": ["name": "CryptoCoinOutline"]],
1227
+ "columns": index == 20 ? columns(middle: "0.01") : homeSidecarColumns(index, updated: false),
1228
+ "trailing": [["kind": "button", "label": "Swap", "actionKey": "swap"]],
1229
+ "style": ["horizontalPadding": 12, "verticalPadding": 8, "leadingGap": 12,
1230
+ "columns": ["token": "$bodyLg", "fontWeight": "medium", "lines": 1],
1231
+ "columnSecondary": ["token": "$bodyMd", "lines": 1],
1232
+ "image": ["width": 32, "height": 32]]]
1233
+ }
1234
+ let snapshot: [String: Any] = ["schemaVersion": 1, "generation": 1,
1235
+ "layout": ["kind": "linear", "contentPaddingHorizontal": 8], "rows": rows]
1236
+ view.applySnapshotJson(String(decoding: try JSONSerialization.data(withJSONObject: snapshot),
1237
+ as: UTF8.self))
1238
+ list.contentInsetAdjustmentBehavior = .never
1239
+ func layout() {
1240
+ window.layoutIfNeeded()
1241
+ view.layoutIfNeeded()
1242
+ list.layoutIfNeeded()
1243
+ }
1244
+ func check(_ phase: String, count: Int, width: CGFloat, weights: [Int]? = nil) throws {
1245
+ layout()
1246
+ let cell = try XCTUnwrap(list.cellForItem(at: IndexPath(item: 20, section: 0))
1247
+ as? NativeListDataRowCell, phase)
1248
+ let stack = try XCTUnwrap(cell.root.arrangedSubviews[3] as? UIStackView, phase)
1249
+ let visible = stack.arrangedSubviews.filter { !$0.isHidden }
1250
+ XCTAssertEqual(stack.arrangedSubviews.count, 4, phase)
1251
+ XCTAssertEqual(stack.arrangedSubviews.map(\.isHidden),
1252
+ (0..<4).map { $0 >= count }, phase)
1253
+ XCTAssertEqual(visible.count, count, phase)
1254
+ let cellFrame = cell.convert(cell.bounds, to: list)
1255
+ let stackFrame = stack.convert(stack.bounds, to: list)
1256
+ let attributes = try XCTUnwrap(list.layoutAttributesForItem(
1257
+ at: IndexPath(item: 20, section: 0)), phase)
1258
+ XCTAssertEqual(attributes.frame.width, width, accuracy: 1, phase)
1259
+ XCTAssertEqual(cellFrame.width, width, accuracy: 1,
1260
+ "\(phase) window=\(window.frame) controller=\(controller.view.frame) view=\(view.frame) list=\(list.frame) bounds=\(list.bounds) attributes=\(attributes.frame)")
1261
+ let rowConstraints = Mirror(reflecting: cell).children.first {
1262
+ $0.label == "rowConstraints"
1263
+ }?.value as? [NSLayoutConstraint] ?? []
1264
+ let columnWidthConstraints = Mirror(reflecting: cell).children.first {
1265
+ $0.label == "columnWidthConstraints"
1266
+ }?.value as? [NSLayoutConstraint] ?? []
1267
+ let constraints = rowConstraints + columnWidthConstraints
1268
+ let constraintState = constraints.map { constraint in
1269
+ let first = stack.arrangedSubviews.firstIndex {
1270
+ $0 === constraint.firstItem as? UIView
1271
+ } ?? -1
1272
+ let second = stack.arrangedSubviews.firstIndex {
1273
+ $0 === constraint.secondItem as? UIView
1274
+ } ?? -1
1275
+ return "\(first):\(second):\(constraint.multiplier):\(constraint.isActive)"
1276
+ }.joined(separator: ",")
1277
+ let hidingState = stack.arrangedSubviews.enumerated().map { index, column in
1278
+ let hiding = (stack.constraints + column.constraints).filter {
1279
+ $0.isActive && $0.identifier == "UISV-hiding"
1280
+ }.map { "\($0.constant)" }.joined(separator: ",")
1281
+ return "\(index):\(column.isHidden):\(hiding)"
1282
+ }.joined(separator: ",")
1283
+ let geometry = "\(phase) model=\(count) cell=\(cellFrame) stack=\(stackFrame) "
1284
+ + "columns=\(stack.arrangedSubviews.map { "\($0.isHidden):\($0.convert($0.bounds, to: list))" }) "
1285
+ + "constraints=\(constraintState) hiding=\(hidingState)"
1286
+ XCTAssertGreaterThanOrEqual(stackFrame.minX, cellFrame.minX - 0.5, geometry)
1287
+ XCTAssertLessThanOrEqual(stackFrame.maxX, cellFrame.maxX + 0.5, geometry)
1288
+ for column in visible {
1289
+ let columnFrame = column.convert(column.bounds, to: list)
1290
+ XCTAssertGreaterThan(columnFrame.width, 0, geometry)
1291
+ XCTAssertGreaterThanOrEqual(columnFrame.minX, stackFrame.minX - 0.5, geometry)
1292
+ XCTAssertLessThanOrEqual(columnFrame.maxX, stackFrame.maxX + 0.5, geometry)
1293
+ let primaryLine = (column as? UIStackView)?.arrangedSubviews.first
1294
+ if let primaryLine {
1295
+ let lineFrame = primaryLine.convert(primaryLine.bounds, to: list)
1296
+ XCTAssertGreaterThanOrEqual(lineFrame.minX, columnFrame.minX - 0.5, geometry)
1297
+ XCTAssertLessThanOrEqual(lineFrame.maxX, columnFrame.maxX + 0.5, geometry)
1298
+ }
1299
+ for label in labels(in: column) where !label.isHidden {
1300
+ let labelFrame = label.convert(label.bounds, to: list)
1301
+ XCTAssertGreaterThanOrEqual(labelFrame.minX, columnFrame.minX - 0.5,
1302
+ "\(geometry) label=\(labelFrame)")
1303
+ XCTAssertLessThanOrEqual(labelFrame.maxX, columnFrame.maxX + 0.5,
1304
+ "\(geometry) label=\(labelFrame)")
1305
+ }
1306
+ }
1307
+ if count >= 2 {
1308
+ let expectedWeights = weights ?? Array(repeating: 1, count: count)
1309
+ XCTAssertEqual(expectedWeights.count, count, geometry)
1310
+ for index in 1..<count {
1311
+ XCTAssertEqual(visible[index].bounds.width,
1312
+ visible[0].bounds.width * CGFloat(expectedWeights[index])
1313
+ / CGFloat(expectedWeights[0]),
1314
+ accuracy: 1, geometry)
1315
+ let previous = visible[index - 1].convert(visible[index - 1].bounds, to: stack)
1316
+ let current = visible[index].convert(visible[index].bounds, to: stack)
1317
+ XCTAssertGreaterThanOrEqual(current.minX, previous.maxX - 0.5, geometry)
1318
+ XCTAssertEqual(current.minX - previous.maxX, stack.spacing,
1319
+ accuracy: 0.5, geometry)
1320
+ }
1321
+ }
1322
+ }
1323
+ func patch(_ next: [[String: Any]]) throws {
1324
+ let patches: [[String: Any]] = [["key": "row-20", "type": "dataRow",
1325
+ "changes": ["columns": next]]]
1326
+ view.applyPatchesJson(String(decoding: try JSONSerialization.data(withJSONObject: patches),
1327
+ as: UTF8.self))
1328
+ }
1329
+ func widthConstraints() throws -> [NSLayoutConstraint] {
1330
+ let cell = try XCTUnwrap(list.cellForItem(at: IndexPath(item: 20, section: 0))
1331
+ as? NativeListDataRowCell)
1332
+ return try XCTUnwrap(Mirror(reflecting: cell).children.first {
1333
+ $0.label == "columnWidthConstraints"
1334
+ }?.value as? [NSLayoutConstraint])
1335
+ }
1336
+ func sizeConstraints() throws -> [NSLayoutConstraint] {
1337
+ let cell = try XCTUnwrap(list.cellForItem(at: IndexPath(item: 20, section: 0))
1338
+ as? NativeListDataRowCell)
1339
+ return try XCTUnwrap(Mirror(reflecting: cell).children.first {
1340
+ $0.label == "rowConstraints"
1341
+ }?.value as? [NSLayoutConstraint])
1342
+ }
1343
+ layout()
1344
+ list.setContentOffset(CGPoint(x: 0, y: 18 * 60), animated: false)
1345
+ try check("three populated", count: 3, width: 700)
1346
+ try patch(columns(middle: nil))
1347
+ try check("three empty middle", count: 3, width: 700)
1348
+ try patch(columns(middle: "0.02"))
1349
+ try check("three restored", count: 3, width: 700)
1350
+ try patch(columns(middle: nil, two: true))
1351
+ try check("two columns", count: 2, width: 700)
1352
+ try patch(columns(middle: nil))
1353
+ try check("three after two", count: 3, width: 700)
1354
+ let stableConstraints = try widthConstraints()
1355
+ let stableSizeConstraints = try sizeConstraints()
1356
+ XCTAssertEqual(stableConstraints.count, 2)
1357
+ XCTAssertEqual(stableSizeConstraints.count, 4)
1358
+ for update in 0..<10 {
1359
+ var next = columns(middle: nil)
1360
+ next[2]["text"] = "$1.\(update)"
1361
+ try patch(next)
1362
+ try check("same-shape price \(update)", count: 3, width: 700)
1363
+ let retained = try widthConstraints()
1364
+ XCTAssertEqual(retained.count, stableConstraints.count)
1365
+ XCTAssertTrue(zip(stableConstraints, retained).allSatisfy { $0 === $1 })
1366
+ XCTAssertTrue(retained.allSatisfy(\.isActive))
1367
+ let size = try sizeConstraints()
1368
+ XCTAssertEqual(size.count, stableSizeConstraints.count)
1369
+ XCTAssertTrue(zip(stableSizeConstraints, size).allSatisfy { $0 === $1 })
1370
+ XCTAssertTrue(size.allSatisfy(\.isActive))
1371
+ }
1372
+ var equivalentWeights = columns(middle: nil)
1373
+ for index in equivalentWeights.indices { equivalentWeights[index]["weight"] = 2 }
1374
+ try patch(equivalentWeights)
1375
+ try check("equivalent weights", count: 3, width: 700, weights: [2, 2, 2])
1376
+ let equivalentConstraints = try widthConstraints()
1377
+ XCTAssertEqual(equivalentConstraints.count, stableConstraints.count)
1378
+ XCTAssertTrue(zip(stableConstraints, equivalentConstraints).allSatisfy { $0 === $1 })
1379
+ var unequalWeights = columns(middle: nil)
1380
+ unequalWeights[0]["weight"] = 2
1381
+ try patch(unequalWeights)
1382
+ try check("unequal weights", count: 3, width: 700, weights: [2, 1, 1])
1383
+ let unequalConstraints = try widthConstraints()
1384
+ XCTAssertEqual(unequalConstraints.count, 2)
1385
+ XCTAssertTrue(stableConstraints.allSatisfy { !$0.isActive })
1386
+ try patch(columns(middle: nil, two: true))
1387
+ try check("two after weights", count: 2, width: 700)
1388
+ let twoConstraints = try widthConstraints()
1389
+ XCTAssertEqual(twoConstraints.count, 1)
1390
+ XCTAssertTrue(unequalConstraints.allSatisfy { !$0.isActive })
1391
+ try patch([])
1392
+ try check("zero columns", count: 0, width: 700)
1393
+ XCTAssertTrue((try widthConstraints()).isEmpty)
1394
+ XCTAssertTrue(twoConstraints.allSatisfy { !$0.isActive })
1395
+ try patch(columns(middle: nil))
1396
+ try check("three after zero", count: 3, width: 700)
1397
+ list.setContentOffset(.zero, animated: false)
1398
+ layout()
1399
+ XCTAssertFalse(list.indexPathsForVisibleItems.contains(IndexPath(item: 20, section: 0)))
1400
+ list.setContentOffset(CGPoint(x: 0, y: 18 * 60), animated: false)
1401
+ try check("after scroll reuse", count: 3, width: 700)
1402
+ window.frame.size.width = 1032
1403
+ controller.view.frame = window.bounds
1404
+ view.frame = window.bounds
1405
+ view.setSidecarLayoutJson("")
1406
+ drainMainQueue()
1407
+ try check("narrow no sidecar", count: 3, width: 1016)
1408
+ window.frame.size.width = 1376
1409
+ controller.view.frame = window.bounds
1410
+ view.frame = window.bounds
1411
+ view.setSidecarLayoutJson(sidecar)
1412
+ drainMainQueue()
1413
+ try check("wide sidecar restored", count: 3, width: 700)
1414
+ func animatedPatch(_ next: [[String: Any]], phase: String, count: Int) throws {
1415
+ let patches: [[String: Any]] = [["key": "row-20", "type": "dataRow",
1416
+ "changes": ["columns": next]]]
1417
+ let json = String(decoding: try JSONSerialization.data(withJSONObject: patches), as: UTF8.self)
1418
+ let finished = expectation(description: "Home column animation: \(phase)")
1419
+ UIView.animate(withDuration: 0.2, delay: 0, options: .beginFromCurrentState) {
1420
+ view.applyPatchesJson(json)
1421
+ view.layoutIfNeeded()
1422
+ list.layoutIfNeeded()
1423
+ } completion: { _ in finished.fulfill() }
1424
+ wait(for: [finished], timeout: 1)
1425
+ try check(phase, count: count, width: 700)
1426
+ waitForStructuralFrames(0.1)
1427
+ try check("\(phase) stable", count: count, width: 700)
1428
+ }
1429
+ try animatedPatch(columns(middle: nil, two: true), phase: "animated two", count: 2)
1430
+ try animatedPatch(columns(middle: nil), phase: "animated three", count: 3)
1431
+ try animatedPatch(columns(middle: "0.06"), phase: "animated nonempty", count: 3)
1432
+ }
1433
+
1434
+ func testStructuralShrinkUsesTheTallerFinalColumnForOffsetClamping() throws {
1435
+ let cases: [(name: String, sidecar: Int, offset: CGFloat, height: CGFloat,
1436
+ zeroInset: Bool)] = [
1437
+ ("sidecar stays taller", 2200, 1450, 2200, false),
1438
+ ("left column stays taller", 1000, 1100, 1560, false),
1439
+ ("left column zero inset", 1000, 1100, 1560, true),
1440
+ ("sidecar becomes taller", 1700, 1050, 1700, false),
1441
+ ]
1442
+ for testCase in cases {
1443
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 1000, height: 600))
1444
+ let controller = UIViewController()
1445
+ window.rootViewController = controller
1446
+ let view = NativeListView(frame: window.bounds)
1447
+ controller.view.addSubview(view)
1448
+ window.isHidden = false
1449
+ defer {
1450
+ view.dispose()
1451
+ view.removeFromSuperview()
1452
+ window.isHidden = true
1453
+ window.rootViewController = nil
1454
+ }
1455
+ let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
1456
+ // Keep the viewport insets fixed while testing structural offset clamping.
1457
+ let explicitInset = testCase.zeroInset ? UIEdgeInsets.zero :
1458
+ UIEdgeInsets(top: 62, left: 0, bottom: 34, right: 0)
1459
+ list.contentInsetAdjustmentBehavior = .never
1460
+ list.contentInset = explicitInset
1461
+ view.mountContainerSlot(UIView(), atIndex: 0)
1462
+ view.mountContainerSlot(UIView(), atIndex: 1)
1463
+ let footer = UIView()
1464
+ view.mountContainerSlot(footer, atIndex: 2)
1465
+ let sidecar = UIView()
1466
+ view.mountContainerSlot(sidecar, atIndex: 3)
1467
+ view.setContainerSlotHeightsJson("[0,0,60,\(testCase.sidecar)]")
1468
+ view.setSidecarLayoutJson("{\"width\":300,\"gap\":24,\"maxContentWidth\":1000}")
1469
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 30))
1470
+ view.layoutIfNeeded()
1471
+ list.layoutIfNeeded()
1472
+ drainMainQueue()
1473
+ XCTAssertEqual(list.adjustedContentInset, explicitInset, testCase.name)
1474
+ XCTAssertEqual(list.contentSize.height, max(1860, CGFloat(testCase.sidecar)),
1475
+ accuracy: 1, testCase.name)
1476
+ list.contentOffset.y = testCase.offset
1477
+ list.layoutIfNeeded()
1478
+ XCTAssertEqual(list.contentOffset.y, testCase.offset, accuracy: 1, testCase.name)
1479
+
1480
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 25, transitionID: testCase.name))
1481
+ let completed = expectation(description: "Native structural display link: \(testCase.name)")
1482
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { completed.fulfill() }
1483
+ wait(for: [completed], timeout: 1)
1484
+ list.layoutIfNeeded()
1485
+ XCTAssertEqual(list.adjustedContentInset, explicitInset, testCase.name)
1486
+ XCTAssertEqual(list.contentSize.height, testCase.height, accuracy: 1, testCase.name)
1487
+ let inset = list.adjustedContentInset
1488
+ let finalMaximum = max(-inset.top, testCase.height - list.bounds.height + inset.bottom)
1489
+ let footerTop: CGFloat = 1500
1490
+ let footerBottom: CGFloat = footerTop + 60
1491
+ let footerVisibleMinimum = footerBottom - list.bounds.height + inset.bottom
1492
+ let footerVisibleMaximum = footerTop - inset.top
1493
+ let expectedOffset = min(max(testCase.offset, footerVisibleMinimum),
1494
+ min(footerVisibleMaximum, finalMaximum))
1495
+ XCTAssertEqual(list.contentOffset.y, expectedOffset, accuracy: 1, testCase.name)
1496
+ XCTAssertNotNil(sidecar.superview, testCase.name)
1497
+ XCTAssertEqual(footer.convert(.zero, to: list).y, 1500, accuracy: 1, testCase.name)
1498
+ waitForStructuralFrames(0.1)
1499
+ XCTAssertEqual(list.adjustedContentInset, explicitInset, testCase.name)
1500
+ XCTAssertEqual(list.contentSize.height, testCase.height, accuracy: 1, testCase.name)
1501
+ XCTAssertEqual(list.contentOffset.y, expectedOffset, accuracy: 1, testCase.name)
1502
+ }
1503
+ }
1504
+
1505
+ func testStructuralTransitionFromGridOrHorizontalCommitsAtomically() throws {
1506
+ try requireStructuralAnimation()
1507
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 400, height: 600))
1508
+ let controller = UIViewController()
1509
+ window.rootViewController = controller
1510
+ let view = NativeListView(frame: window.bounds)
1511
+ controller.view.addSubview(view)
1512
+ window.isHidden = false
1513
+ let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
1514
+ defer {
1515
+ view.dispose()
1516
+ view.removeFromSuperview()
1517
+ window.isHidden = true
1518
+ window.rootViewController = nil
1519
+ }
1520
+ view.applySnapshotJson(try structuralRowsSnapshot(
1521
+ count: 6, generation: 1, layoutKind: "grid"))
1522
+ view.layoutIfNeeded()
1523
+ view.applySnapshotJson(try structuralRowsSnapshot(
1524
+ count: 8, generation: 2, transitionID: "from-grid", layoutKind: "linear"))
1525
+ XCTAssertNil(Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink)
1526
+ XCTAssertEqual(list.numberOfItems(inSection: 0), 8)
1527
+
1528
+ view.applySnapshotJson(try structuralRowsSnapshot(
1529
+ count: 6, generation: 3, orientation: "horizontal"))
1530
+ view.layoutIfNeeded()
1531
+ view.applySnapshotJson(try structuralRowsSnapshot(
1532
+ count: 8, generation: 4, transitionID: "from-horizontal", layoutKind: "table"))
1533
+ XCTAssertNil(Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink)
1534
+ XCTAssertEqual(list.numberOfItems(inSection: 0), 8)
1535
+ }
1536
+
1537
+ func testVisibleNativeCellsRemainBoundedAsRowsGrowTenfold() throws {
1538
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
1539
+ let controller = UIViewController()
1540
+ window.rootViewController = controller
1541
+ let view = NativeListView(frame: window.bounds)
1542
+ controller.view.addSubview(view)
1543
+ window.isHidden = false
1544
+ defer { window.isHidden = true }
1545
+ let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
1546
+ func sampleCells(count: Int) throws -> [(visible: Int, attached: Int)] {
1547
+ view.applySnapshotJson(try structuralRowsSnapshot(count: count))
1548
+ view.layoutIfNeeded()
1549
+ list.layoutIfNeeded()
1550
+ let lastOffset = CGFloat(count * 60 - 600)
1551
+ return [CGFloat(0), lastOffset / 2, lastOffset].map { offset in
1552
+ list.setContentOffset(CGPoint(x: 0, y: offset), animated: false)
1553
+ list.layoutIfNeeded()
1554
+ return (list.visibleCells.count,
1555
+ list.subviews.compactMap { $0 as? NativeListRowHost }.count)
1556
+ }
1557
+ }
1558
+ let hundredCells = try sampleCells(count: 100)
1559
+ let thousandCells = try sampleCells(count: 1000)
1560
+ print("NativeList 100 visible/attached first/mid/tail: \(hundredCells)")
1561
+ print("NativeList 1000 visible/attached first/mid/tail: \(thousandCells)")
1562
+ XCTAssertTrue(hundredCells.allSatisfy {
1563
+ $0.visible > 0 && $0.visible <= 24 && $0.attached > 0 && $0.attached <= 24
1564
+ })
1565
+ XCTAssertTrue(thousandCells.allSatisfy {
1566
+ $0.visible > 0 && $0.visible <= 24 && $0.attached > 0 && $0.attached <= 24
1567
+ })
1568
+ XCTAssertLessThanOrEqual(thousandCells.map(\.attached).max() ?? 0,
1569
+ (hundredCells.map(\.attached).max() ?? 0) + 4)
1570
+ }
1571
+
1572
+ func testStructuralTransitionRapidReversalKeepsRetiringContentAtNaturalHeight() throws {
1573
+ try requireStructuralAnimation()
1574
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
1575
+ let controller = UIViewController()
1576
+ window.rootViewController = controller
1577
+ let view = NativeListView(frame: window.bounds)
1578
+ controller.view.addSubview(view)
1579
+ window.isHidden = false
1580
+ defer { view.dispose(); view.removeFromSuperview(); window.isHidden = true }
1581
+ let footer = UIView()
1582
+ for index in 0..<2 { view.mountContainerSlot(UIView(), atIndex: index) }
1583
+ view.mountContainerSlot(footer, atIndex: 2)
1584
+ view.setContainerSlotHeightsJson("[0,0,60,0]")
1585
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 25, generation: 1))
1586
+ view.layoutIfNeeded()
1587
+ let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
1588
+ list.layoutIfNeeded()
1589
+ drainMainQueue()
1590
+ list.contentOffset.y = 900
1591
+ list.layoutIfNeeded()
1592
+
1593
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 30, generation: 2,
1594
+ transitionID: "expand-first", durationMs: 400))
1595
+ waitForStructuralFrames(0.08)
1596
+ list.contentOffset.y = 1100
1597
+ list.layoutIfNeeded()
1598
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 25, generation: 3,
1599
+ transitionID: "collapse-middle", durationMs: 400))
1600
+ waitForStructuralFrames(0.08)
1601
+ list.layoutIfNeeded()
1602
+ let source = try XCTUnwrap(list.dataSource as? UICollectionViewDiffableDataSource<Int, String>)
1603
+ let flow = try XCTUnwrap(list.collectionViewLayout as? NativeListFlowLayout)
1604
+ let clip = try XCTUnwrap(flow.tailClip)
1605
+ let retiring = try XCTUnwrap(list.cellForItem(at: IndexPath(item: 25, section: 0)) as? NativeListRowHost)
1606
+ XCTAssertGreaterThan(clip.endY, 1500)
1607
+ XCTAssertLessThan(clip.endY, 1800)
1608
+ XCTAssertEqual(retiring.bounds.height, 60, accuracy: 1)
1609
+ XCTAssertEqual(retiring.contentView.bounds.height, 60, accuracy: 1)
1610
+ XCTAssertNil(retiring.structuralContentHeight)
1611
+ XCTAssertEqual(retiring.alpha, 1, accuracy: 0.01)
1612
+ XCTAssertFalse(retiring.isUserInteractionEnabled)
1613
+ XCTAssertTrue(retiring.accessibilityElementsHidden)
1614
+ XCTAssertEqual(footer.convert(.zero, to: list).y,
1615
+ clip.endY, accuracy: 1)
1616
+
1617
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 30, generation: 4,
1618
+ transitionID: "expand-last", durationMs: 400))
1619
+ waitForStructuralFrames(0.5)
1620
+ list.layoutIfNeeded()
1621
+ let keys = source.snapshot().itemIdentifiers
1622
+ XCTAssertEqual(keys, (0..<30).map { "row-\($0)" })
1623
+ XCTAssertEqual(Set(keys).count, 30)
1624
+ XCTAssertEqual(list.contentSize.height, 1860, accuracy: 1)
1625
+ XCTAssertEqual(footer.convert(.zero, to: list).y, 1800, accuracy: 1)
1626
+ XCTAssertGreaterThanOrEqual(list.contentOffset.y, -list.adjustedContentInset.top - 1)
1627
+ XCTAssertLessThanOrEqual(list.contentOffset.y,
1628
+ list.contentSize.height - list.bounds.height + list.adjustedContentInset.bottom + 1)
1629
+ for case let cell as NativeListRowHost in list.visibleCells {
1630
+ XCTAssertEqual(cell.alpha, 1, accuracy: 0.01)
1631
+ XCTAssertNil(cell.structuralContentHeight)
1632
+ }
1633
+ }
1634
+
1635
+ func testHybridSettlementListenerFollowsRegistration() throws {
1636
+ let hybrid = HybridNativeList()
1637
+ let host = try XCTUnwrap(hybrid.view as? NativeListView)
1638
+ var first: [String] = []
1639
+ var second: [String] = []
1640
+ XCTAssertNil(host.onStructuralTransitionSettled)
1641
+
1642
+ hybrid.onStructuralTransitionSettled = { first.append($0) }
1643
+ host.onStructuralTransitionSettled?("first")
1644
+ XCTAssertEqual(first, ["first"])
1645
+
1646
+ hybrid.onStructuralTransitionSettled = { second.append($0) }
1647
+ host.onStructuralTransitionSettled?("second")
1648
+ XCTAssertEqual(first, ["first"])
1649
+ XCTAssertEqual(second, ["second"])
1650
+
1651
+ hybrid.onStructuralTransitionSettled = nil
1652
+ XCTAssertNil(host.onStructuralTransitionSettled)
1653
+ hybrid.dispose()
1654
+ }
1655
+
1656
+ func testDisclosureWithoutListenerStillSettlesWithoutReplay() throws {
1657
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
1658
+ let controller = UIViewController()
1659
+ window.rootViewController = controller
1660
+ let view = NativeListView(frame: window.bounds)
1661
+ controller.view.addSubview(view)
1662
+ window.isHidden = false
1663
+ defer {
1664
+ view.dispose()
1665
+ view.removeFromSuperview()
1666
+ window.isHidden = true
1667
+ window.rootViewController = nil
1668
+ }
1669
+ for index in 0..<6 { view.mountContainerSlot(UIView(), atIndex: index) }
1670
+ view.setContainerSlotHeightsJson("[0,0,60,0,100,40]")
1671
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 1,
1672
+ disclosureExpanded: false))
1673
+ view.layoutIfNeeded()
1674
+
1675
+ let first = try structuralRowsSnapshot(count: 6, generation: 2,
1676
+ transitionID: "without-listener", durationMs: 0, disclosureExpanded: true)
1677
+ view.applySnapshotJson(first)
1678
+ drainMainQueue()
1679
+ XCTAssertEqual(Mirror(reflecting: view).descendant("pendingDisclosureSettlement")
1680
+ .map { Mirror(reflecting: $0).children.count }, 0)
1681
+
1682
+ var settlements: [[String: Any]] = []
1683
+ view.onStructuralTransitionSettled = { payload in
1684
+ guard let data = payload.data(using: .utf8),
1685
+ let value = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
1686
+ return
1687
+ }
1688
+ settlements.append(value)
1689
+ }
1690
+ view.applySnapshotJson(first)
1691
+ drainMainQueue()
1692
+ XCTAssertTrue(settlements.isEmpty)
1693
+
1694
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 3,
1695
+ transitionID: "with-listener", durationMs: 0, disclosureExpanded: false))
1696
+ drainMainQueue()
1697
+ XCTAssertEqual(settlements.count, 1)
1698
+ XCTAssertEqual(settlements.first?["transitionId"] as? String, "with-listener")
1699
+ XCTAssertEqual(settlements.first?["status"] as? String, "completed")
1700
+ }
1701
+
1702
+ func testDisclosureInitialEndpoints() throws {
1703
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
1704
+ let controller = UIViewController()
1705
+ window.rootViewController = controller
1706
+ let view = NativeListView(frame: window.bounds)
1707
+ controller.view.addSubview(view)
1708
+ window.isHidden = false
1709
+ defer {
1710
+ view.dispose()
1711
+ view.removeFromSuperview()
1712
+ window.isHidden = true
1713
+ window.rootViewController = nil
1714
+ }
1715
+ let slots = (0..<6).map { _ in UIView() }
1716
+ for (index, slot) in slots.enumerated() { view.mountContainerSlot(slot, atIndex: index) }
1717
+ view.setContainerSlotHeightsJson("[0,0,140,0,100,40]")
1718
+ var settlements: [String] = []
1719
+ view.onStructuralTransitionSettled = { settlements.append($0) }
1720
+ let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
1721
+ list.contentInsetAdjustmentBehavior = .never
1722
+ list.contentInset = .zero
1723
+
1724
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 1,
1725
+ disclosureExpanded: false))
1726
+ view.layoutIfNeeded()
1727
+ list.layoutIfNeeded()
1728
+ let footerHost = try XCTUnwrap(slots[2].superview)
1729
+ let contentHost = try XCTUnwrap(slots[4].superview)
1730
+ let toggleHost = try XCTUnwrap(slots[5].superview)
1731
+ XCTAssertEqual(contentHost.frame.minY, 360, accuracy: 1)
1732
+ XCTAssertEqual(contentHost.bounds.height, 100, accuracy: 1)
1733
+ XCTAssertTrue(contentHost.isHidden)
1734
+ XCTAssertFalse(contentHost.isUserInteractionEnabled)
1735
+ XCTAssertTrue(contentHost.accessibilityElementsHidden)
1736
+ XCTAssertEqual(toggleHost.frame.minY, 360, accuracy: 1)
1737
+ XCTAssertEqual(footerHost.frame.minY, 400, accuracy: 1)
1738
+ XCTAssertEqual(list.contentSize.height, 540, accuracy: 1)
1739
+ XCTAssertTrue(toggleHost.point(inside: CGPoint(x: 10, y: 10), with: nil))
1740
+ XCTAssertTrue(settlements.isEmpty)
1741
+
1742
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 2,
1743
+ disclosureExpanded: true))
1744
+ view.layoutIfNeeded()
1745
+ list.layoutIfNeeded()
1746
+ XCTAssertEqual(contentHost.frame.minY, 360, accuracy: 1)
1747
+ XCTAssertEqual(contentHost.bounds.height, 100, accuracy: 1)
1748
+ XCTAssertFalse(contentHost.isHidden)
1749
+ XCTAssertTrue(contentHost.isUserInteractionEnabled)
1750
+ XCTAssertFalse(contentHost.accessibilityElementsHidden)
1751
+ XCTAssertEqual(toggleHost.frame.minY, 460, accuracy: 1)
1752
+ XCTAssertEqual(footerHost.frame.minY, 520, accuracy: 1)
1753
+ XCTAssertEqual(list.contentSize.height, 660, accuracy: 1)
1754
+ XCTAssertTrue(settlements.isEmpty)
1755
+ }
1756
+
1757
+ func testDisclosureCollapseClock() throws {
1758
+ try requireStructuralAnimation()
1759
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
1760
+ let controller = UIViewController()
1761
+ window.rootViewController = controller
1762
+ let view = NativeListView(frame: window.bounds)
1763
+ controller.view.addSubview(view)
1764
+ window.isHidden = false
1765
+ defer {
1766
+ view.dispose()
1767
+ view.removeFromSuperview()
1768
+ window.isHidden = true
1769
+ window.rootViewController = nil
1770
+ }
1771
+ let slots = (0..<6).map { _ in UIView() }
1772
+ let expandedChild = UIView(frame: CGRect(x: 0, y: 0, width: 320, height: 100))
1773
+ slots[4].addSubview(expandedChild)
1774
+ let toggle = UIButton(frame: CGRect(x: 0, y: 0, width: 320, height: 40))
1775
+ for index in 0..<6 {
1776
+ view.mountContainerSlot(index == 5 ? toggle : slots[index], atIndex: index)
1777
+ }
1778
+ view.setContainerSlotHeightsJson("[0,0,140,0,100,40]")
1779
+ var settlements: [[String: Any]] = []
1780
+ view.onStructuralTransitionSettled = { payload in
1781
+ guard let data = payload.data(using: .utf8),
1782
+ let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
1783
+ return
1784
+ }
1785
+ settlements.append(object)
1786
+ }
1787
+ let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
1788
+ list.contentInsetAdjustmentBehavior = .never
1789
+ list.contentInset = .zero
1790
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 50, generation: 1,
1791
+ disclosureExpanded: true))
1792
+ view.layoutIfNeeded()
1793
+ list.layoutIfNeeded()
1794
+ drainMainQueue()
1795
+ list.contentOffset.y = 2400
1796
+ list.layoutIfNeeded()
1797
+
1798
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 2,
1799
+ transitionID: "disclosure-collapse", durationMs: 800,
1800
+ disclosureExpanded: false))
1801
+ waitForStructuralFrames(0.16)
1802
+ list.layoutIfNeeded()
1803
+ let flow = try XCTUnwrap(list.collectionViewLayout as? NativeListFlowLayout)
1804
+ let clip = try XCTUnwrap(flow.tailClip)
1805
+ let contentHost = try XCTUnwrap(slots[4].superview)
1806
+ let toggleHost = try XCTUnwrap(toggle.superview)
1807
+ let footerHost = try XCTUnwrap(slots[2].superview)
1808
+ XCTAssertEqual(contentHost.bounds.height, 100, accuracy: 1)
1809
+ XCTAssertEqual(contentHost.frame.minY, clip.endY, accuracy: 1)
1810
+ let contentVisible = toggleHost.frame.minY - contentHost.frame.minY
1811
+ XCTAssertGreaterThan(contentVisible, 0)
1812
+ XCTAssertLessThan(contentVisible, 100)
1813
+ XCTAssertEqual(try XCTUnwrap(contentHost.layer.mask).frame.height,
1814
+ contentVisible, accuracy: 1)
1815
+ XCTAssertFalse(contentHost.isUserInteractionEnabled)
1816
+ XCTAssertTrue(contentHost.accessibilityElementsHidden)
1817
+ XCTAssertTrue(toggleHost.isUserInteractionEnabled)
1818
+ XCTAssertFalse(toggleHost.accessibilityElementsHidden)
1819
+ XCTAssertTrue(toggleHost.point(inside: CGPoint(x: 10, y: 10), with: nil))
1820
+ XCTAssertEqual(toggleHost.frame.minY, clip.endY + contentVisible, accuracy: 1)
1821
+ let inset = footerHost.frame.minY - toggleHost.frame.maxY
1822
+ XCTAssertGreaterThan(inset, 0)
1823
+ XCTAssertLessThan(inset, 20)
1824
+ XCTAssertTrue(settlements.isEmpty)
1825
+
1826
+ waitForStructuralFrames(0.75)
1827
+ list.layoutIfNeeded()
1828
+ XCTAssertNil(flow.tailClip)
1829
+ XCTAssertEqual(toggleHost.frame.minY, 360, accuracy: 1)
1830
+ XCTAssertEqual(footerHost.frame.minY, 400, accuracy: 1)
1831
+ XCTAssertTrue(contentHost.isHidden)
1832
+ XCTAssertEqual(settlements.count, 1)
1833
+ XCTAssertEqual(settlements.first?["transitionId"] as? String, "disclosure-collapse")
1834
+ XCTAssertEqual(settlements.first?["status"] as? String, "completed")
1835
+ XCTAssertEqual(settlements.first?["settledExpanded"] as? Bool, false)
1836
+
1837
+ let roots = (0..<6).map { $0 == 5 ? toggle : slots[$0] }
1838
+ let hosts = roots.map(\.superview)
1839
+ XCTAssertTrue(hosts.allSatisfy { $0 != nil })
1840
+ let toggleWindowOrigin = toggleHost.convert(CGPoint.zero, to: window)
1841
+ let footerWindowOrigin = footerHost.convert(CGPoint.zero, to: window)
1842
+ let settledOffset = list.contentOffset
1843
+ let settledSize = list.contentSize
1844
+ expandedChild.removeFromSuperview()
1845
+ view.setContainerSlotHeightsJson("[0,0,140,0,0,40]")
1846
+ view.layoutIfNeeded()
1847
+ list.layoutIfNeeded()
1848
+ drainMainQueue()
1849
+ view.layoutIfNeeded()
1850
+ list.layoutIfNeeded()
1851
+ for (root, host) in zip(roots, hosts) {
1852
+ XCTAssertTrue(root.superview === host)
1853
+ XCTAssertTrue(root.window === window)
1854
+ }
1855
+ XCTAssertEqual(toggleHost.convert(CGPoint.zero, to: window).x, toggleWindowOrigin.x, accuracy: 1)
1856
+ XCTAssertEqual(toggleHost.convert(CGPoint.zero, to: window).y, toggleWindowOrigin.y, accuracy: 1)
1857
+ XCTAssertEqual(footerHost.convert(CGPoint.zero, to: window).x, footerWindowOrigin.x, accuracy: 1)
1858
+ XCTAssertEqual(footerHost.convert(CGPoint.zero, to: window).y, footerWindowOrigin.y, accuracy: 1)
1859
+ XCTAssertEqual(list.contentOffset.x, settledOffset.x, accuracy: 1)
1860
+ XCTAssertEqual(list.contentOffset.y, settledOffset.y, accuracy: 1)
1861
+ XCTAssertEqual(list.contentSize.width, settledSize.width, accuracy: 1)
1862
+ XCTAssertEqual(list.contentSize.height, settledSize.height, accuracy: 1)
1863
+ XCTAssertEqual(settlements.count, 1)
1864
+ }
1865
+
1866
+ func testDisclosureAckLatest() throws {
1867
+ try requireStructuralAnimation()
1868
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
1869
+ let controller = UIViewController()
1870
+ window.rootViewController = controller
1871
+ let view = NativeListView(frame: window.bounds)
1872
+ controller.view.addSubview(view)
1873
+ window.isHidden = false
1874
+ defer {
1875
+ view.dispose()
1876
+ view.removeFromSuperview()
1877
+ window.isHidden = true
1878
+ window.rootViewController = nil
1879
+ }
1880
+ let slots = (0..<6).map { _ in UIView() }
1881
+ for (index, slot) in slots.enumerated() { view.mountContainerSlot(slot, atIndex: index) }
1882
+ view.setContainerSlotHeightsJson("[0,0,140,0,100,40]")
1883
+ var settlements: [(String, String, Bool)] = []
1884
+ view.onStructuralTransitionSettled = { payload in
1885
+ guard let data = payload.data(using: .utf8),
1886
+ let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
1887
+ let id = object["transitionId"] as? String,
1888
+ let status = object["status"] as? String,
1889
+ let expanded = object["settledExpanded"] as? Bool else { return }
1890
+ settlements.append((id, status, expanded))
1891
+ }
1892
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 1,
1893
+ disclosureExpanded: false))
1894
+ view.layoutIfNeeded()
1895
+ drainMainQueue()
1896
+ XCTAssertTrue(settlements.isEmpty)
1897
+
1898
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 2,
1899
+ transitionID: "same-rows", durationMs: 400, disclosureExpanded: false))
1900
+ drainMainQueue()
1901
+ XCTAssertEqual(settlements.map(\.0), ["same-rows"])
1902
+ XCTAssertEqual(settlements.first?.1, "completed")
1903
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 3,
1904
+ transitionID: "same-rows", durationMs: 400, disclosureExpanded: false))
1905
+ drainMainQueue()
1906
+ XCTAssertEqual(settlements.count, 1)
1907
+
1908
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 50, generation: 4,
1909
+ transitionID: "expand-superseded", durationMs: 400,
1910
+ disclosureExpanded: true))
1911
+ waitForStructuralFrames(0.08)
1912
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 5,
1913
+ transitionID: "collapse-latest", durationMs: 400,
1914
+ disclosureExpanded: false))
1915
+ waitForStructuralFrames(0.5)
1916
+ XCTAssertEqual(settlements.map(\.0), ["same-rows", "collapse-latest"])
1917
+ XCTAssertEqual(settlements.last?.1, "completed")
1918
+ XCTAssertEqual(settlements.last?.2, false)
1919
+ XCTAssertTrue(slots[4].superview?.isHidden ?? false)
1920
+
1921
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 0, generation: 6,
1922
+ transitionID: "empty-immediate", durationMs: 0,
1923
+ disclosureExpanded: false))
1924
+ drainMainQueue()
1925
+ XCTAssertEqual(settlements.map(\.0),
1926
+ ["same-rows", "collapse-latest", "empty-immediate"])
1927
+ XCTAssertEqual(settlements.last?.1, "completed")
1928
+ }
1929
+
1930
+ func testDisclosureGeometryFallbackUsesLatestSnapshot() throws {
1931
+ try requireStructuralAnimation()
1932
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
1933
+ let controller = UIViewController()
1934
+ window.rootViewController = controller
1935
+ let view = NativeListView(frame: window.bounds)
1936
+ controller.view.addSubview(view)
1937
+ window.isHidden = false
1938
+ defer {
1939
+ view.dispose()
1940
+ view.removeFromSuperview()
1941
+ window.isHidden = true
1942
+ window.rootViewController = nil
1943
+ }
1944
+ let slots = (0..<6).map { _ in UIView() }
1945
+ for (index, slot) in slots.enumerated() { view.mountContainerSlot(slot, atIndex: index) }
1946
+ view.setContainerSlotHeightsJson("[0,0,140,0,100,40]")
1947
+ let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
1948
+ list.contentInsetAdjustmentBehavior = .never
1949
+ var settlements: [(String, String)] = []
1950
+ view.onStructuralTransitionSettled = { payload in
1951
+ guard let data = payload.data(using: .utf8),
1952
+ let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
1953
+ let id = object["transitionId"] as? String,
1954
+ let status = object["status"] as? String else { return }
1955
+ settlements.append((id, status))
1956
+ }
1957
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 1,
1958
+ disclosureExpanded: false))
1959
+ view.layoutIfNeeded()
1960
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 50, generation: 2,
1961
+ transitionID: "expand", durationMs: 800, disclosureExpanded: true))
1962
+ waitForStructuralFrames(0.08)
1963
+ XCTAssertNotNil(Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink)
1964
+
1965
+ view.setContainerSlotHeightsJson("[0,0,160,0,100,40]", followedBySnapshot: true)
1966
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 50, generation: 3,
1967
+ transitionID: "expand", durationMs: 800, disclosureExpanded: true))
1968
+ drainMainQueue()
1969
+ XCTAssertNil(Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink)
1970
+ XCTAssertNil((list.collectionViewLayout as? NativeListFlowLayout)?.tailClip)
1971
+ let contentHost = try XCTUnwrap(slots[4].superview)
1972
+ let toggleHost = try XCTUnwrap(slots[5].superview)
1973
+ let footerHost = try XCTUnwrap(slots[2].superview)
1974
+ XCTAssertEqual(contentHost.bounds.height, 100, accuracy: 1)
1975
+ XCTAssertEqual(toggleHost.frame.minY, 3_100, accuracy: 1)
1976
+ XCTAssertEqual(footerHost.frame.minY, 3_160, accuracy: 1)
1977
+ XCTAssertEqual(footerHost.bounds.height, 160, accuracy: 1)
1978
+ XCTAssertEqual(settlements.map(\.0), ["expand"])
1979
+ XCTAssertEqual(settlements.last?.1, "interrupted")
1980
+
1981
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 4,
1982
+ transitionID: "collapse", durationMs: 800, disclosureExpanded: false))
1983
+ waitForStructuralFrames(0.08)
1984
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 5,
1985
+ transitionID: "collapse", durationMs: 800))
1986
+ drainMainQueue()
1987
+ XCTAssertNil(Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink)
1988
+ XCTAssertNil((list.collectionViewLayout as? NativeListFlowLayout)?.tailClip)
1989
+ XCTAssertEqual(settlements.map(\.0), ["expand"],
1990
+ "Removing disclosure without a new command cannot acknowledge the old target")
1991
+ }
1992
+
1993
+ func testDisclosureLateCompletionCannotSettleNewCommand() throws {
1994
+ try requireStructuralAnimation()
1995
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
1996
+ let controller = UIViewController()
1997
+ window.rootViewController = controller
1998
+ let view = NativeListView(frame: window.bounds)
1999
+ controller.view.addSubview(view)
2000
+ window.isHidden = false
2001
+ defer {
2002
+ view.dispose()
2003
+ view.removeFromSuperview()
2004
+ window.isHidden = true
2005
+ window.rootViewController = nil
2006
+ }
2007
+ for index in 0..<6 { view.mountContainerSlot(UIView(), atIndex: index) }
2008
+ view.setContainerSlotHeightsJson("[0,0,140,0,100,40]")
2009
+ var settled: [String] = []
2010
+ view.onStructuralTransitionSettled = { payload in
2011
+ guard let data = payload.data(using: .utf8),
2012
+ let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
2013
+ let id = object["transitionId"] as? String else { return }
2014
+ settled.append(id)
2015
+ }
2016
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 1,
2017
+ disclosureExpanded: false))
2018
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 2,
2019
+ transitionID: "old-direct", disclosureExpanded: false))
2020
+ let oldCompletedBeforeReplacement = settled.contains("old-direct")
2021
+ XCTAssertFalse(oldCompletedBeforeReplacement,
2022
+ "The older diffable completion must remain pending when its replacement starts")
2023
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 50, generation: 3,
2024
+ transitionID: "latest-expand", durationMs: 800, disclosureExpanded: true))
2025
+ drainMainQueue()
2026
+ XCTAssertNotNil(Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink)
2027
+ XCTAssertTrue(settled.isEmpty,
2028
+ "An older diffable completion must not settle the active newer transition")
2029
+ waitForStructuralFrames(0.9)
2030
+ XCTAssertEqual(settled, ["latest-expand"])
2031
+ }
2032
+
2033
+ func testDisclosureContentGrowthAndDragSettlement() throws {
2034
+ try requireStructuralAnimation()
2035
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
2036
+ let controller = UIViewController()
2037
+ window.rootViewController = controller
2038
+ let view = NativeListView(frame: window.bounds)
2039
+ controller.view.addSubview(view)
2040
+ window.isHidden = false
2041
+ defer {
2042
+ view.dispose()
2043
+ view.removeFromSuperview()
2044
+ window.isHidden = true
2045
+ window.rootViewController = nil
2046
+ }
2047
+ let slots = (0..<6).map { _ in UIView() }
2048
+ for (index, slot) in slots.enumerated() { view.mountContainerSlot(slot, atIndex: index) }
2049
+ view.setContainerSlotHeightsJson("[0,0,140,0,100,40]")
2050
+ let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
2051
+ list.contentInsetAdjustmentBehavior = .never
2052
+ let contentHost = try XCTUnwrap(slots[4].superview)
2053
+ var settlements: [(String, String)] = []
2054
+ let growSettled = expectation(description: "Disclosure growth settles")
2055
+ view.onStructuralTransitionSettled = { payload in
2056
+ guard let data = payload.data(using: .utf8),
2057
+ let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
2058
+ let id = object["transitionId"] as? String,
2059
+ let status = object["status"] as? String else { return }
2060
+ settlements.append((id, status))
2061
+ if id == "grow" && status == "completed" { growSettled.fulfill() }
2062
+ }
2063
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 1,
2064
+ disclosureExpanded: false))
2065
+ view.layoutIfNeeded()
2066
+ list.layoutIfNeeded()
2067
+ drainMainQueue()
2068
+ XCTAssertTrue(contentHost.isHidden, "Initial collapsed content must be hidden")
2069
+ XCTAssertNil(Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink)
2070
+
2071
+ let growthPhase = expectation(description: "Disclosure growth presents a partial content mask")
2072
+ var growthObserved = false
2073
+ var growthLast = "no frame sampled"
2074
+ let growthStartedAt = CACurrentMediaTime()
2075
+ let growthProbe = Timer(timeInterval: 1.0 / 60, repeats: true) { _ in
2076
+ let clock = Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink
2077
+ let tail = Mirror(reflecting: view).descendant("structuralTail")
2078
+ let tailActive = tail.map { !Mirror(reflecting: $0).children.isEmpty } ?? false
2079
+ let maskHeight = contentHost.layer.mask?.frame.height
2080
+ growthLast = "clock=\(clock != nil) tail=\(tailActive) "
2081
+ + "mask=\(String(describing: maskHeight)) bounds=\(contentHost.bounds.height) "
2082
+ + "elapsed=\(CACurrentMediaTime() - growthStartedAt) settlements=\(settlements)"
2083
+ guard !growthObserved, let clock, tailActive, let maskHeight,
2084
+ maskHeight > 0, maskHeight < 100 else { return }
2085
+ growthObserved = true
2086
+ let animationStart = Mirror(reflecting: view).descendant("structuralAnimationStart")
2087
+ as? CFTimeInterval
2088
+ let animationDuration = Mirror(reflecting: view).descendant("structuralAnimationDuration")
2089
+ as? CFTimeInterval
2090
+ XCTAssertNotNil(animationStart, "The active disclosure clock must have a start time")
2091
+ XCTAssertNotNil(animationDuration, "The active disclosure clock must have a duration")
2092
+ view.setContainerSlotHeightsJson("[0,0,140,0,140,40]")
2093
+ XCTAssertTrue(Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink
2094
+ === clock, "Content growth must retain the active display link")
2095
+ XCTAssertEqual(Mirror(reflecting: view).descendant("structuralAnimationStart")
2096
+ as? CFTimeInterval, animationStart, "Content growth must keep the original deadline")
2097
+ XCTAssertEqual(Mirror(reflecting: view).descendant("structuralAnimationDuration")
2098
+ as? CFTimeInterval, animationDuration, "Content growth must keep the original duration")
2099
+ XCTAssertEqual(contentHost.layer.mask?.frame.height ?? -1, maskHeight, accuracy: 1,
2100
+ "Content growth must not jump the visible mask")
2101
+ growthPhase.fulfill()
2102
+ }
2103
+ RunLoop.main.add(growthProbe, forMode: .common)
2104
+ defer { growthProbe.invalidate() }
2105
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 50, generation: 2,
2106
+ transitionID: "grow", durationMs: 800, disclosureExpanded: true))
2107
+ wait(for: [growthPhase], timeout: 1.5)
2108
+ guard growthObserved else {
2109
+ XCTFail("No partial disclosure growth frame observed: \(growthLast)")
2110
+ return
2111
+ }
2112
+ growthProbe.invalidate()
2113
+ wait(for: [growSettled], timeout: 1.5)
2114
+ XCTAssertEqual(contentHost.bounds.height, 140, accuracy: 1)
2115
+ XCTAssertNil(contentHost.layer.mask)
2116
+ XCTAssertTrue(contentHost.isUserInteractionEnabled)
2117
+ XCTAssertEqual(settlements.map(\.0), ["grow"], "Growth must settle exactly once")
2118
+ XCTAssertEqual(settlements.last?.1, "completed")
2119
+
2120
+ let collapsePhase = expectation(description: "Disclosure collapse presents a partial content mask")
2121
+ var collapseObserved = false
2122
+ var collapseLast = "no frame sampled"
2123
+ let collapseStartedAt = CACurrentMediaTime()
2124
+ let collapseProbe = Timer(timeInterval: 1.0 / 60, repeats: true) { _ in
2125
+ let clock = Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink
2126
+ let tail = Mirror(reflecting: view).descendant("structuralTail")
2127
+ let tailActive = tail.map { !Mirror(reflecting: $0).children.isEmpty } ?? false
2128
+ let maskHeight = contentHost.layer.mask?.frame.height
2129
+ collapseLast = "clock=\(clock != nil) tail=\(tailActive) "
2130
+ + "mask=\(String(describing: maskHeight)) bounds=\(contentHost.bounds.height) "
2131
+ + "elapsed=\(CACurrentMediaTime() - collapseStartedAt) settlements=\(settlements)"
2132
+ guard !collapseObserved, clock != nil, tailActive, let maskHeight,
2133
+ maskHeight > 0, maskHeight < 140 else { return }
2134
+ collapseObserved = true
2135
+ view.scrollViewWillBeginDragging(list)
2136
+ XCTAssertNil(Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink,
2137
+ "Dragging must stop the active disclosure display link")
2138
+ let remainingTail = Mirror(reflecting: view).descendant("structuralTail")
2139
+ XCTAssertNotNil(remainingTail, "The structural tail field must remain observable")
2140
+ XCTAssertFalse(remainingTail.map { !Mirror(reflecting: $0).children.isEmpty } ?? true,
2141
+ "Dragging must clear the temporary structural tail")
2142
+ XCTAssertTrue(contentHost.isHidden, "Dragging a collapse must hide content")
2143
+ XCTAssertEqual(settlements.map(\.0), ["grow", "drag"],
2144
+ "Dragging must settle the collapse exactly once")
2145
+ XCTAssertEqual(settlements.last?.1, "interrupted")
2146
+ collapsePhase.fulfill()
2147
+ }
2148
+ RunLoop.main.add(collapseProbe, forMode: .common)
2149
+ defer { collapseProbe.invalidate() }
2150
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 3,
2151
+ transitionID: "drag", durationMs: 800, disclosureExpanded: false))
2152
+ wait(for: [collapsePhase], timeout: 1.5)
2153
+ guard collapseObserved else {
2154
+ XCTFail("No partial disclosure collapse frame observed: \(collapseLast)")
2155
+ return
2156
+ }
2157
+ collapseProbe.invalidate()
2158
+ }
2159
+
2160
+ func testDisclosureDirectCollapseKeepsToggleVisible() throws {
2161
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 1376, height: 600))
2162
+ let controller = UIViewController()
2163
+ window.rootViewController = controller
2164
+ let view = NativeListView(frame: window.bounds)
2165
+ controller.view.addSubview(view)
2166
+ window.isHidden = false
2167
+ defer {
2168
+ view.dispose()
2169
+ view.removeFromSuperview()
2170
+ window.isHidden = true
2171
+ window.rootViewController = nil
2172
+ }
2173
+ let slots = (0..<6).map { _ in UIView() }
2174
+ for (index, slot) in slots.enumerated() { view.mountContainerSlot(slot, atIndex: index) }
2175
+ view.setSidecarLayoutJson("{\"width\":400,\"gap\":24,\"maxContentWidth\":1140}")
2176
+ view.setContainerSlotHeightsJson("[0,0,900,2200,100,40]")
2177
+ let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
2178
+ list.contentInsetAdjustmentBehavior = .never
2179
+ list.contentInset = .zero
2180
+ var settlements: [[String: Any]] = []
2181
+ view.onStructuralTransitionSettled = { payload in
2182
+ guard let data = payload.data(using: .utf8),
2183
+ let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
2184
+ return
2185
+ }
2186
+ settlements.append(object)
2187
+ }
2188
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 50, generation: 1,
2189
+ disclosureExpanded: true))
2190
+ view.layoutIfNeeded()
2191
+ list.layoutIfNeeded()
2192
+ list.contentOffset.y = 2_600
2193
+ list.layoutIfNeeded()
2194
+ XCTAssertEqual(list.contentOffset.y, 2_600, accuracy: 1)
2195
+
2196
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 2,
2197
+ transitionID: "direct-collapse", durationMs: 0, disclosureExpanded: false))
2198
+ drainMainQueue()
2199
+ list.layoutIfNeeded()
2200
+ let toggle = try XCTUnwrap(slots[5].superview)
2201
+ XCTAssertEqual(toggle.frame.minY, 360, accuracy: 1)
2202
+ XCTAssertEqual(list.contentSize.height, 2_200, accuracy: 1)
2203
+ XCTAssertEqual(list.contentOffset.y, 360, accuracy: 1,
2204
+ "A direct collapsed endpoint must expose the stable toggle despite a taller sidecar")
2205
+ XCTAssertEqual(settlements.count, 1)
2206
+ XCTAssertEqual(settlements.first?["transitionId"] as? String, "direct-collapse")
2207
+ XCTAssertEqual(settlements.first?["status"] as? String, "completed")
2208
+
2209
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 50, generation: 3,
2210
+ disclosureExpanded: true))
2211
+ drainMainQueue()
2212
+ view.frame.size.height = 0
2213
+ view.layoutIfNeeded()
2214
+ XCTAssertEqual(list.bounds.height, 0)
2215
+ view.scrollToOffset(500, animated: false)
2216
+ let pending = try XCTUnwrap(Mirror(reflecting: view).descendant("pendingScrollRequest"))
2217
+ XCTAssertEqual(Mirror(reflecting: pending).children.count, 1,
2218
+ "The explicit command must still be queued when the direct collapse is accepted")
2219
+ view.frame.size.height = 600
2220
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 4,
2221
+ transitionID: "direct-collapse-after-pending-scroll", durationMs: 0,
2222
+ disclosureExpanded: false))
2223
+ drainMainQueue()
2224
+ view.layoutIfNeeded()
2225
+ list.layoutIfNeeded()
2226
+ drainMainQueue()
2227
+ XCTAssertEqual(list.contentOffset.y, 500, accuracy: 1,
2228
+ "A direct collapse must not replace an already pending explicit scroll")
2229
+ XCTAssertEqual(settlements.count, 2)
2230
+ XCTAssertEqual(settlements.last?["transitionId"] as? String,
2231
+ "direct-collapse-after-pending-scroll")
2232
+ }
2233
+
2234
+ func testStructuralTailKeepsMiddleRowsNaturalAndFooterAttachedDuringCollapse() throws {
2235
+ try requireStructuralAnimation()
2236
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
2237
+ let controller = UIViewController()
2238
+ window.rootViewController = controller
2239
+ let view = NativeListView(frame: window.bounds)
2240
+ var retiredActions = 0
2241
+ view.onRowAction = { _ in retiredActions += 1 }
2242
+ controller.view.addSubview(view)
2243
+ window.isHidden = false
2244
+ defer {
2245
+ view.dispose()
2246
+ view.removeFromSuperview()
2247
+ window.isHidden = true
2248
+ window.rootViewController = nil
2249
+ }
2250
+ for index in 0..<2 { view.mountContainerSlot(UIView(), atIndex: index) }
2251
+ let footer = UIView()
2252
+ view.mountContainerSlot(footer, atIndex: 2)
2253
+ view.setContainerSlotHeightsJson("[0,0,60,0]")
2254
+ let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
2255
+ list.contentInsetAdjustmentBehavior = .never
2256
+ list.contentInset = .zero
2257
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 50, generation: 1))
2258
+ view.layoutIfNeeded()
2259
+ list.layoutIfNeeded()
2260
+ drainMainQueue()
2261
+ list.contentOffset.y = 2400
2262
+ list.layoutIfNeeded()
2263
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 2,
2264
+ transitionID: "tail-50-to-6", durationMs: 800))
2265
+ waitForStructuralFrames(0.16)
2266
+ list.layoutIfNeeded()
2267
+ let source = try XCTUnwrap(list.dataSource as? UICollectionViewDiffableDataSource<Int, String>)
2268
+ XCTAssertEqual(source.snapshot().itemIdentifiers, (0..<50).map { "row-\($0)" })
2269
+ let flow = try XCTUnwrap(list.collectionViewLayout as? NativeListFlowLayout)
2270
+ let clip = try XCTUnwrap(flow.tailClip)
2271
+ XCTAssertEqual(clip.firstIndex, 6)
2272
+ XCTAssertGreaterThan(clip.endY, 360)
2273
+ XCTAssertLessThan(clip.endY, 3000)
2274
+ let tailCells = list.visibleCells.compactMap { cell -> NativeListRowHost? in
2275
+ guard let path = list.indexPath(for: cell), path.item >= 6 else { return nil }
2276
+ return cell as? NativeListRowHost
2277
+ }
2278
+ XCTAssertFalse(tailCells.isEmpty)
2279
+ XCTAssertTrue(tailCells.allSatisfy { abs($0.bounds.height - 60) <= 1 && $0.alpha == 1 })
2280
+ XCTAssertTrue(tailCells.allSatisfy { !$0.isUserInteractionEnabled && $0.accessibilityElementsHidden })
2281
+ XCTAssertFalse(view.collectionView(list, shouldSelectItemAt: IndexPath(item: 25, section: 0)))
2282
+ view.collectionView(list, didSelectItemAt: IndexPath(item: 25, section: 0))
2283
+ XCTAssertEqual(retiredActions, 0)
2284
+ XCTAssertEqual(footer.convert(.zero, to: list).y, clip.endY, accuracy: 1)
2285
+ let footerBottomBeforeResize = footer.convert(.zero, to: list).y + 60
2286
+ view.setContainerSlotHeightsJson("[0,0,80,0]")
2287
+ XCTAssertEqual(footer.convert(.zero, to: list).y + 80,
2288
+ footerBottomBeforeResize, accuracy: 1)
2289
+ let footerHost = try XCTUnwrap(footer.superview)
2290
+ let hiddenTop = try XCTUnwrap(flow.tailClip).endY - footerHost.frame.minY
2291
+ if hiddenTop > 1 {
2292
+ XCTAssertFalse(footerHost.point(inside: CGPoint(x: 10, y: hiddenTop / 2), with: nil))
2293
+ XCTAssertTrue(footerHost.point(inside: CGPoint(x: 10, y: hiddenTop + 1), with: nil))
2294
+ }
2295
+ waitForStructuralFrames(0.06)
2296
+ list.layoutIfNeeded()
2297
+ XCTAssertEqual(footer.convert(.zero, to: list).y, try XCTUnwrap(flow.tailClip).endY,
2298
+ accuracy: 1)
2299
+ let footerBottomBeforeSecondResize = footer.convert(.zero, to: list).y + 80
2300
+ view.setContainerSlotHeightsJson("[0,0,100,0]")
2301
+ XCTAssertEqual(footer.convert(.zero, to: list).y + 100,
2302
+ footerBottomBeforeSecondResize, accuracy: 1)
2303
+ waitForStructuralFrames(0.7)
2304
+ list.layoutIfNeeded()
2305
+ XCTAssertNil(flow.tailClip)
2306
+ XCTAssertEqual(source.snapshot().itemIdentifiers, (0..<6).map { "row-\($0)" })
2307
+ XCTAssertEqual(list.contentSize.height, 460, accuracy: 1)
2308
+ XCTAssertEqual(footer.convert(.zero, to: list).y, 360, accuracy: 1)
2309
+ XCTAssertEqual(list.contentOffset.y, 0, accuracy: 1)
2310
+ }
2311
+
2312
+ func testStructuralTailCollapsePaintsEveryExpectedViewportRow() throws {
2313
+ try requireStructuralAnimation()
2314
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 1000, height: 600))
2315
+ let controller = UIViewController()
2316
+ window.rootViewController = controller
2317
+ let view = NativeListView(frame: window.bounds)
2318
+ controller.view.addSubview(view)
2319
+ window.isHidden = false
2320
+ defer {
2321
+ view.dispose()
2322
+ view.removeFromSuperview()
2323
+ window.isHidden = true
2324
+ window.rootViewController = nil
2325
+ }
2326
+ for index in 0..<2 { view.mountContainerSlot(UIView(), atIndex: index) }
2327
+ view.mountContainerSlot(UIView(), atIndex: 2)
2328
+ view.mountContainerSlot(UIView(), atIndex: 3)
2329
+ view.setContainerSlotHeightsJson("[0,0,60,3000]")
2330
+ let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
2331
+ list.contentInsetAdjustmentBehavior = .never
2332
+ list.contentInset = .zero
2333
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 50, generation: 1))
2334
+ view.layoutIfNeeded()
2335
+ list.layoutIfNeeded()
2336
+ drainMainQueue()
2337
+ list.contentOffset.y = 2400
2338
+ list.layoutIfNeeded()
2339
+ let flow = try XCTUnwrap(list.collectionViewLayout as? NativeListFlowLayout)
2340
+ var checkedFrames = 0
2341
+ var failures: [String] = []
2342
+ let probe = Timer(timeInterval: 1.0 / 60, repeats: true) { _ in
2343
+ guard let clip = flow.tailClip else { return }
2344
+ list.layoutIfNeeded()
2345
+ let viewportTop = list.contentOffset.y
2346
+ let viewportBottom = viewportTop + list.bounds.height
2347
+ var expectedCount = 0
2348
+ var attributesCount = 0
2349
+ var attachedCount = 0
2350
+ var visibleCellsCount = 0
2351
+ var firstFailure: String?
2352
+ let attachedCells = list.subviews.compactMap { $0 as? NativeListRowHost }
2353
+ let visibleCells = list.visibleCells
2354
+ for index in 6..<50 {
2355
+ let rowTop = CGFloat(index * 60)
2356
+ let rowBottom = rowTop + 60
2357
+ let expectedTop = max(rowTop, viewportTop)
2358
+ let expectedBottom = min(rowBottom, viewportBottom, clip.endY)
2359
+ guard expectedBottom - expectedTop >= 8 else { continue }
2360
+ expectedCount += 1
2361
+ let path = IndexPath(item: index, section: 0)
2362
+ let attributes = flow.layoutAttributesForItem(at: path)
2363
+ if attributes != nil { attributesCount += 1 }
2364
+ let cell = attachedCells.first { list.indexPath(for: $0) == path }
2365
+ if cell != nil { attachedCount += 1 }
2366
+ let inVisibleCells = cell.map { visibleCells.contains($0) } ?? false
2367
+ if inVisibleCells { visibleCellsCount += 1 }
2368
+ let maskEnd = cell?.layer.mask?.frame.maxY ?? 60
2369
+ let neededMaskEnd = expectedBottom - rowTop
2370
+ if cell == nil || cell?.isHidden == true || cell?.alpha != 1
2371
+ || maskEnd + 1 < neededMaskEnd {
2372
+ if firstFailure == nil {
2373
+ let hidden = cell.map { String($0.isHidden) } ?? "nil"
2374
+ let frame = cell.map { String(describing: $0.frame) } ?? "nil"
2375
+ firstFailure = "row=\(index) offset=\(Int(viewportTop)) clip=\(Int(clip.endY)) "
2376
+ + "attached=\(cell != nil) visibleCells=\(inVisibleCells) hidden=\(hidden) "
2377
+ + "frame=\(frame) attributes=\(String(describing: attributes?.frame)) "
2378
+ + "maskEnd=\(Int(maskEnd)) alpha=\(cell?.alpha ?? -1)"
2379
+ }
2380
+ }
2381
+ }
2382
+ if expectedCount > 0 { checkedFrames += 1 }
2383
+ if let firstFailure, failures.count < 8 {
2384
+ failures.append("expected=\(expectedCount) attrs=\(attributesCount) "
2385
+ + "attached=\(attachedCount) visibleCells=\(visibleCellsCount) " + firstFailure)
2386
+ }
2387
+ }
2388
+ RunLoop.main.add(probe, forMode: .common)
2389
+ defer { probe.invalidate() }
2390
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 2,
2391
+ transitionID: "tail-viewport-paint", durationMs: 800))
2392
+ waitForStructuralFrames(0.82)
2393
+ XCTAssertGreaterThanOrEqual(checkedFrames, 5)
2394
+ XCTAssertTrue(failures.isEmpty, failures.joined(separator: "\n"))
2395
+ }
2396
+
2397
+ func testStructuralTailGeometryChangeWithSameIDCommitsFinalLayout() throws {
2398
+ try requireStructuralAnimation()
2399
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
2400
+ let controller = UIViewController()
2401
+ window.rootViewController = controller
2402
+ let view = NativeListView(frame: window.bounds)
2403
+ controller.view.addSubview(view)
2404
+ window.isHidden = false
2405
+ defer {
2406
+ view.dispose()
2407
+ view.removeFromSuperview()
2408
+ window.isHidden = true
2409
+ window.rootViewController = nil
2410
+ }
2411
+ for index in 0..<3 { view.mountContainerSlot(UIView(), atIndex: index) }
2412
+ view.setContainerSlotHeightsJson("[0,0,60,0]")
2413
+ let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
2414
+ list.contentInsetAdjustmentBehavior = .never
2415
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 50, generation: 1))
2416
+ view.layoutIfNeeded()
2417
+ list.layoutIfNeeded()
2418
+ drainMainQueue()
2419
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 2,
2420
+ transitionID: "same-id-geometry", durationMs: 400))
2421
+ waitForStructuralFrames(0.08)
2422
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 3,
2423
+ transitionID: "same-id-geometry", durationMs: 400, firstRowHeight: 70))
2424
+ let source = try XCTUnwrap(list.dataSource as? UICollectionViewDiffableDataSource<Int, String>)
2425
+ let flow = try XCTUnwrap(list.collectionViewLayout as? NativeListFlowLayout)
2426
+ list.layoutIfNeeded()
2427
+ XCTAssertNil(flow.tailClip)
2428
+ XCTAssertEqual(source.snapshot().itemIdentifiers, (0..<6).map { "row-\($0)" })
2429
+ XCTAssertEqual(list.contentSize.height, 430, accuracy: 1)
2430
+ waitForStructuralFrames(0.42)
2431
+ XCTAssertEqual(list.contentSize.height, 430, accuracy: 1)
2432
+ }
2433
+
2434
+ func testStructuralTailSameIDOrientationChangeStopsVerticalTimeline() throws {
2435
+ try requireStructuralAnimation()
2436
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
2437
+ let controller = UIViewController()
2438
+ window.rootViewController = controller
2439
+ let view = NativeListView(frame: window.bounds)
2440
+ controller.view.addSubview(view)
2441
+ window.isHidden = false
2442
+ defer {
2443
+ view.dispose()
2444
+ view.removeFromSuperview()
2445
+ window.isHidden = true
2446
+ window.rootViewController = nil
2447
+ }
2448
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 50, generation: 1))
2449
+ let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
2450
+ view.layoutIfNeeded()
2451
+ list.layoutIfNeeded()
2452
+ drainMainQueue()
2453
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 2,
2454
+ transitionID: "same-id-orientation", durationMs: 400))
2455
+ waitForStructuralFrames(0.08)
2456
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 3,
2457
+ transitionID: "same-id-orientation", durationMs: 400, orientation: "horizontal"))
2458
+ let flow = try XCTUnwrap(list.collectionViewLayout as? NativeListFlowLayout)
2459
+ XCTAssertNil(flow.tailClip)
2460
+ XCTAssertEqual(flow.scrollDirection, .horizontal)
2461
+ waitForStructuralFrames(0.42)
2462
+ XCTAssertEqual(flow.scrollDirection, .horizontal)
2463
+ XCTAssertNil(flow.tailClip)
2464
+ }
2465
+
2466
+ func testStructuralTailHostLimitFallsBackToAtomicSnapshot() throws {
2467
+ try requireStructuralAnimation()
2468
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
2469
+ let controller = UIViewController()
2470
+ window.rootViewController = controller
2471
+ let view = NativeListView(frame: window.bounds)
2472
+ controller.view.addSubview(view)
2473
+ window.isHidden = false
2474
+ defer {
2475
+ view.dispose()
2476
+ view.removeFromSuperview()
2477
+ window.isHidden = true
2478
+ window.rootViewController = nil
2479
+ }
2480
+ for index in 0..<3 { view.mountContainerSlot(UIView(), atIndex: index) }
2481
+ view.setContainerSlotHeightsJson("[0,0,60,0]")
2482
+ let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
2483
+ list.contentInsetAdjustmentBehavior = .never
2484
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 1000, generation: 1,
2485
+ uniformHeight: 1))
2486
+ view.layoutIfNeeded()
2487
+ list.layoutIfNeeded()
2488
+ drainMainQueue()
2489
+ var phases: [Int] = []
2490
+ let observer = NotificationCenter.default.addObserver(
2491
+ forName: Notification.Name("OneKeyNativeListScrollCommandAccepted"),
2492
+ object: list, queue: nil
2493
+ ) { notification in
2494
+ if let phase = notification.userInfo?["phase"] as? Int { phases.append(phase) }
2495
+ }
2496
+ defer { NotificationCenter.default.removeObserver(observer) }
2497
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 2,
2498
+ transitionID: "tail-host-limit", durationMs: 400, uniformHeight: 1))
2499
+ list.layoutIfNeeded()
2500
+ let source = try XCTUnwrap(list.dataSource as? UICollectionViewDiffableDataSource<Int, String>)
2501
+ let flow = try XCTUnwrap(list.collectionViewLayout as? NativeListFlowLayout)
2502
+ XCTAssertEqual(source.snapshot().itemIdentifiers, (0..<6).map { "row-\($0)" })
2503
+ XCTAssertNil(flow.tailClip)
2504
+ XCTAssertTrue(phases.isEmpty)
2505
+ }
2506
+
2507
+ func testStructuralTailExpansionKeepsOffsetWithTallerInitialSidecar() throws {
2508
+ try requireStructuralAnimation()
2509
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 1000, height: 600))
2510
+ let controller = UIViewController()
2511
+ window.rootViewController = controller
2512
+ let view = NativeListView(frame: window.bounds)
2513
+ controller.view.addSubview(view)
2514
+ window.isHidden = false
2515
+ defer {
2516
+ view.dispose()
2517
+ view.removeFromSuperview()
2518
+ window.isHidden = true
2519
+ window.rootViewController = nil
2520
+ }
2521
+ for index in 0..<4 { view.mountContainerSlot(UIView(), atIndex: index) }
2522
+ view.setContainerSlotHeightsJson("[0,0,60,2200]")
2523
+ view.setSidecarLayoutJson("{\"width\":300,\"gap\":24,\"maxContentWidth\":1000}")
2524
+ let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
2525
+ list.contentInsetAdjustmentBehavior = .never
2526
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 1))
2527
+ view.layoutIfNeeded()
2528
+ list.layoutIfNeeded()
2529
+ drainMainQueue()
2530
+ XCTAssertEqual(list.contentSize.height, 2200, accuracy: 1)
2531
+ list.contentOffset.y = 1000
2532
+ list.layoutIfNeeded()
2533
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 50, generation: 2,
2534
+ transitionID: "tail-6-to-50", durationMs: 400))
2535
+ waitForStructuralFrames(0.16)
2536
+ list.layoutIfNeeded()
2537
+ let source = try XCTUnwrap(list.dataSource as? UICollectionViewDiffableDataSource<Int, String>)
2538
+ let flow = try XCTUnwrap(list.collectionViewLayout as? NativeListFlowLayout)
2539
+ XCTAssertEqual(source.snapshot().itemIdentifiers, (0..<50).map { "row-\($0)" })
2540
+ XCTAssertGreaterThan(try XCTUnwrap(flow.tailClip).endY, 360)
2541
+ XCTAssertLessThan(try XCTUnwrap(flow.tailClip).endY, 3000)
2542
+ let suffixCells = list.visibleCells.filter {
2543
+ (list.indexPath(for: $0)?.item ?? -1) >= 6
2544
+ }
2545
+ XCTAssertFalse(suffixCells.isEmpty)
2546
+ XCTAssertTrue(suffixCells.allSatisfy {
2547
+ !$0.isUserInteractionEnabled && $0.accessibilityElementsHidden
2548
+ })
2549
+ XCTAssertEqual(list.contentOffset.y, 1000, accuracy: 1)
2550
+ waitForStructuralFrames(0.3)
2551
+ list.layoutIfNeeded()
2552
+ XCTAssertEqual(source.snapshot().itemIdentifiers, (0..<50).map { "row-\($0)" })
2553
+ XCTAssertEqual(list.contentSize.height, 3060, accuracy: 1)
2554
+ XCTAssertEqual(list.contentOffset.y, 1000, accuracy: 1)
2555
+ XCTAssertNil(flow.tailClip)
2556
+ }
2557
+
2558
+ func testStructuralTailDifferentPrefixDuringExpansionCommitsAtomicSnapshot() throws {
2559
+ try requireStructuralAnimation()
2560
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 1000, height: 600))
2561
+ let controller = UIViewController()
2562
+ window.rootViewController = controller
2563
+ let view = NativeListView(frame: window.bounds)
2564
+ controller.view.addSubview(view)
2565
+ window.isHidden = false
2566
+ defer {
2567
+ view.dispose()
2568
+ view.removeFromSuperview()
2569
+ window.isHidden = true
2570
+ window.rootViewController = nil
2571
+ }
2572
+ for index in 0..<4 { view.mountContainerSlot(UIView(), atIndex: index) }
2573
+ view.setContainerSlotHeightsJson("[0,0,60,1600]")
2574
+ view.setSidecarLayoutJson("{\"width\":300,\"gap\":24,\"maxContentWidth\":1000}")
2575
+ let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
2576
+ list.contentInsetAdjustmentBehavior = .never
2577
+ list.contentInset = .zero
2578
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 1))
2579
+ view.layoutIfNeeded()
2580
+ list.layoutIfNeeded()
2581
+ drainMainQueue()
2582
+ XCTAssertEqual(list.adjustedContentInset.top, 0, accuracy: 0.01)
2583
+ XCTAssertEqual(list.adjustedContentInset.bottom, 0, accuracy: 0.01)
2584
+ list.contentOffset.y = 900
2585
+ list.layoutIfNeeded()
2586
+
2587
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 50, generation: 2,
2588
+ transitionID: "tail-first-six", durationMs: 1200))
2589
+ waitForStructuralFrames(0.12)
2590
+ let source = try XCTUnwrap(list.dataSource as? UICollectionViewDiffableDataSource<Int, String>)
2591
+ let flow = try XCTUnwrap(list.collectionViewLayout as? NativeListFlowLayout)
2592
+ XCTAssertEqual(source.snapshot().itemIdentifiers, (0..<50).map { "row-\($0)" })
2593
+ XCTAssertGreaterThan(try XCTUnwrap(flow.tailClip).endY, 360)
2594
+
2595
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 10, generation: 3,
2596
+ transitionID: "tail-next-ten", durationMs: 1200))
2597
+ list.layoutIfNeeded()
2598
+ XCTAssertEqual(source.snapshot().itemIdentifiers, (0..<10).map { "row-\($0)" })
2599
+ XCTAssertNil(flow.tailClip)
2600
+ XCTAssertNil(Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink)
2601
+ XCTAssertEqual(Mirror(reflecting: view).descendant("structuralSpacerHeight") as? CGFloat ?? -1,
2602
+ 0, accuracy: 1)
2603
+ XCTAssertEqual(list.contentSize.height, 1600, accuracy: 1)
2604
+ XCTAssertEqual(list.contentOffset.y, 900, accuracy: 1)
2605
+ waitForStructuralFrames(1.25)
2606
+ list.layoutIfNeeded()
2607
+ XCTAssertEqual(source.snapshot().itemIdentifiers, (0..<10).map { "row-\($0)" })
2608
+ XCTAssertEqual(list.contentSize.height, 1600, accuracy: 1)
2609
+ XCTAssertEqual(list.contentOffset.y, 900, accuracy: 1)
2610
+ }
2611
+
2612
+ func testStructuralTailInterruptedExpansionRestoresBoundaryCellInteraction() throws {
2613
+ try requireStructuralAnimation()
2614
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 1000, height: 600))
2615
+ let controller = UIViewController()
2616
+ window.rootViewController = controller
2617
+ let view = NativeListView(frame: window.bounds)
2618
+ controller.view.addSubview(view)
2619
+ window.isHidden = false
2620
+ defer {
2621
+ view.dispose()
2622
+ view.removeFromSuperview()
2623
+ window.isHidden = true
2624
+ window.rootViewController = nil
2625
+ }
2626
+ for index in 0..<2 { view.mountContainerSlot(UIView(), atIndex: index) }
2627
+ let footer = UIView()
2628
+ view.mountContainerSlot(footer, atIndex: 2)
2629
+ view.mountContainerSlot(UIView(), atIndex: 3)
2630
+ view.setContainerSlotHeightsJson("[0,0,60,2200]")
2631
+ view.setSidecarLayoutJson("{\"width\":300,\"gap\":24,\"maxContentWidth\":1000}")
2632
+ let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
2633
+ list.contentInsetAdjustmentBehavior = .never
2634
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 1))
2635
+ view.layoutIfNeeded()
2636
+ list.layoutIfNeeded()
2637
+ drainMainQueue()
2638
+ list.contentOffset.y = 300
2639
+ list.layoutIfNeeded()
2640
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 50, generation: 2,
2641
+ transitionID: "interrupt-boundary", durationMs: 1200))
2642
+ let footerBottomBeforeResize = footer.convert(.zero, to: list).y + 60
2643
+ view.setContainerSlotHeightsJson("[0,0,80,2200]")
2644
+ XCTAssertEqual(footer.convert(.zero, to: list).y + 80,
2645
+ footerBottomBeforeResize, accuracy: 1)
2646
+ let flow = try XCTUnwrap(list.collectionViewLayout as? NativeListFlowLayout)
2647
+ let prefixBottom = try XCTUnwrap(flow.layoutAttributesForItem(
2648
+ at: IndexPath(item: 5, section: 0))).frame.maxY
2649
+ let footerHost = try XCTUnwrap(footer.superview)
2650
+ let hiddenTop = prefixBottom - footerHost.frame.minY
2651
+ XCTAssertGreaterThan(hiddenTop, 0)
2652
+ XCTAssertFalse(footerHost.point(inside: CGPoint(x: 10, y: hiddenTop / 2), with: nil))
2653
+ XCTAssertTrue(footerHost.point(inside: CGPoint(x: 10, y: hiddenTop + 1), with: nil))
2654
+ var boundaryCell: NativeListRowHost?
2655
+ let deadline = Date().addingTimeInterval(1)
2656
+ while boundaryCell == nil && Date() < deadline {
2657
+ if let clip = flow.tailClip {
2658
+ for case let cell as NativeListRowHost in list.visibleCells {
2659
+ guard let path = list.indexPath(for: cell), path.item >= 6,
2660
+ let mask = cell.layer.mask else { continue }
2661
+ let visibleHeight = clip.endY - cell.frame.minY
2662
+ if clip.endY > cell.frame.minY + 2 &&
2663
+ clip.endY < cell.frame.maxY - 2 &&
2664
+ mask.frame.height > 2 &&
2665
+ mask.frame.height < cell.bounds.height - 2 &&
2666
+ abs(mask.frame.height - visibleHeight) <= 1 {
2667
+ boundaryCell = cell
2668
+ break
2669
+ }
2670
+ }
2671
+ }
2672
+ if boundaryCell == nil { RunLoop.main.run(until: Date().addingTimeInterval(0.01)) }
2673
+ }
2674
+ let boundary = try XCTUnwrap(boundaryCell)
2675
+ let displayLink = try XCTUnwrap(
2676
+ Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink)
2677
+ displayLink.isPaused = true
2678
+ defer { displayLink.isPaused = false }
2679
+ let mask = try XCTUnwrap(boundary.layer.mask)
2680
+ XCTAssertGreaterThan(mask.frame.height, 2)
2681
+ XCTAssertLessThan(mask.frame.height, boundary.bounds.height - 2)
2682
+ XCTAssertFalse(boundary.isUserInteractionEnabled)
2683
+ XCTAssertTrue(boundary.accessibilityElementsHidden)
2684
+ let offsetBeforeInterruption = list.contentOffset.y
2685
+ view.scrollViewWillBeginDragging(list)
2686
+ list.layoutIfNeeded()
2687
+ XCTAssertNil(Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink)
2688
+ XCTAssertNil(boundary.layer.mask)
2689
+ XCTAssertFalse(boundary.isHidden)
2690
+ XCTAssertTrue(boundary.isUserInteractionEnabled)
2691
+ XCTAssertFalse(boundary.accessibilityElementsHidden)
2692
+ XCTAssertTrue(footerHost.point(inside: CGPoint(x: 10, y: 1), with: nil))
2693
+ XCTAssertEqual(list.contentOffset.y, offsetBeforeInterruption, accuracy: 1)
2694
+ XCTAssertEqual(list.contentOffset.y, 300, accuracy: 1)
2695
+ }
2696
+
2697
+ func testStructuralTailExplicitScrollTakesOwnershipFromExpansion() throws {
2698
+ try requireStructuralAnimation()
2699
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
2700
+ let controller = UIViewController()
2701
+ window.rootViewController = controller
2702
+ let view = NativeListView(frame: window.bounds)
2703
+ controller.view.addSubview(view)
2704
+ window.isHidden = false
2705
+ defer {
2706
+ view.dispose()
2707
+ view.removeFromSuperview()
2708
+ window.isHidden = true
2709
+ window.rootViewController = nil
2710
+ }
2711
+ for index in 0..<3 { view.mountContainerSlot(UIView(), atIndex: index) }
2712
+ view.setContainerSlotHeightsJson("[0,0,60,0]")
2713
+ let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
2714
+ list.contentInsetAdjustmentBehavior = .never
2715
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 1))
2716
+ view.layoutIfNeeded()
2717
+ list.layoutIfNeeded()
2718
+ drainMainQueue()
2719
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 50, generation: 2,
2720
+ transitionID: "explicit-scroll-over-expansion", durationMs: 400))
2721
+ waitForStructuralFrames(0.08)
2722
+ view.scrollToIndex(40, animated: false, alignment: "start")
2723
+ drainMainQueue()
2724
+ list.layoutIfNeeded()
2725
+ let source = try XCTUnwrap(list.dataSource as? UICollectionViewDiffableDataSource<Int, String>)
2726
+ XCTAssertEqual(source.snapshot().itemIdentifiers, (0..<50).map { "row-\($0)" })
2727
+ XCTAssertEqual(list.contentOffset.y, 2400, accuracy: 1)
2728
+ waitForStructuralFrames(0.4)
2729
+ XCTAssertEqual(list.contentOffset.y, 2400, accuracy: 1)
2730
+ XCTAssertTrue(list.visibleCells.allSatisfy {
2731
+ $0.isUserInteractionEnabled && !$0.accessibilityElementsHidden && $0.layer.mask == nil
2732
+ })
2733
+ }
2734
+
2735
+ func testStructuralTransitionSurvivesVisibleDataRowPatch() throws {
2736
+ try requireStructuralAnimation()
2737
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
2738
+ let controller = UIViewController()
2739
+ window.rootViewController = controller
2740
+ let view = NativeListView(frame: window.bounds)
2741
+ controller.view.addSubview(view)
2742
+ window.isHidden = false
2743
+ defer { view.dispose(); view.removeFromSuperview(); window.isHidden = true }
2744
+ let footer = UIView()
2745
+ for index in 0..<2 { view.mountContainerSlot(UIView(), atIndex: index) }
2746
+ view.mountContainerSlot(footer, atIndex: 2)
2747
+ view.setContainerSlotHeightsJson("[0,0,60,0]")
2748
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 30, generation: 1, dataRows: true))
2749
+ view.layoutIfNeeded()
2750
+ let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
2751
+ list.layoutIfNeeded()
2752
+ drainMainQueue()
2753
+ list.contentOffset.y = 1100
2754
+ list.layoutIfNeeded()
2755
+
2756
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 25, generation: 2,
2757
+ transitionID: "collapse-with-patch", durationMs: 800, dataRows: true))
2758
+ waitForStructuralFrames(0.3)
2759
+ let source = try XCTUnwrap(list.dataSource as? UICollectionViewDiffableDataSource<Int, String>)
2760
+ let flow = try XCTUnwrap(list.collectionViewLayout as? NativeListFlowLayout)
2761
+ let heightBeforePatch = try XCTUnwrap(flow.tailClip).endY
2762
+ XCTAssertGreaterThan(heightBeforePatch, 1500)
2763
+ let retiring = try XCTUnwrap(list.cellForItem(at: IndexPath(item: 25, section: 0)) as? NativeListRowHost)
2764
+ XCTAssertEqual(retiring.bounds.height, 60, accuracy: 1)
2765
+ func storedContent(_ key: String) throws -> String {
2766
+ let items = try XCTUnwrap(Mirror(reflecting: view).descendant("itemsByKey")
2767
+ as? [String: NativeListItem])
2768
+ return try XCTUnwrap(items[key]).content
2769
+ }
2770
+ let oldAcceptedContent = try storedContent("row-20")
2771
+ let retiredContent = try storedContent("row-25")
2772
+ let patch: [[String: Any]] = [[
2773
+ "key": "row-20", "type": "dataRow", "changes": [
2774
+ "revision": 2,
2775
+ "columns": [["key": "asset", "text": "Asset"],
2776
+ ["key": "price", "text": "$123.45"]],
2777
+ ],
2778
+ ]]
2779
+ let invalidRetiredPatch: [String: Any] = ["key": "row-25", "type": "dataRow",
2780
+ "changes": ["revision": 2]]
2781
+ view.applyPatchesJson(String(decoding: try JSONSerialization.data(
2782
+ withJSONObject: patch + [invalidRetiredPatch]), as: UTF8.self))
2783
+ XCTAssertEqual(try storedContent("row-20"), oldAcceptedContent)
2784
+ XCTAssertEqual(try storedContent("row-25"), retiredContent)
2785
+ view.applyPatchesJson(String(decoding: try JSONSerialization.data(withJSONObject: patch),
2786
+ as: UTF8.self))
2787
+ XCTAssertNotEqual(try storedContent("row-20"), oldAcceptedContent)
2788
+ XCTAssertEqual(try storedContent("row-25"), retiredContent)
2789
+ list.layoutIfNeeded()
2790
+ let patched = try XCTUnwrap(list.cellForItem(at: IndexPath(item: 20, section: 0)))
2791
+ XCTAssertTrue(labels(in: patched).contains { $0.text == "$123.45" })
2792
+ waitForStructuralFrames(0.05)
2793
+ list.layoutIfNeeded()
2794
+ XCTAssertLessThan(try XCTUnwrap(flow.tailClip).endY, heightBeforePatch)
2795
+ waitForStructuralFrames(0.55)
2796
+ list.layoutIfNeeded()
2797
+ XCTAssertEqual(source.snapshot().itemIdentifiers, (0..<25).map { "row-\($0)" })
2798
+ XCTAssertEqual(list.contentSize.height, 1560, accuracy: 1)
2799
+ XCTAssertEqual(footer.convert(.zero, to: list).y, 1500, accuracy: 1)
2800
+ let finalPatched = try XCTUnwrap(list.cellForItem(at: IndexPath(item: 20, section: 0)))
2801
+ XCTAssertTrue(labels(in: finalPatched).contains { $0.text == "$123.45" })
2802
+ }
2803
+
2804
+ func testStructuralTransitionDoesNotOverrideUserDrag() throws {
2805
+ try requireStructuralAnimation()
2806
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
2807
+ let controller = UIViewController()
2808
+ window.rootViewController = controller
2809
+ let view = NativeListView(frame: window.bounds)
2810
+ controller.view.addSubview(view)
2811
+ window.isHidden = false
2812
+ defer { view.dispose(); view.removeFromSuperview(); window.isHidden = true }
2813
+ for index in 0..<3 { view.mountContainerSlot(UIView(), atIndex: index) }
2814
+ view.setContainerSlotHeightsJson("[0,0,60,0]")
2815
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 30, generation: 1))
2816
+ view.layoutIfNeeded()
2817
+ let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
2818
+ list.layoutIfNeeded()
2819
+ drainMainQueue()
2820
+ list.contentOffset.y = 1100
2821
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 25, generation: 2,
2822
+ transitionID: "collapse-drag", durationMs: 400))
2823
+ waitForStructuralFrames(0.08)
2824
+ view.scrollViewWillBeginDragging(list)
2825
+ XCTAssertNil(Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink)
2826
+ XCTAssertEqual(list.contentSize.height, 1560, accuracy: 1)
2827
+ list.contentOffset.y = 600
2828
+ list.layoutIfNeeded()
2829
+ waitForStructuralFrames(0.15)
2830
+ XCTAssertEqual(list.contentOffset.y, 600, accuracy: 1)
2831
+ waitForStructuralFrames(0.35)
2832
+ list.layoutIfNeeded()
2833
+ XCTAssertEqual(list.contentOffset.y, 600, accuracy: 1)
2834
+ let source = try XCTUnwrap(list.dataSource as? UICollectionViewDiffableDataSource<Int, String>)
2835
+ XCTAssertEqual(source.snapshot().itemIdentifiers, (0..<25).map { "row-\($0)" })
2836
+ XCTAssertEqual(list.contentSize.height, 1560, accuracy: 1)
2837
+ XCTAssertTrue(list.visibleCells.allSatisfy { abs($0.alpha - 1) < 0.01 })
2838
+ }
2839
+
2840
+ func testStructuralTransitionDetachAndDisposeReleaseView() throws {
2841
+ try requireStructuralAnimation()
2842
+ for dispose in [false, true] {
2843
+ var callbacks = 0
2844
+ let reference = try detachStructuralTransition(dispose: dispose) { callbacks += 1 }
2845
+ if dispose {
2846
+ drainMainQueue()
2847
+ XCTAssertNil(reference.view)
2848
+ let countAtDispose = callbacks
2849
+ waitForStructuralFrames(0.5)
2850
+ XCTAssertEqual(callbacks, countAtDispose)
2851
+ } else {
2852
+ waitForStructuralFrames(0.5)
2853
+ XCTAssertNil(reference.view)
2854
+ }
2855
+ }
2856
+ }
2857
+
2858
+ private final class WeakListReference {
2859
+ weak var view: NativeListView?
2860
+ }
2861
+
2862
+ private func detachStructuralTransition(
2863
+ dispose: Bool, onCallback: @escaping () -> Void
2864
+ ) throws -> WeakListReference {
2865
+ try autoreleasepool {
2866
+ let reference = WeakListReference()
2867
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
2868
+ let controller = UIViewController()
2869
+ window.rootViewController = controller
2870
+ let view = NativeListView(frame: window.bounds)
2871
+ controller.view.addSubview(view)
2872
+ window.isHidden = false
2873
+ for index in 0..<3 { view.mountContainerSlot(UIView(), atIndex: index) }
2874
+ view.setContainerSlotHeightsJson("[0,0,60,0]")
2875
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 30, generation: 1))
2876
+ view.layoutIfNeeded()
2877
+ let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
2878
+ view.onVisibleRangeChanged = { _ in onCallback() }
2879
+ view.onEndReached = { _ in onCallback() }
2880
+ list.layoutIfNeeded()
2881
+ drainMainQueue()
2882
+ view.applySnapshotJson(try structuralRowsSnapshot(count: 25, generation: 2,
2883
+ transitionID: dispose ? "dispose-mid-transition" : "detach-mid-transition",
2884
+ durationMs: 400))
2885
+ // Interrupt an active transition without racing a wall-clock delay on a busy runner.
2886
+ XCTAssertNotNil(Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink)
2887
+ reference.view = view
2888
+ if dispose {
2889
+ view.dispose()
2890
+ XCTAssertNil(list.delegate)
2891
+ XCTAssertNil(list.dataSource)
2892
+ }
2893
+ view.removeFromSuperview()
2894
+ XCTAssertNil(Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink)
2895
+ XCTAssertNil((list.collectionViewLayout as? NativeListFlowLayout)?.tailClip)
2896
+ window.isHidden = true
2897
+ window.rootViewController = nil
2898
+ return reference
2899
+ }
2900
+ }
2901
+
2902
+ private func dispatchAccessoryTouchUpInside(
2903
+ _ control: UIButton, on stack: NativeListAccessoryStack,
2904
+ file: StaticString = #filePath, line: UInt = #line
2905
+ ) throws {
2906
+ guard control.allTargets.contains(stack) else {
2907
+ XCTFail("Accessory control is not attached to its stack", file: file, line: line)
2908
+ return
2909
+ }
2910
+ let actions = try XCTUnwrap(
2911
+ control.actions(forTarget: stack, forControlEvent: .touchUpInside),
2912
+ file: file, line: line)
2913
+ guard !actions.isEmpty else {
2914
+ XCTFail("Accessory control has no touch-up action", file: file, line: line)
2915
+ return
2916
+ }
2917
+ // This pod XCTest has no UIApplication to dispatch UIControl.sendActions.
2918
+ guard control.isEnabled else { return }
2919
+ for action in actions {
2920
+ let selector = NSSelectorFromString(action)
2921
+ guard stack.responds(to: selector) else {
2922
+ XCTFail("Accessory action selector is missing: \(action)", file: file, line: line)
2923
+ continue
2924
+ }
2925
+ if action.hasSuffix(":") { _ = stack.perform(selector, with: control) }
2926
+ else { _ = stack.perform(selector) }
2927
+ }
2928
+ }
2929
+
2930
+ private func requireStructuralAnimation() throws {
2931
+ if UIAccessibility.isReduceMotionEnabled {
2932
+ throw XCTSkip("Structural animation is disabled by Reduce Motion")
2933
+ }
2934
+ }
2935
+
2936
+ private func waitForStructuralFrames(_ seconds: TimeInterval) {
2937
+ let completed = expectation(description: "Native structural display link ran for \(seconds)s")
2938
+ DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { completed.fulfill() }
2939
+ wait(for: [completed], timeout: seconds + 1)
2940
+ }
2941
+
2942
+ private func labels(in view: UIView) -> [UILabel] {
2943
+ let current = (view as? UILabel).map { [$0] } ?? []
2944
+ return current + view.subviews.flatMap(labels(in:))
2945
+ }
2946
+
2947
+ private func structuralRowsSnapshot(
2948
+ count: Int, generation: Int? = nil, transitionID: String? = nil,
2949
+ durationMs: Int = 80, dataRows: Bool = false, firstRowHeight: Int? = nil,
2950
+ uniformHeight: Int = 60, orientation: String = "vertical", layoutKind: String = "linear",
2951
+ disclosureExpanded: Bool? = nil, disclosureInset: Int = 20
2952
+ ) throws -> String {
2953
+ let rows: [[String: Any]] = (0..<count).map {
2954
+ let height = $0 == 0 ? firstRowHeight ?? uniformHeight : uniformHeight
2955
+ if dataRows {
2956
+ return ["key": "row-\($0)", "type": "dataRow", "height": height,
2957
+ "columns": [["key": "asset", "text": "Asset"],
2958
+ ["key": "price", "text": "$1.00"]]]
2959
+ }
2960
+ return ["key": "row-\($0)", "type": "action", "title": "Row", "height": height]
2961
+ }
2962
+ var layout: [String: Any] = ["kind": layoutKind, "orientation": orientation]
2963
+ if layoutKind == "grid" { layout["gridColumns"] = 2 }
2964
+ var value: [String: Any] = [
2965
+ "schemaVersion": 1, "generation": generation ?? count + 1,
2966
+ "layout": layout, "rows": rows,
2967
+ ]
2968
+ if let transitionID {
2969
+ value["structuralTransition"] = [
2970
+ "id": transitionID, "durationMs": durationMs, "anchorKey": "row-24",
2971
+ ]
2972
+ }
2973
+ if let disclosureExpanded {
2974
+ value["disclosure"] = ["expanded": disclosureExpanded,
2975
+ "expandedTrailingInset": disclosureInset]
2976
+ }
2977
+ return String(decoding: try JSONSerialization.data(withJSONObject: value), as: UTF8.self)
2978
+ }
2979
+
2980
+ private func assertHomeSidecarGeometry(sidecarBeforeFirstLayout: Bool) throws {
2981
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 1376, height: 900))
2982
+ let controller = UIViewController()
2983
+ window.rootViewController = controller
2984
+ let view = NativeListView(frame: window.bounds)
2985
+ controller.view.addSubview(view)
2986
+ window.isHidden = false
2987
+ defer {
2988
+ view.dispose()
2989
+ view.removeFromSuperview()
2990
+ window.isHidden = true
2991
+ window.rootViewController = nil
2992
+ }
2993
+ for index in 0..<4 { view.mountContainerSlot(UIView(), atIndex: index) }
2994
+ view.setContainerSlotHeightsJson("[0,0,0,1200]")
2995
+ let sidecar = "{\"width\":400,\"gap\":24,\"maxContentWidth\":1140}"
2996
+ if sidecarBeforeFirstLayout { view.setSidecarLayoutJson(sidecar) }
2997
+ view.applySnapshotJson(try homeSidecarSnapshot())
2998
+ let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
2999
+ list.contentInsetAdjustmentBehavior = .never
3000
+ func layout() {
3001
+ window.layoutIfNeeded()
3002
+ view.layoutIfNeeded()
3003
+ list.layoutIfNeeded()
3004
+ }
3005
+ func assertRows(x: CGFloat, width: CGFloat) {
3006
+ let cells = list.visibleCells.compactMap { $0 as? NativeListDataRowCell }
3007
+ XCTAssertFalse(cells.isEmpty)
3008
+ for cell in cells {
3009
+ let frame = cell.convert(cell.bounds, to: list)
3010
+ XCTAssertEqual(frame.minX, x, accuracy: 1)
3011
+ XCTAssertEqual(frame.width, width, accuracy: 1)
3012
+ if let path = list.indexPath(for: cell),
3013
+ let attributes = list.layoutAttributesForItem(at: path)
3014
+ {
3015
+ XCTAssertEqual(attributes.frame.minX, x, accuracy: 1)
3016
+ XCTAssertEqual(attributes.frame.width, width, accuracy: 1)
3017
+ }
3018
+ let root = cell.root.convert(cell.root.bounds, to: list)
3019
+ XCTAssertGreaterThanOrEqual(root.minX, frame.minX - 1)
3020
+ XCTAssertLessThanOrEqual(root.maxX, frame.maxX + 1)
3021
+ }
3022
+ }
3023
+ layout()
3024
+ if !sidecarBeforeFirstLayout {
3025
+ assertRows(x: 8, width: 1360)
3026
+ view.setSidecarLayoutJson(sidecar)
3027
+ layout()
3028
+ }
3029
+ assertRows(x: 126, width: 700)
3030
+ list.contentOffset.y = 18 * 60
3031
+ layout()
3032
+ assertRows(x: 126, width: 700)
3033
+ let patch: [[String: Any]] = [[
3034
+ "key": "row-20", "type": "dataRow",
3035
+ "changes": ["columns": homeSidecarColumns(20, updated: true),
3036
+ "trailing": [["kind": "button", "label": "Swap", "actionKey": "swap"]]],
3037
+ ]]
3038
+ view.applyPatchesJson(String(decoding: try JSONSerialization.data(withJSONObject: patch),
3039
+ as: UTF8.self))
3040
+ layout()
3041
+ assertRows(x: 126, width: 700)
3042
+ window.frame.size.width = 1024
3043
+ controller.view.frame = window.bounds
3044
+ view.frame = window.bounds
3045
+ layout()
3046
+ assertRows(x: 8, width: 584)
3047
+ window.frame.size.width = 1376
3048
+ controller.view.frame = window.bounds
3049
+ view.frame = window.bounds
3050
+ layout()
3051
+ assertRows(x: 126, width: 700)
3052
+ view.setSidecarLayoutJson("")
3053
+ layout()
3054
+ assertRows(x: 8, width: 1360)
3055
+ }
3056
+
3057
+ private func homeSidecarSnapshot() throws -> String {
3058
+ let rows: [[String: Any]] = (0..<30).map { index in
3059
+ ["key": "row-\(index)", "type": "dataRow", "height": 60,
3060
+ "leading": ["kind": "token", "fallbackIcon": ["name": "CryptoCoinOutline"]],
3061
+ "columns": homeSidecarColumns(index, updated: false),
3062
+ "trailing": [["kind": "button", "label": "Swap", "actionKey": "swap"]],
3063
+ "style": ["horizontalPadding": 12, "verticalPadding": 8, "leadingGap": 12,
3064
+ "image": ["width": 32, "height": 32]]]
3065
+ }
3066
+ let snapshot: [String: Any] = [
3067
+ "schemaVersion": 1, "generation": 1,
3068
+ "layout": ["kind": "linear", "contentPaddingHorizontal": 8], "rows": rows,
3069
+ ]
3070
+ return String(decoding: try JSONSerialization.data(withJSONObject: snapshot), as: UTF8.self)
3071
+ }
3072
+
3073
+ private func homeSidecarColumns(_ index: Int, updated: Bool) -> [[String: Any]] {
3074
+ [
3075
+ ["key": "asset", "text": "Asset \(index)", "secondaryText": "Public fixture", "weight": 1],
3076
+ ["key": "balance", "text": updated ? "0.2966" : "0.000175",
3077
+ "secondaryText": "$0.13", "alignment": "end", "weight": 1],
3078
+ ["key": "price", "text": updated ? "$739.22" : "$0.9989",
3079
+ "secondaryText": "-0.04%", "alignment": "end", "weight": 1],
3080
+ ]
3081
+ }
3082
+
366
3083
  private func makeScrollingList() throws -> NativeListView {
367
3084
  let view = NativeListView(frame: CGRect(x: 0, y: 0, width: 320, height: 708))
368
3085
  for index in 0..<3 { view.mountContainerSlot(UIView(), atIndex: index) }
369
- view.setContainerSlotHeightsJson("[44,44,44]")
3086
+ view.setContainerSlotHeightsJson("[44,44,44,0]")
370
3087
  view.applySnapshotJson(try scrollingSnapshot(count: 40))
371
3088
  view.layoutIfNeeded()
372
3089
  let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)