react-native-audio-browser 0.2.0-next.21 → 0.2.0-next.23

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 (29) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/com/audiobrowser/browser/InertStyleDiagnostic.kt +28 -7
  3. package/android/src/main/java/com/audiobrowser/util/StyleWire.kt +2 -0
  4. package/android/src/test/java/com/audiobrowser/browser/InertStyleDiagnosticTest.kt +32 -8
  5. package/android/src/test/java/com/audiobrowser/browser/JsonStyleTest.kt +21 -0
  6. package/android/src/test/java/com/audiobrowser/browser/StyleResolverTest.kt +6 -0
  7. package/ios/Browser/InertStyleDiagnostic.swift +25 -8
  8. package/ios/Browser/SectionPresentation.swift +18 -5
  9. package/ios/CarPlay/CPInterfaceController+Safe.swift +93 -23
  10. package/ios/CarPlay/CarPlayListItemFactory.swift +74 -54
  11. package/ios/CarPlay/TopTemplateReplacer.swift +96 -0
  12. package/ios/Model/NitroTypeStubs.swift +3 -0
  13. package/ios/Tests/InertStyleDiagnosticTests.swift +17 -8
  14. package/ios/Tests/JsonModelsTests.swift +14 -0
  15. package/ios/Tests/SectionPresentationTests.swift +21 -13
  16. package/ios/Tests/StyleResolverTests.swift +5 -0
  17. package/ios/Tests/TopTemplateReplacerTests.swift +172 -0
  18. package/lib/typescript/src/types/browser-nodes.d.ts +28 -21
  19. package/lib/typescript/src/types/browser-nodes.d.ts.map +1 -1
  20. package/nitrogen/generated/android/c++/JGridTile.hpp +3 -0
  21. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/GridTile.kt +3 -2
  22. package/nitrogen/generated/ios/swift/GridTile.swift +4 -0
  23. package/nitrogen/generated/shared/c++/GridTile.hpp +6 -2
  24. package/package.json +1 -1
  25. package/src/types/browser-nodes.ts +28 -21
  26. package/website/guide/artwork.md +1 -1
  27. package/website/guide/browser.md +11 -5
  28. package/website/guide/carplay.md +30 -0
  29. package/website/guide/track.md +1 -1
@@ -46,22 +46,29 @@ final class CarPlayListItemFactory {
46
46
 
47
47
  // MARK: - Sections
48
48
 
49
- /// Whether this OS has CarPlay's wrapping tile container (the iOS 26
50
- /// element API) — the capability `SectionPresentation` degrades against.
51
- private static var supportsWrappingGrid: Bool {
49
+ /// Whether this OS has CarPlay's iOS 26 element API — the wrapping tile
50
+ /// container and the text-less image row `SectionPresentation` degrades
51
+ /// against.
52
+ private static var supportsElements: Bool {
52
53
  if #available(iOS 26.0, *) { return true }
53
54
  return false
54
55
  }
55
56
 
57
+ private static func listSection(_ items: [CPListTemplateItem], header: String?) -> CPListSection {
58
+ if let header {
59
+ return CPListSection(items: items, header: header, sectionIndexTitle: nil)
60
+ }
61
+ return CPListSection(items: items)
62
+ }
63
+
56
64
  /// Maps the page's sections 1:1 to CPListSections (ADR 0010), respecting
57
65
  /// CarPlay's section and total-item budgets. Style blocks declare the
58
66
  /// requested presentation (resolved `section ?? page` here — ADR 0011);
59
67
  /// this renders CarPlay's nearest supported form (`SectionPresentation`):
60
68
  /// - list (default): a titled/headerless section of list rows.
61
- /// - single-line grid (`gridWrap: false`): a headerless section holding
62
- /// one single-line image-row item whose text is the section title — the
63
- /// tiles that fit render, the rest truncate (the platform doesn't
64
- /// report the fit).
69
+ /// - single-line grid (`gridWrap: false`): a section holding one
70
+ /// single-line image-row item — the tiles that fit render, the rest
71
+ /// truncate (the platform doesn't report the fit).
65
72
  /// - wrapping grid: a wrapping, titled tile grid on iOS 26+; a plain list
66
73
  /// before that.
67
74
  func createSections(from resolvedTrack: ResolvedTrack) -> [CPListSection] {
@@ -84,26 +91,24 @@ final class CarPlayListItemFactory {
84
91
  guard !section.children.isEmpty else { continue }
85
92
 
86
93
  let style = StyleResolver.sectionStyle(section: section.style, page: resolvedTrack.style)
87
- let presentation = SectionPresentation(for: style, supportsWrappingGrid: Self.supportsWrappingGrid)
94
+ let presentation = SectionPresentation(for: style, supportsWrappingGrid: Self.supportsElements)
88
95
  switch presentation {
89
96
  case .list:
90
97
  let availableSlots = maxTotalItems - totalItemCount
91
98
  let items: [CPListTemplateItem] = section.children.prefix(availableSlots).map {
92
99
  createListItem(for: $0, style: StyleResolver.trackStyle(track: $0.style, section: style))
93
100
  }
94
- if let title = section.title {
95
- listSections.append(CPListSection(items: items, header: title, sectionIndexTitle: nil))
96
- } else {
97
- listSections.append(CPListSection(items: items))
98
- }
101
+ listSections.append(Self.listSection(items, header: section.title))
99
102
  totalItemCount += items.count
100
103
  case .singleLineRow, .wrappingGrid:
101
- // Tile presentations render as one image-row item inside a
102
- // headerless CPListSection, with the section title as the item's
103
- // text — a headed section would render the title twice.
104
- guard let item = createImageRowItem(for: section, style: style, presentation: presentation)
105
- else { continue }
106
- listSections.append(CPListSection(items: [item]))
104
+ // Tile presentations render as one image-row item in its own
105
+ // CPListSection. The title is drawn once: as the section's header
106
+ // (`SectionPresentation.tileSectionHeader`), or as the item's text.
107
+ let header = SectionPresentation.tileSectionHeader(section, supportsElements: Self.supportsElements)
108
+ guard let item = createImageRowItem(
109
+ for: section, text: header == nil ? section.title : nil, style: style, presentation: presentation,
110
+ ) else { continue }
111
+ listSections.append(Self.listSection([item], header: header))
107
112
  totalItemCount += 1
108
113
  }
109
114
  }
@@ -213,16 +218,25 @@ final class CarPlayListItemFactory {
213
218
  return CPListImageRowItem.maximumImageSize
214
219
  }
215
220
 
216
- /// Target artwork size for one tile. Cards read their own class maximums
217
- /// ('background' selects the larger full-height target); every other
218
- /// family uses the shared element size.
219
- private static func tileImageSize(gridTile: GridTile?, cardImage: CardImage?) -> CGSize {
220
- if #available(iOS 26.0, *), gridTile == .card {
221
+ /// Target artwork size for one tile. Cards, image tiles and plain row
222
+ /// elements read their own class maximums ('background' selects the larger
223
+ /// full-height card target); condensed tiles use the shared element size.
224
+ private static func tileImageSize(
225
+ family: SectionPresentation.TileElementFamily, cardImage: CardImage?,
226
+ ) -> CGSize {
227
+ guard #available(iOS 26.0, *) else { return CPListImageRowItem.maximumImageSize }
228
+ switch family {
229
+ case .cardElements:
221
230
  return cardImage == .background
222
231
  ? CPListImageRowItemCardElement.maximumFullHeightImageSize
223
232
  : CPListImageRowItemCardElement.maximumImageSize
233
+ case .rowElements:
234
+ return CPListImageRowItemRowElement.maximumImageSize
235
+ case .imageGridElements:
236
+ return CPListImageRowItemImageGridElement.maximumImageSize
237
+ case .condensedElements:
238
+ return CPListImageRowItemElement.maximumImageSize
224
239
  }
225
- return rowImageSize
226
240
  }
227
241
 
228
242
  /// Creates the image-row item rendering a tile-presented section: a single
@@ -231,10 +245,14 @@ final class CarPlayListItemFactory {
231
245
  ///
232
246
  /// Returns nil when nothing is drawable — every child hidden by the
233
247
  /// disabled ladder (pre-26 only; the element APIs gray instead).
248
+ ///
249
+ /// - Parameter text: the row's own title; nil when the list section's
250
+ /// header carries it.
234
251
  private func createImageRowItem(
235
- for section: Section, style: SectionStyle, presentation: SectionPresentation,
252
+ for section: Section, text rowText: String?, style: SectionStyle, presentation: SectionPresentation,
236
253
  ) -> CPListImageRowItem? {
237
254
  let singleLine = presentation == .singleLineRow
255
+ let family = SectionPresentation.tileFamily(for: style)
238
256
  // Disabled ladder: iOS 26 elements can draw unavailability (grayed +
239
257
  // inert, below); the legacy image row can't, so there a disabled track
240
258
  // hides — never a normal-looking dead tile.
@@ -286,14 +304,14 @@ final class CarPlayListItemFactory {
286
304
  elements[index].image = image
287
305
  }
288
306
  }
289
- // The family is style-driven (`SectionPresentation.tileFamily`); both
290
- // non-plain families take either wrap mode. Pre-26 the treatment drops
291
- // and the layout survives (the legacy branch below).
292
- switch SectionPresentation.tileFamily(for: style, singleLine: singleLine) {
307
+ // The family is style-driven (`SectionPresentation.tileFamily`); every
308
+ // family takes either wrap mode. Pre-26 the treatment drops and the
309
+ // layout survives (the legacy branch below).
310
+ switch family {
293
311
  case .cardElements:
294
312
  // Cards have no shape and no accessory slot; their knobs are the
295
313
  // tint and the image mode ('background' fills the card full-height
296
- // and turns the tint into the color behind the labels).
314
+ // and fades the tint in behind the labels).
297
315
  let elements = makeElements { track, resolved in
298
316
  CPListImageRowItemCardElement(
299
317
  image: placeholder(),
@@ -304,7 +322,7 @@ final class CarPlayListItemFactory {
304
322
  )
305
323
  }
306
324
  item = CPListImageRowItem(
307
- text: section.title, cardElements: elements, allowsMultipleLines: !singleLine,
325
+ text: rowText, cardElements: elements, allowsMultipleLines: !singleLine,
308
326
  )
309
327
  applyImage = imageApplier(elements)
310
328
  case .condensedElements:
@@ -318,19 +336,9 @@ final class CarPlayListItemFactory {
318
336
  )
319
337
  }
320
338
  item = CPListImageRowItem(
321
- text: section.title, condensedElements: elements, allowsMultipleLines: !singleLine,
339
+ text: rowText, condensedElements: elements, allowsMultipleLines: !singleLine,
322
340
  )
323
341
  applyImage = imageApplier(elements)
324
- case .rowElements:
325
- // The only plain tile with a subtitle slot — the imageGridElements
326
- // family has no equivalent, by SDK design.
327
- let elements = makeElements { track, _ in
328
- CPListImageRowItemRowElement(
329
- image: placeholder(), title: track.title, subtitle: track.subtitle,
330
- )
331
- }
332
- item = CPListImageRowItem(text: section.title, elements: elements, allowsMultipleLines: false)
333
- applyImage = imageApplier(elements)
334
342
  case .imageGridElements:
335
343
  // imageGridElements, never the title-less gridElements: an
336
344
  // artwork-less track must keep its name on screen (ADR 0010).
@@ -342,28 +350,40 @@ final class CarPlayListItemFactory {
342
350
  accessorySymbolName: SectionPresentation.effectiveAccessorySymbol(resolved),
343
351
  )
344
352
  }
345
- item = CPListImageRowItem(text: section.title, imageGridElements: elements, allowsMultipleLines: true)
353
+ item = CPListImageRowItem(
354
+ text: rowText, imageGridElements: elements, allowsMultipleLines: !singleLine,
355
+ )
356
+ applyImage = imageApplier(elements)
357
+ case .rowElements:
358
+ // Titled, so an artwork-less track keeps its name on screen
359
+ // (ADR 0010); no shape and no accessory slot.
360
+ let elements = makeElements { track, _ in
361
+ CPListImageRowItemRowElement(
362
+ image: placeholder(), title: track.title, subtitle: track.subtitle,
363
+ )
364
+ }
365
+ item = CPListImageRowItem(
366
+ text: rowText, elements: elements, allowsMultipleLines: !singleLine,
367
+ )
346
368
  applyImage = imageApplier(elements)
347
369
  }
348
370
  } else {
349
371
  let placeholders = tracks.map { _ in placeholder() }
350
372
  // Use imageTitles variant on iOS 17.4+ to show titles below each thumbnail
351
373
  if #available(iOS 17.4, *) {
352
- item = CPListImageRowItem(text: section.title ?? "", images: placeholders, imageTitles: tracks.map(\.title))
374
+ item = CPListImageRowItem(text: rowText ?? "", images: placeholders, imageTitles: tracks.map(\.title))
353
375
  } else {
354
- item = CPListImageRowItem(text: section.title ?? "", images: placeholders)
376
+ item = CPListImageRowItem(text: rowText ?? "", images: placeholders)
355
377
  }
356
378
  }
357
379
 
358
380
  // Header tap → navigate to section.path ("view all"). A path-less
359
- // section is a pure preview — its header tap is a no-op rather than a
360
- // selection that can't resolve.
361
- item.handler = { [onItemSelected] _, completion in
362
- guard let path = section.path else {
363
- completion()
364
- return
381
+ // section is a pure preview and gets no handler.
382
+ if let path = section.path {
383
+ let target = Self.navigationTrack(path: path, title: section.title)
384
+ item.handler = { [onItemSelected] _, completion in
385
+ onItemSelected(target, completion)
365
386
  }
366
- onItemSelected(Self.navigationTrack(path: path, title: section.title), completion)
367
387
  }
368
388
 
369
389
  // Per-tile taps select the child track directly. A disabled tile is
@@ -385,7 +405,7 @@ final class CarPlayListItemFactory {
385
405
  imageLoader?.loadArtwork(
386
406
  for: track,
387
407
  style: resolved,
388
- size: Self.tileImageSize(gridTile: style.gridTile, cardImage: resolved.cardImage),
408
+ size: Self.tileImageSize(family: family, cardImage: resolved.cardImage),
389
409
  ) { [weak item, applyImage] image in
390
410
  Task { @MainActor in
391
411
  guard let item, let image else { return }
@@ -0,0 +1,96 @@
1
+ import Foundation
2
+ import os.log
3
+
4
+ /// The stack mutations a top-template replacement is made of:
5
+ /// `CPInterfaceController` in the app, a fake in tests.
6
+ @MainActor
7
+ protocol TemplateStack {
8
+ associatedtype Template: AnyObject
9
+
10
+ /// Root first.
11
+ var templates: [Template] { get }
12
+
13
+ /// Unanimated.
14
+ func popTop(completion: @escaping (Bool, Error?) -> Void)
15
+ func push(_ template: Template, animated: Bool, completion: @escaping (Bool, Error?) -> Void)
16
+ }
17
+
18
+ /// Pushes onto a full template stack by replacing its top template — an
19
+ /// unanimated pop, then the push — and holds back every other stack mutation
20
+ /// until both have completed. See "Template Depth" in
21
+ /// `website/guide/carplay.md`.
22
+ @MainActor
23
+ final class TopTemplateReplacer {
24
+ private let logger = Logger(subsystem: "com.audiobrowser", category: "TemplateStack")
25
+ private var deferred: [() -> Void] = []
26
+
27
+ /// True from the pop until the push completes.
28
+ private(set) var isReplacing = false
29
+
30
+ /// Runs `mutation` now, or — while a replacement is in flight — once it has
31
+ /// settled, after the mutations that arrived before it.
32
+ func whenSettled(_ mutation: @escaping () -> Void) {
33
+ guard isReplacing else {
34
+ mutation()
35
+ return
36
+ }
37
+ deferred.append(mutation)
38
+ }
39
+
40
+ /// Pushes `template` onto `stack` in place of its top template. When the
41
+ /// push fails, the popped template is pushed back, so the user stays where
42
+ /// they were. `completion` reports the push of `template` itself.
43
+ ///
44
+ /// Refuses a stack with nothing above the root to replace, and a call made
45
+ /// while a replacement is in flight: route it through `whenSettled`.
46
+ func push<Stack: TemplateStack>(
47
+ _ template: Stack.Template,
48
+ replacingTopOf stack: Stack,
49
+ animated: Bool,
50
+ completion: ((Bool, Error?) -> Void)? = nil,
51
+ ) {
52
+ let templates = stack.templates
53
+ guard !isReplacing, templates.count > 1, let top = templates.last else {
54
+ logger.error("push skipped: \(Self.name(of: template)) has no template to replace (\(templates.count) templates)")
55
+ completion?(false, nil)
56
+ return
57
+ }
58
+ isReplacing = true
59
+ logger.info("push at the depth limit: replacing the top, \(Self.name(of: top)), with \(Self.name(of: template))")
60
+
61
+ stack.popTop { [self] popped, error in
62
+ guard popped else {
63
+ logger.error("replace abandoned: pop failed, \(Self.name(of: template)) not pushed (\(stack.templates.count) templates)")
64
+ settle(completion, pushed: false, error: error)
65
+ return
66
+ }
67
+ stack.push(template, animated: animated) { [self] pushed, error in
68
+ guard !pushed else {
69
+ settle(completion, pushed: true, error: error)
70
+ return
71
+ }
72
+ logger.error("replace failed: \(Self.name(of: template)) not pushed, restoring \(Self.name(of: top))")
73
+ stack.push(top, animated: false) { [self] restored, _ in
74
+ if !restored {
75
+ logger.error("replace failed: \(Self.name(of: top)) not restored (\(stack.templates.count) templates)")
76
+ }
77
+ settle(completion, pushed: false, error: error)
78
+ }
79
+ }
80
+ }
81
+ }
82
+
83
+ private func settle(_ completion: ((Bool, Error?) -> Void)?, pushed: Bool, error: Error?) {
84
+ // Still replacing here, so a mutation the completion issues queues behind
85
+ // the ones that arrived during the replacement.
86
+ completion?(pushed, error)
87
+ isReplacing = false
88
+ while !isReplacing, !deferred.isEmpty {
89
+ deferred.removeFirst()()
90
+ }
91
+ }
92
+
93
+ private static func name(of template: AnyObject) -> String {
94
+ String(describing: type(of: template))
95
+ }
96
+ }
@@ -128,12 +128,14 @@
128
128
 
129
129
  enum GridTile: Equatable {
130
130
  case plain
131
+ case image
131
132
  case card
132
133
  case condensed
133
134
 
134
135
  init?(fromString string: String) {
135
136
  switch string {
136
137
  case "plain": self = .plain
138
+ case "image": self = .image
137
139
  case "card": self = .card
138
140
  case "condensed": self = .condensed
139
141
  default: return nil
@@ -145,6 +147,7 @@
145
147
  var stringValue: String {
146
148
  switch self {
147
149
  case .plain: "plain"
150
+ case .image: "image"
148
151
  case .card: "card"
149
152
  case .condensed: "condensed"
150
153
  }
@@ -74,22 +74,31 @@ struct InertStyleDiagnosticTests {
74
74
 
75
75
  @Test func imageShapeNeedsAShapedTile() {
76
76
  let list = SectionStyle(imageShape: .circular)
77
+ let plain = SectionStyle(display: .grid, imageShape: .circular)
77
78
  let cards = SectionStyle(gridTile: .card, display: .grid, imageShape: .circular)
78
79
  let condensed = SectionStyle(gridTile: .condensed, display: .grid, imageShape: .circular)
80
+ let image = SectionStyle(gridTile: .image, display: .grid, imageShape: .circular)
79
81
  #expect(findings([Section(style: list, children: [])]).count == 1)
82
+ #expect(findings([Section(style: plain, children: [])]).count == 1)
80
83
  #expect(findings([Section(style: cards, children: [])]).count == 1)
81
- // Plain and condensed tiles both take a shape.
82
84
  #expect(findings([Section(style: condensed, children: [])]).isEmpty)
85
+ #expect(findings([Section(style: image, children: [])]).isEmpty)
83
86
  }
84
87
 
85
- @Test func accessorySymbolIsInertOnlyOnCards() {
88
+ @Test func accessorySymbolNeedsAnAccessorySlot() {
86
89
  let rows = SectionStyle(accessorySymbol: "lock.fill")
90
+ let condensed = SectionStyle(gridTile: .condensed, display: .grid, accessorySymbol: "lock.fill")
91
+ let image = SectionStyle(gridTile: .image, display: .grid, accessorySymbol: "lock.fill")
92
+ let plain = SectionStyle(display: .grid, accessorySymbol: "lock.fill")
87
93
  let cards = SectionStyle(gridTile: .card, display: .grid, accessorySymbol: "lock.fill")
88
- // List rows draw accessories; card elements have no slot for one.
89
94
  #expect(findings([Section(style: rows, children: [])]).isEmpty)
90
- let found = findings([Section(style: cards, children: [])])
91
- #expect(found.count == 1)
92
- #expect(found[0].contains("no accessory slot"))
95
+ #expect(findings([Section(style: condensed, children: [])]).isEmpty)
96
+ #expect(findings([Section(style: image, children: [])]).isEmpty)
97
+ for style in [plain, cards] {
98
+ let found = findings([Section(style: style, children: [])])
99
+ #expect(found.count == 1)
100
+ #expect(found[0].contains("accessory slot"))
101
+ }
93
102
  }
94
103
 
95
104
  @Test func artworkRenderingIsNeverInert() {
@@ -114,9 +123,9 @@ struct InertStyleDiagnosticTests {
114
123
  #expect(found[0].contains("style.cardTint"))
115
124
  }
116
125
 
117
- @Test func aTracksOwnShapeIsLiveInAPlainGrid() {
126
+ @Test func aTracksOwnShapeIsLiveInAShapedGrid() {
118
127
  let section = Section(
119
- style: SectionStyle(display: .grid),
128
+ style: SectionStyle(gridTile: .image, display: .grid),
120
129
  children: [playable(style: TrackStyle(imageShape: .circular))],
121
130
  )
122
131
  #expect(findings([section]).isEmpty)
@@ -162,6 +162,20 @@ struct JsonStyleTests {
162
162
  #expect(style?.cardImage == .background)
163
163
  }
164
164
 
165
+ @Test func decodesTheImageFamily() throws {
166
+ let json = Data(
167
+ """
168
+ {"path":"/home","title":"Home","sections":[
169
+ {"style":{"display":"grid","gridTile":"image","imageShape":"circular"},
170
+ "children":[{"title":"C","src":"s"}]}
171
+ ]}
172
+ """.utf8)
173
+ let resolved = try JSONDecoder().decode(JsonResolvedTrack.self, from: json).toNitro()
174
+ let style = resolved.sections?.first?.style
175
+ #expect(style?.gridTile == .image)
176
+ #expect(style?.imageShape == .circular)
177
+ }
178
+
165
179
  @Test func unknownGridTileDecodesAsNoDeclaration() throws {
166
180
  let json = Data(
167
181
  """
@@ -59,21 +59,29 @@ struct SectionPresentationTests {
59
59
  == .list)
60
60
  }
61
61
 
62
- @Test func tileFamilyFollowsGridTileWithThePlainSplit() {
63
- // Card/condensed follow the declaration in either wrap mode; the plain
64
- // default splits on the wrap: single-line keeps row elements (the
65
- // subtitle slot), wrapping renders the image grid.
62
+ @Test func tileFamilyFollowsGridTile() {
63
+ // The family follows the declaration alone; the wrap mode only decides
64
+ // whether its elements take more than one line.
65
+ #expect(SectionPresentation.tileFamily(for: SectionStyle(gridTile: .card)) == .cardElements)
66
66
  #expect(
67
- SectionPresentation.tileFamily(for: SectionStyle(gridTile: .card), singleLine: true)
68
- == .cardElements)
67
+ SectionPresentation.tileFamily(for: SectionStyle(gridTile: .image)) == .imageGridElements)
69
68
  #expect(
70
- SectionPresentation.tileFamily(for: SectionStyle(gridTile: .condensed), singleLine: false)
71
- == .condensedElements)
72
- #expect(SectionPresentation.tileFamily(for: nil, singleLine: true) == .rowElements)
73
- #expect(SectionPresentation.tileFamily(for: nil, singleLine: false) == .imageGridElements)
74
- #expect(
75
- SectionPresentation.tileFamily(for: SectionStyle(gridTile: .plain), singleLine: true)
76
- == .rowElements)
69
+ SectionPresentation.tileFamily(for: SectionStyle(gridTile: .condensed)) == .condensedElements)
70
+ #expect(SectionPresentation.tileFamily(for: nil) == .rowElements)
71
+ #expect(SectionPresentation.tileFamily(for: SectionStyle(gridTile: .plain)) == .rowElements)
72
+ }
73
+
74
+ @Test func aPathlessShelfTitlesTheSectionHeader() {
75
+ let viewAll = Section(title: "Popular", path: "/popular", children: [])
76
+ let preview = Section(title: "Popular", children: [])
77
+ let untitled = Section(children: [])
78
+ // The image row's own text carries a chevron, so it is kept for the
79
+ // shelf that has somewhere to go.
80
+ #expect(SectionPresentation.tileSectionHeader(viewAll, supportsElements: true) == nil)
81
+ #expect(SectionPresentation.tileSectionHeader(preview, supportsElements: true) == "Popular")
82
+ #expect(SectionPresentation.tileSectionHeader(untitled, supportsElements: true) == nil)
83
+ // The legacy row cannot drop its text.
84
+ #expect(SectionPresentation.tileSectionHeader(preview, supportsElements: false) == nil)
77
85
  }
78
86
 
79
87
  @Test func effectiveAccessorySymbol_noneAndAbsenceDrawNothing() {
@@ -48,6 +48,11 @@ struct SectionStyleResolutionTests {
48
48
  page: SectionStyle(gridTile: .card),
49
49
  )
50
50
  #expect(overridden.gridTile == .plain)
51
+ let image = StyleResolver.sectionStyle(
52
+ section: SectionStyle(gridTile: .image),
53
+ page: SectionStyle(gridTile: .card),
54
+ )
55
+ #expect(image.gridTile == .image)
51
56
  }
52
57
  }
53
58
 
@@ -0,0 +1,172 @@
1
+ import Foundation
2
+ import Testing
3
+
4
+ @testable import AudioBrowserTestable
5
+
6
+ private final class Page {
7
+ let name: String
8
+ init(_ name: String) { self.name = name }
9
+ }
10
+
11
+ /// A template stack whose mutations complete only when the test steps them,
12
+ /// the way CarPlay's complete on a later run-loop turn.
13
+ @MainActor
14
+ private final class FakeStack: TemplateStack {
15
+ private(set) var templates: [Page]
16
+ private(set) var calls: [String] = []
17
+ var failingPushes: Set<String> = []
18
+ var failsPop = false
19
+ private var pending: [() -> Void] = []
20
+
21
+ init(_ names: String...) {
22
+ templates = names.map(Page.init)
23
+ }
24
+
25
+ var names: [String] {
26
+ templates.map(\.name)
27
+ }
28
+
29
+ func popTop(completion: @escaping (Bool, Error?) -> Void) {
30
+ calls.append("pop")
31
+ pending.append { [self] in
32
+ guard !failsPop else {
33
+ completion(false, nil)
34
+ return
35
+ }
36
+ templates.removeLast()
37
+ completion(true, nil)
38
+ }
39
+ }
40
+
41
+ func push(_ template: Page, animated: Bool, completion: @escaping (Bool, Error?) -> Void) {
42
+ calls.append("push \(template.name)\(animated ? " animated" : "")")
43
+ pending.append { [self] in
44
+ guard !failingPushes.contains(template.name) else {
45
+ completion(false, nil)
46
+ return
47
+ }
48
+ templates.append(template)
49
+ completion(true, nil)
50
+ }
51
+ }
52
+
53
+ func step() {
54
+ pending.removeFirst()()
55
+ }
56
+
57
+ func run() {
58
+ while !pending.isEmpty {
59
+ step()
60
+ }
61
+ }
62
+ }
63
+
64
+ @Suite("TopTemplateReplacer")
65
+ @MainActor
66
+ struct TopTemplateReplacerTests {
67
+ private let replacer = TopTemplateReplacer()
68
+ private let stack = FakeStack("tabs", "A", "B", "C", "D")
69
+
70
+ @Test func replacesTheTopPageAndKeepsTheRest() {
71
+ var result: Bool?
72
+ replacer.push(Page("E"), replacingTopOf: stack, animated: true) { pushed, _ in result = pushed }
73
+ stack.run()
74
+
75
+ #expect(stack.names == ["tabs", "A", "B", "C", "E"])
76
+ #expect(stack.calls == ["pop", "push E animated"])
77
+ #expect(result == true)
78
+ #expect(!replacer.isReplacing)
79
+ }
80
+
81
+ @Test func thePushWaitsForThePopToComplete() {
82
+ replacer.push(Page("E"), replacingTopOf: stack, animated: true)
83
+ #expect(stack.calls == ["pop"])
84
+ stack.step()
85
+ #expect(stack.calls == ["pop", "push E animated"])
86
+ }
87
+
88
+ @Test func aMutationArrivingMidReplacementRunsOnceItHasSettled() {
89
+ var seen: [[String]] = []
90
+ replacer.push(Page("E"), replacingTopOf: stack, animated: true)
91
+ stack.step()
92
+ replacer.whenSettled { seen.append(stack.names) }
93
+ #expect(seen.isEmpty)
94
+ stack.run()
95
+
96
+ #expect(seen == [["tabs", "A", "B", "C", "E"]])
97
+ }
98
+
99
+ @Test func deferredMutationsRunInArrivalOrderBehindASecondReplacement() {
100
+ var order: [String] = []
101
+ replacer.push(Page("E"), replacingTopOf: stack, animated: true)
102
+ replacer.whenSettled { [replacer, stack] in
103
+ order.append("F")
104
+ replacer.push(Page("F"), replacingTopOf: stack, animated: true)
105
+ }
106
+ replacer.whenSettled { order.append("after F: \(stack.names.last ?? "")") }
107
+ stack.run()
108
+
109
+ #expect(stack.names == ["tabs", "A", "B", "C", "F"])
110
+ #expect(order == ["F", "after F: F"])
111
+ }
112
+
113
+ @Test func aMutationIssuedByTheCompletionQueuesBehindEarlierArrivals() {
114
+ var order: [String] = []
115
+ replacer.push(Page("E"), replacingTopOf: stack, animated: true) { [replacer] _, _ in
116
+ replacer.whenSettled { order.append("from completion") }
117
+ }
118
+ replacer.whenSettled { order.append("arrived mid-replacement") }
119
+ stack.run()
120
+
121
+ #expect(order == ["arrived mid-replacement", "from completion"])
122
+ }
123
+
124
+ @Test func aFailedPopLeavesTheStackAsItWas() {
125
+ stack.failsPop = true
126
+ var result: Bool?
127
+ replacer.push(Page("E"), replacingTopOf: stack, animated: true) { pushed, _ in result = pushed }
128
+ stack.run()
129
+
130
+ #expect(stack.names == ["tabs", "A", "B", "C", "D"])
131
+ #expect(stack.calls == ["pop"])
132
+ #expect(result == false)
133
+ #expect(!replacer.isReplacing)
134
+ }
135
+
136
+ @Test func aFailedPushRestoresThePoppedPage() {
137
+ stack.failingPushes = ["E"]
138
+ let top = stack.templates[4]
139
+ var result: Bool?
140
+ var ranAfter = false
141
+ replacer.push(Page("E"), replacingTopOf: stack, animated: true) { pushed, _ in result = pushed }
142
+ replacer.whenSettled { ranAfter = true }
143
+ stack.run()
144
+
145
+ #expect(stack.names == ["tabs", "A", "B", "C", "D"])
146
+ #expect(stack.templates[4] === top)
147
+ #expect(stack.calls == ["pop", "push E animated", "push D"])
148
+ #expect(result == false)
149
+ #expect(ranAfter)
150
+ }
151
+
152
+ @Test func aFailedRestoreStillSettles() {
153
+ stack.failingPushes = ["E", "D"]
154
+ var result: Bool?
155
+ replacer.push(Page("E"), replacingTopOf: stack, animated: true) { pushed, _ in result = pushed }
156
+ stack.run()
157
+
158
+ #expect(stack.names == ["tabs", "A", "B", "C"])
159
+ #expect(result == false)
160
+ #expect(!replacer.isReplacing)
161
+ }
162
+
163
+ @Test func refusesAStackWithNothingAboveTheRoot() {
164
+ let rootOnly = FakeStack("tabs")
165
+ var result: Bool?
166
+ replacer.push(Page("E"), replacingTopOf: rootOnly, animated: true) { pushed, _ in result = pushed }
167
+
168
+ #expect(rootOnly.calls.isEmpty)
169
+ #expect(result == false)
170
+ #expect(!replacer.isReplacing)
171
+ }
172
+ }