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.
@@ -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