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.
- package/README.md +1 -1
- package/android/src/main/java/com/audiobrowser/browser/InertStyleDiagnostic.kt +28 -7
- package/android/src/main/java/com/audiobrowser/util/StyleWire.kt +2 -0
- package/android/src/test/java/com/audiobrowser/browser/InertStyleDiagnosticTest.kt +32 -8
- package/android/src/test/java/com/audiobrowser/browser/JsonStyleTest.kt +21 -0
- package/android/src/test/java/com/audiobrowser/browser/StyleResolverTest.kt +6 -0
- package/ios/Browser/InertStyleDiagnostic.swift +25 -8
- package/ios/Browser/SectionPresentation.swift +18 -5
- package/ios/CarPlay/CPInterfaceController+Safe.swift +93 -23
- package/ios/CarPlay/CarPlayListItemFactory.swift +74 -54
- package/ios/CarPlay/TopTemplateReplacer.swift +96 -0
- package/ios/Model/NitroTypeStubs.swift +3 -0
- package/ios/Tests/InertStyleDiagnosticTests.swift +17 -8
- package/ios/Tests/JsonModelsTests.swift +14 -0
- package/ios/Tests/SectionPresentationTests.swift +21 -13
- package/ios/Tests/StyleResolverTests.swift +5 -0
- package/ios/Tests/TopTemplateReplacerTests.swift +172 -0
- package/lib/typescript/src/types/browser-nodes.d.ts +28 -21
- package/lib/typescript/src/types/browser-nodes.d.ts.map +1 -1
- package/nitrogen/generated/android/c++/JGridTile.hpp +3 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/GridTile.kt +3 -2
- package/nitrogen/generated/ios/swift/GridTile.swift +4 -0
- package/nitrogen/generated/shared/c++/GridTile.hpp +6 -2
- package/package.json +1 -1
- package/src/types/browser-nodes.ts +28 -21
- package/website/guide/artwork.md +1 -1
- package/website/guide/browser.md +11 -5
- package/website/guide/carplay.md +30 -0
- 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
|
|
50
|
-
///
|
|
51
|
-
|
|
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
|
|
62
|
-
///
|
|
63
|
-
///
|
|
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.
|
|
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
|
-
|
|
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
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
217
|
-
/// ('background' selects the larger
|
|
218
|
-
///
|
|
219
|
-
private static func tileImageSize(
|
|
220
|
-
|
|
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`);
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
switch
|
|
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
|
|
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:
|
|
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:
|
|
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(
|
|
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:
|
|
374
|
+
item = CPListImageRowItem(text: rowText ?? "", images: placeholders, imageTitles: tracks.map(\.title))
|
|
353
375
|
} else {
|
|
354
|
-
item = CPListImageRowItem(text:
|
|
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
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
91
|
-
#expect(
|
|
92
|
-
|
|
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
|
|
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
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
|
|
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: .
|
|
68
|
-
== .cardElements)
|
|
67
|
+
SectionPresentation.tileFamily(for: SectionStyle(gridTile: .image)) == .imageGridElements)
|
|
69
68
|
#expect(
|
|
70
|
-
SectionPresentation.tileFamily(for: SectionStyle(gridTile: .condensed)
|
|
71
|
-
|
|
72
|
-
#expect(SectionPresentation.tileFamily(for:
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
+
}
|