expo-tvos-search 1.2.0
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/LICENSE +21 -0
- package/README.md +197 -0
- package/build/index.d.ts +215 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +81 -0
- package/expo-module.config.json +9 -0
- package/ios/ExpoTvosSearch.podspec +16 -0
- package/ios/ExpoTvosSearchModule.swift +84 -0
- package/ios/ExpoTvosSearchView.swift +507 -0
- package/ios/MarqueeAnimationCalculator.swift +41 -0
- package/ios/MarqueeText.swift +168 -0
- package/ios/Tests/MarqueeAnimationCalculatorTests.swift +103 -0
- package/ios/Tests/SearchResultItemTests.swift +200 -0
- package/ios/Tests/SearchViewModelTests.swift +202 -0
- package/package.json +73 -0
- package/src/__tests__/__mocks__/expo-modules-core.ts +21 -0
- package/src/__tests__/__mocks__/react-native.ts +41 -0
- package/src/__tests__/events.test.ts +126 -0
- package/src/__tests__/index.test.tsx +124 -0
- package/src/__tests__/setup.ts +49 -0
- package/src/index.tsx +261 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
#if os(tvOS)
|
|
4
|
+
|
|
5
|
+
/// A text view that scrolls horizontally when content exceeds container width.
|
|
6
|
+
/// Uses PreferenceKey for reactive measurement and Task for cancellable animations.
|
|
7
|
+
struct MarqueeText: View {
|
|
8
|
+
let text: String
|
|
9
|
+
let font: Font
|
|
10
|
+
let leftFade: CGFloat
|
|
11
|
+
let rightFade: CGFloat
|
|
12
|
+
let startDelay: Double
|
|
13
|
+
let animate: Bool
|
|
14
|
+
|
|
15
|
+
@State private var textWidth: CGFloat = 0
|
|
16
|
+
@State private var containerWidth: CGFloat = 0
|
|
17
|
+
@State private var offset: CGFloat = 0
|
|
18
|
+
@State private var animationTask: Task<Void, Never>?
|
|
19
|
+
|
|
20
|
+
private let calculator = MarqueeAnimationCalculator()
|
|
21
|
+
|
|
22
|
+
init(
|
|
23
|
+
_ text: String,
|
|
24
|
+
font: Font = .callout,
|
|
25
|
+
leftFade: CGFloat = 10,
|
|
26
|
+
rightFade: CGFloat = 10,
|
|
27
|
+
startDelay: Double = 1.5,
|
|
28
|
+
animate: Bool = true
|
|
29
|
+
) {
|
|
30
|
+
self.text = text
|
|
31
|
+
self.font = font
|
|
32
|
+
self.leftFade = leftFade
|
|
33
|
+
self.rightFade = rightFade
|
|
34
|
+
self.startDelay = startDelay
|
|
35
|
+
self.animate = animate
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private var needsScroll: Bool {
|
|
39
|
+
calculator.shouldScroll(textWidth: textWidth, containerWidth: containerWidth)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
var body: some View {
|
|
43
|
+
GeometryReader { geometry in
|
|
44
|
+
ZStack(alignment: .leading) {
|
|
45
|
+
// Hidden text to measure actual width
|
|
46
|
+
Text(text)
|
|
47
|
+
.font(font)
|
|
48
|
+
.fixedSize()
|
|
49
|
+
.background(
|
|
50
|
+
GeometryReader { textGeometry in
|
|
51
|
+
Color.clear
|
|
52
|
+
.preference(key: TextWidthKey.self, value: textGeometry.size.width)
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
.hidden()
|
|
56
|
+
|
|
57
|
+
// Visible text content
|
|
58
|
+
Group {
|
|
59
|
+
if needsScroll {
|
|
60
|
+
// Duplicated text for seamless scroll loop
|
|
61
|
+
Text(text + " " + text)
|
|
62
|
+
.font(font)
|
|
63
|
+
.fixedSize()
|
|
64
|
+
.offset(x: offset)
|
|
65
|
+
} else {
|
|
66
|
+
Text(text)
|
|
67
|
+
.font(font)
|
|
68
|
+
.lineLimit(1)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
.frame(width: geometry.size.width, alignment: .leading)
|
|
73
|
+
.clipped()
|
|
74
|
+
.mask(fadeMask)
|
|
75
|
+
.onPreferenceChange(TextWidthKey.self) { width in
|
|
76
|
+
textWidth = width
|
|
77
|
+
}
|
|
78
|
+
.onChange(of: geometry.size.width) { newWidth in
|
|
79
|
+
containerWidth = newWidth
|
|
80
|
+
}
|
|
81
|
+
.onAppear {
|
|
82
|
+
containerWidth = geometry.size.width
|
|
83
|
+
}
|
|
84
|
+
.onChange(of: animate) { shouldAnimate in
|
|
85
|
+
if shouldAnimate && needsScroll {
|
|
86
|
+
startScrolling()
|
|
87
|
+
} else {
|
|
88
|
+
stopScrolling()
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
.onChange(of: needsScroll) { scrollNeeded in
|
|
92
|
+
if animate && scrollNeeded {
|
|
93
|
+
startScrolling()
|
|
94
|
+
} else {
|
|
95
|
+
stopScrolling()
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
.onDisappear {
|
|
99
|
+
// Cancel animation task when view disappears to prevent memory leaks
|
|
100
|
+
animationTask?.cancel()
|
|
101
|
+
animationTask = nil
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private var fadeMask: some View {
|
|
107
|
+
HStack(spacing: 0) {
|
|
108
|
+
LinearGradient(
|
|
109
|
+
colors: [.clear, .black],
|
|
110
|
+
startPoint: .leading,
|
|
111
|
+
endPoint: .trailing
|
|
112
|
+
)
|
|
113
|
+
.frame(width: needsScroll ? leftFade : 0)
|
|
114
|
+
|
|
115
|
+
Color.black
|
|
116
|
+
|
|
117
|
+
LinearGradient(
|
|
118
|
+
colors: [.black, .clear],
|
|
119
|
+
startPoint: .leading,
|
|
120
|
+
endPoint: .trailing
|
|
121
|
+
)
|
|
122
|
+
.frame(width: needsScroll ? rightFade : 0)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private func startScrolling() {
|
|
127
|
+
animationTask?.cancel()
|
|
128
|
+
offset = 0
|
|
129
|
+
|
|
130
|
+
let distance = calculator.scrollDistance(textWidth: textWidth)
|
|
131
|
+
let duration = calculator.animationDuration(for: distance)
|
|
132
|
+
|
|
133
|
+
animationTask = Task {
|
|
134
|
+
do {
|
|
135
|
+
try await Task.sleep(nanoseconds: UInt64(startDelay * 1_000_000_000))
|
|
136
|
+
} catch {
|
|
137
|
+
return // Task was cancelled
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
guard !Task.isCancelled else { return }
|
|
141
|
+
|
|
142
|
+
await MainActor.run {
|
|
143
|
+
withAnimation(.linear(duration: duration).repeatForever(autoreverses: false)) {
|
|
144
|
+
offset = -distance
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private func stopScrolling() {
|
|
151
|
+
animationTask?.cancel()
|
|
152
|
+
animationTask = nil
|
|
153
|
+
withAnimation(.easeOut(duration: 0.2)) {
|
|
154
|
+
offset = 0
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/// PreferenceKey for measuring text width reactively
|
|
160
|
+
private struct TextWidthKey: PreferenceKey {
|
|
161
|
+
static var defaultValue: CGFloat = 0
|
|
162
|
+
|
|
163
|
+
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
|
164
|
+
value = nextValue()
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
#endif
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
|
|
3
|
+
/// Unit tests for MarqueeAnimationCalculator
|
|
4
|
+
final class MarqueeAnimationCalculatorTests: XCTestCase {
|
|
5
|
+
var calculator: MarqueeAnimationCalculator!
|
|
6
|
+
|
|
7
|
+
override func setUp() {
|
|
8
|
+
super.setUp()
|
|
9
|
+
calculator = MarqueeAnimationCalculator()
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
override func tearDown() {
|
|
13
|
+
calculator = nil
|
|
14
|
+
super.tearDown()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// MARK: - shouldScroll Tests
|
|
18
|
+
|
|
19
|
+
func testShouldScroll_textWiderThanContainer_returnsTrue() {
|
|
20
|
+
XCTAssertTrue(calculator.shouldScroll(textWidth: 500, containerWidth: 300))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func testShouldScroll_textFitsContainer_returnsFalse() {
|
|
24
|
+
XCTAssertFalse(calculator.shouldScroll(textWidth: 200, containerWidth: 300))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func testShouldScroll_zeroContainerWidth_returnsFalse() {
|
|
28
|
+
XCTAssertFalse(calculator.shouldScroll(textWidth: 500, containerWidth: 0))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func testShouldScroll_equalWidths_returnsFalse() {
|
|
32
|
+
XCTAssertFalse(calculator.shouldScroll(textWidth: 300, containerWidth: 300))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func testShouldScroll_zeroTextWidth_returnsFalse() {
|
|
36
|
+
XCTAssertFalse(calculator.shouldScroll(textWidth: 0, containerWidth: 300))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func testShouldScroll_negativeContainerWidth_returnsFalse() {
|
|
40
|
+
XCTAssertFalse(calculator.shouldScroll(textWidth: 500, containerWidth: -100))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// MARK: - scrollDistance Tests
|
|
44
|
+
|
|
45
|
+
func testScrollDistance_defaultSpacing() {
|
|
46
|
+
XCTAssertEqual(calculator.scrollDistance(textWidth: 400), 440)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func testScrollDistance_customSpacing() {
|
|
50
|
+
let customCalculator = MarqueeAnimationCalculator(spacing: 20)
|
|
51
|
+
XCTAssertEqual(customCalculator.scrollDistance(textWidth: 400), 420)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
func testScrollDistance_zeroSpacing() {
|
|
55
|
+
let noSpacingCalculator = MarqueeAnimationCalculator(spacing: 0)
|
|
56
|
+
XCTAssertEqual(noSpacingCalculator.scrollDistance(textWidth: 400), 400)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func testScrollDistance_zeroTextWidth() {
|
|
60
|
+
XCTAssertEqual(calculator.scrollDistance(textWidth: 0), 40)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// MARK: - animationDuration Tests
|
|
64
|
+
|
|
65
|
+
func testAnimationDuration_defaultSpeed() {
|
|
66
|
+
XCTAssertEqual(calculator.animationDuration(for: 300), 10.0, accuracy: 0.001)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func testAnimationDuration_customSpeed() {
|
|
70
|
+
let fastCalculator = MarqueeAnimationCalculator(pixelsPerSecond: 60)
|
|
71
|
+
XCTAssertEqual(fastCalculator.animationDuration(for: 300), 5.0, accuracy: 0.001)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func testAnimationDuration_zeroDistance() {
|
|
75
|
+
XCTAssertEqual(calculator.animationDuration(for: 0), 0.0, accuracy: 0.001)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
func testAnimationDuration_largeDistance() {
|
|
79
|
+
XCTAssertEqual(calculator.animationDuration(for: 3000), 100.0, accuracy: 0.001)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// MARK: - Integration Tests
|
|
83
|
+
|
|
84
|
+
func testFullCalculation_typicalLongTitle() {
|
|
85
|
+
let textWidth: CGFloat = 500
|
|
86
|
+
let containerWidth: CGFloat = 280
|
|
87
|
+
|
|
88
|
+
XCTAssertTrue(calculator.shouldScroll(textWidth: textWidth, containerWidth: containerWidth))
|
|
89
|
+
|
|
90
|
+
let distance = calculator.scrollDistance(textWidth: textWidth)
|
|
91
|
+
XCTAssertEqual(distance, 540)
|
|
92
|
+
|
|
93
|
+
let duration = calculator.animationDuration(for: distance)
|
|
94
|
+
XCTAssertEqual(duration, 18.0, accuracy: 0.001)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func testFullCalculation_shortTitle() {
|
|
98
|
+
let textWidth: CGFloat = 200
|
|
99
|
+
let containerWidth: CGFloat = 280
|
|
100
|
+
|
|
101
|
+
XCTAssertFalse(calculator.shouldScroll(textWidth: textWidth, containerWidth: containerWidth))
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
|
|
3
|
+
#if os(tvOS)
|
|
4
|
+
|
|
5
|
+
/// Unit tests for SearchResultItem data model
|
|
6
|
+
final class SearchResultItemTests: XCTestCase {
|
|
7
|
+
|
|
8
|
+
// MARK: - Initialization Tests
|
|
9
|
+
|
|
10
|
+
func testInit_allProperties() {
|
|
11
|
+
let item = SearchResultItem(
|
|
12
|
+
id: "123",
|
|
13
|
+
title: "Test Movie",
|
|
14
|
+
subtitle: "2024",
|
|
15
|
+
imageUrl: "https://example.com/poster.jpg"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
XCTAssertEqual(item.id, "123")
|
|
19
|
+
XCTAssertEqual(item.title, "Test Movie")
|
|
20
|
+
XCTAssertEqual(item.subtitle, "2024")
|
|
21
|
+
XCTAssertEqual(item.imageUrl, "https://example.com/poster.jpg")
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
func testInit_minimalProperties() {
|
|
25
|
+
let item = SearchResultItem(
|
|
26
|
+
id: "456",
|
|
27
|
+
title: "Minimal Movie",
|
|
28
|
+
subtitle: nil,
|
|
29
|
+
imageUrl: nil
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
XCTAssertEqual(item.id, "456")
|
|
33
|
+
XCTAssertEqual(item.title, "Minimal Movie")
|
|
34
|
+
XCTAssertNil(item.subtitle)
|
|
35
|
+
XCTAssertNil(item.imageUrl)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// MARK: - Identifiable Conformance
|
|
39
|
+
|
|
40
|
+
func testIdentifiable_idProperty() {
|
|
41
|
+
let item = SearchResultItem(
|
|
42
|
+
id: "unique-123",
|
|
43
|
+
title: "Title",
|
|
44
|
+
subtitle: nil,
|
|
45
|
+
imageUrl: nil
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
XCTAssertEqual(item.id, "unique-123")
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func testIdentifiable_emptyId() {
|
|
52
|
+
let item = SearchResultItem(
|
|
53
|
+
id: "",
|
|
54
|
+
title: "Title",
|
|
55
|
+
subtitle: nil,
|
|
56
|
+
imageUrl: nil
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
XCTAssertEqual(item.id, "")
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// MARK: - Equatable Conformance
|
|
63
|
+
|
|
64
|
+
func testEquality_sameValues_areEqual() {
|
|
65
|
+
let item1 = SearchResultItem(
|
|
66
|
+
id: "1",
|
|
67
|
+
title: "Movie",
|
|
68
|
+
subtitle: "2024",
|
|
69
|
+
imageUrl: "https://example.com/img.jpg"
|
|
70
|
+
)
|
|
71
|
+
let item2 = SearchResultItem(
|
|
72
|
+
id: "1",
|
|
73
|
+
title: "Movie",
|
|
74
|
+
subtitle: "2024",
|
|
75
|
+
imageUrl: "https://example.com/img.jpg"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
XCTAssertEqual(item1, item2)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
func testEquality_differentId_notEqual() {
|
|
82
|
+
let item1 = SearchResultItem(id: "1", title: "Movie", subtitle: nil, imageUrl: nil)
|
|
83
|
+
let item2 = SearchResultItem(id: "2", title: "Movie", subtitle: nil, imageUrl: nil)
|
|
84
|
+
|
|
85
|
+
XCTAssertNotEqual(item1, item2)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
func testEquality_differentTitle_notEqual() {
|
|
89
|
+
let item1 = SearchResultItem(id: "1", title: "Movie A", subtitle: nil, imageUrl: nil)
|
|
90
|
+
let item2 = SearchResultItem(id: "1", title: "Movie B", subtitle: nil, imageUrl: nil)
|
|
91
|
+
|
|
92
|
+
XCTAssertNotEqual(item1, item2)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
func testEquality_differentSubtitle_notEqual() {
|
|
96
|
+
let item1 = SearchResultItem(id: "1", title: "Movie", subtitle: "2023", imageUrl: nil)
|
|
97
|
+
let item2 = SearchResultItem(id: "1", title: "Movie", subtitle: "2024", imageUrl: nil)
|
|
98
|
+
|
|
99
|
+
XCTAssertNotEqual(item1, item2)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
func testEquality_differentImageUrl_notEqual() {
|
|
103
|
+
let item1 = SearchResultItem(id: "1", title: "Movie", subtitle: nil, imageUrl: "url1")
|
|
104
|
+
let item2 = SearchResultItem(id: "1", title: "Movie", subtitle: nil, imageUrl: "url2")
|
|
105
|
+
|
|
106
|
+
XCTAssertNotEqual(item1, item2)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
func testEquality_nilVsValue_notEqual() {
|
|
110
|
+
let item1 = SearchResultItem(id: "1", title: "Movie", subtitle: nil, imageUrl: nil)
|
|
111
|
+
let item2 = SearchResultItem(id: "1", title: "Movie", subtitle: "2024", imageUrl: nil)
|
|
112
|
+
|
|
113
|
+
XCTAssertNotEqual(item1, item2)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
func testEquality_bothNil_areEqual() {
|
|
117
|
+
let item1 = SearchResultItem(id: "1", title: "Movie", subtitle: nil, imageUrl: nil)
|
|
118
|
+
let item2 = SearchResultItem(id: "1", title: "Movie", subtitle: nil, imageUrl: nil)
|
|
119
|
+
|
|
120
|
+
XCTAssertEqual(item1, item2)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// MARK: - Optional Properties Tests
|
|
124
|
+
|
|
125
|
+
func testOptionalProperties_nilSubtitle() {
|
|
126
|
+
let item = SearchResultItem(
|
|
127
|
+
id: "1",
|
|
128
|
+
title: "Title",
|
|
129
|
+
subtitle: nil,
|
|
130
|
+
imageUrl: "https://example.com"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
XCTAssertNil(item.subtitle)
|
|
134
|
+
XCTAssertNotNil(item.imageUrl)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
func testOptionalProperties_nilImageUrl() {
|
|
138
|
+
let item = SearchResultItem(
|
|
139
|
+
id: "1",
|
|
140
|
+
title: "Title",
|
|
141
|
+
subtitle: "Subtitle",
|
|
142
|
+
imageUrl: nil
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
XCTAssertNotNil(item.subtitle)
|
|
146
|
+
XCTAssertNil(item.imageUrl)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
func testOptionalProperties_emptyStringSubtitle() {
|
|
150
|
+
let item = SearchResultItem(
|
|
151
|
+
id: "1",
|
|
152
|
+
title: "Title",
|
|
153
|
+
subtitle: "",
|
|
154
|
+
imageUrl: nil
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
XCTAssertNotNil(item.subtitle)
|
|
158
|
+
XCTAssertEqual(item.subtitle, "")
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// MARK: - Edge Cases
|
|
162
|
+
|
|
163
|
+
func testLongTitle() {
|
|
164
|
+
let longTitle = String(repeating: "A", count: 500)
|
|
165
|
+
let item = SearchResultItem(
|
|
166
|
+
id: "1",
|
|
167
|
+
title: longTitle,
|
|
168
|
+
subtitle: nil,
|
|
169
|
+
imageUrl: nil
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
XCTAssertEqual(item.title.count, 500)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
func testSpecialCharactersInTitle() {
|
|
176
|
+
let specialTitle = "Movie: The Sequel (2024) - Director's Cut [4K]"
|
|
177
|
+
let item = SearchResultItem(
|
|
178
|
+
id: "1",
|
|
179
|
+
title: specialTitle,
|
|
180
|
+
subtitle: nil,
|
|
181
|
+
imageUrl: nil
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
XCTAssertEqual(item.title, specialTitle)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
func testUnicodeTitle() {
|
|
188
|
+
let unicodeTitle = "映画 🎬 Film"
|
|
189
|
+
let item = SearchResultItem(
|
|
190
|
+
id: "1",
|
|
191
|
+
title: unicodeTitle,
|
|
192
|
+
subtitle: nil,
|
|
193
|
+
imageUrl: nil
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
XCTAssertEqual(item.title, unicodeTitle)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
#endif
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
|
|
3
|
+
#if os(tvOS)
|
|
4
|
+
|
|
5
|
+
/// Unit tests for SearchViewModel
|
|
6
|
+
final class SearchViewModelTests: XCTestCase {
|
|
7
|
+
var viewModel: SearchViewModel!
|
|
8
|
+
|
|
9
|
+
override func setUp() {
|
|
10
|
+
super.setUp()
|
|
11
|
+
viewModel = SearchViewModel()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
override func tearDown() {
|
|
15
|
+
viewModel = nil
|
|
16
|
+
super.tearDown()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// MARK: - Initial State Tests
|
|
20
|
+
|
|
21
|
+
func testInitialState_resultsEmpty() {
|
|
22
|
+
XCTAssertTrue(viewModel.results.isEmpty)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
func testInitialState_isLoadingFalse() {
|
|
26
|
+
XCTAssertFalse(viewModel.isLoading)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func testInitialState_searchTextEmpty() {
|
|
30
|
+
XCTAssertEqual(viewModel.searchText, "")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func testInitialState_defaultColumns() {
|
|
34
|
+
XCTAssertEqual(viewModel.columns, 5)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func testInitialState_defaultPlaceholder() {
|
|
38
|
+
XCTAssertEqual(viewModel.placeholder, "Search movies and videos...")
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func testInitialState_showTitleFalse() {
|
|
42
|
+
XCTAssertFalse(viewModel.showTitle)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
func testInitialState_showSubtitleFalse() {
|
|
46
|
+
XCTAssertFalse(viewModel.showSubtitle)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func testInitialState_showFocusBorderFalse() {
|
|
50
|
+
XCTAssertFalse(viewModel.showFocusBorder)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
func testInitialState_topInsetZero() {
|
|
54
|
+
XCTAssertEqual(viewModel.topInset, 0)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// MARK: - Title Overlay Config Initial State
|
|
58
|
+
|
|
59
|
+
func testInitialState_showTitleOverlayTrue() {
|
|
60
|
+
XCTAssertTrue(viewModel.showTitleOverlay)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
func testInitialState_enableMarqueeTrue() {
|
|
64
|
+
XCTAssertTrue(viewModel.enableMarquee)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func testInitialState_marqueeDelayDefault() {
|
|
68
|
+
XCTAssertEqual(viewModel.marqueeDelay, 1.5, accuracy: 0.001)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// MARK: - Property Updates
|
|
72
|
+
|
|
73
|
+
func testColumnsUpdate() {
|
|
74
|
+
viewModel.columns = 3
|
|
75
|
+
XCTAssertEqual(viewModel.columns, 3)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
func testPlaceholderUpdate() {
|
|
79
|
+
viewModel.placeholder = "Custom placeholder"
|
|
80
|
+
XCTAssertEqual(viewModel.placeholder, "Custom placeholder")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
func testShowTitleUpdate() {
|
|
84
|
+
viewModel.showTitle = true
|
|
85
|
+
XCTAssertTrue(viewModel.showTitle)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
func testShowSubtitleUpdate() {
|
|
89
|
+
viewModel.showSubtitle = true
|
|
90
|
+
XCTAssertTrue(viewModel.showSubtitle)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
func testShowFocusBorderUpdate() {
|
|
94
|
+
viewModel.showFocusBorder = true
|
|
95
|
+
XCTAssertTrue(viewModel.showFocusBorder)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
func testTopInsetUpdate() {
|
|
99
|
+
viewModel.topInset = 100
|
|
100
|
+
XCTAssertEqual(viewModel.topInset, 100)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// MARK: - Title Overlay Config Updates
|
|
104
|
+
|
|
105
|
+
func testShowTitleOverlayUpdate() {
|
|
106
|
+
viewModel.showTitleOverlay = false
|
|
107
|
+
XCTAssertFalse(viewModel.showTitleOverlay)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
func testEnableMarqueeUpdate() {
|
|
111
|
+
viewModel.enableMarquee = false
|
|
112
|
+
XCTAssertFalse(viewModel.enableMarquee)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
func testMarqueeDelayUpdate() {
|
|
116
|
+
viewModel.marqueeDelay = 2.5
|
|
117
|
+
XCTAssertEqual(viewModel.marqueeDelay, 2.5, accuracy: 0.001)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
func testMarqueeDelayUpdate_zeroValue() {
|
|
121
|
+
viewModel.marqueeDelay = 0
|
|
122
|
+
XCTAssertEqual(viewModel.marqueeDelay, 0, accuracy: 0.001)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// MARK: - Results Tests
|
|
126
|
+
|
|
127
|
+
func testResultsUpdate() {
|
|
128
|
+
let item = SearchResultItem(id: "1", title: "Test Movie", subtitle: "2024", imageUrl: nil)
|
|
129
|
+
viewModel.results = [item]
|
|
130
|
+
|
|
131
|
+
XCTAssertEqual(viewModel.results.count, 1)
|
|
132
|
+
XCTAssertEqual(viewModel.results.first?.title, "Test Movie")
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
func testResultsClear() {
|
|
136
|
+
let item = SearchResultItem(id: "1", title: "Test Movie", subtitle: nil, imageUrl: nil)
|
|
137
|
+
viewModel.results = [item]
|
|
138
|
+
viewModel.results = []
|
|
139
|
+
|
|
140
|
+
XCTAssertTrue(viewModel.results.isEmpty)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// MARK: - Callback Tests
|
|
144
|
+
|
|
145
|
+
func testOnSearchCallback() {
|
|
146
|
+
var capturedQuery: String?
|
|
147
|
+
viewModel.onSearch = { query in
|
|
148
|
+
capturedQuery = query
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
viewModel.onSearch?("test query")
|
|
152
|
+
|
|
153
|
+
XCTAssertEqual(capturedQuery, "test query")
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
func testOnSelectItemCallback() {
|
|
157
|
+
var capturedId: String?
|
|
158
|
+
viewModel.onSelectItem = { id in
|
|
159
|
+
capturedId = id
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
viewModel.onSelectItem?("item-123")
|
|
163
|
+
|
|
164
|
+
XCTAssertEqual(capturedId, "item-123")
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
func testOnSearchCallback_emptyQuery() {
|
|
168
|
+
var capturedQuery: String?
|
|
169
|
+
viewModel.onSearch = { query in
|
|
170
|
+
capturedQuery = query
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
viewModel.onSearch?("")
|
|
174
|
+
|
|
175
|
+
XCTAssertEqual(capturedQuery, "")
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
func testCallbacksNil_noError() {
|
|
179
|
+
viewModel.onSearch = nil
|
|
180
|
+
viewModel.onSelectItem = nil
|
|
181
|
+
|
|
182
|
+
viewModel.onSearch?("test")
|
|
183
|
+
viewModel.onSelectItem?("test")
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// MARK: - Published Property Tests
|
|
187
|
+
|
|
188
|
+
func testIsLoadingPublished() {
|
|
189
|
+
viewModel.isLoading = true
|
|
190
|
+
XCTAssertTrue(viewModel.isLoading)
|
|
191
|
+
|
|
192
|
+
viewModel.isLoading = false
|
|
193
|
+
XCTAssertFalse(viewModel.isLoading)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
func testSearchTextPublished() {
|
|
197
|
+
viewModel.searchText = "action"
|
|
198
|
+
XCTAssertEqual(viewModel.searchText, "action")
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
#endif
|