main
swift 231 lines 7.76 KB
Raw
1 import Foundation
2 import Testing
3
4 @testable import Klepon
5
6 // MARK: - Fixtures
7
8 extension GuideEntry {
9 /// Builds a `GuideEntry` with sensible defaults so tests only specify the
10 /// fields they care about.
11 static func fixture(
12 id: String = "klepon",
13 kind: Kind = .dish,
14 title: String = "Klepon",
15 subtitle: String = "Palm sugar-filled rice cake rolled in coconut",
16 summary: String = "A soft, sweet bite with a warm syrupy center.",
17 story: String = "Klepon is a beloved Indonesian snack made from glutinous rice flour.",
18 tasteNotes: [String] = ["Sweet", "Chewy", "Coconut"],
19 highlights: [String] = ["Palm sugar center", "Fresh coconut coating"],
20 region: String? = "Java",
21 aliases: [String] = ["Onde-onde kecil"],
22 tags: [String] = ["Snack", "Dessert"],
23 relatedIDs: [String] = [],
24 suggestedQuestions: [String] = ["What does klepon taste like?"],
25 imageName: String? = nil,
26 isFeatured: Bool = true
27 ) -> GuideEntry {
28 GuideEntry(
29 id: id,
30 kind: kind,
31 title: title,
32 subtitle: subtitle,
33 summary: summary,
34 story: story,
35 tasteNotes: tasteNotes,
36 highlights: highlights,
37 region: region,
38 aliases: aliases,
39 tags: tags,
40 relatedIDs: relatedIDs,
41 suggestedQuestions: suggestedQuestions,
42 imageName: imageName,
43 isFeatured: isFeatured
44 )
45 }
46 }
47
48 /// A test double for the on-device guide engine. Lets us exercise
49 /// `GuideAnswerService` without the Rust/UniFFI inference runtime.
50 final class StubGuideEngine: GuideAnswering {
51 enum Behavior {
52 case success(String)
53 case failure
54 }
55
56 let behavior: Behavior
57 private(set) var receivedPrompts: [String] = []
58
59 init(behavior: Behavior) {
60 self.behavior = behavior
61 }
62
63 func answer(prompt: String) async throws -> String {
64 receivedPrompts.append(prompt)
65 switch behavior {
66 case .success(let text):
67 return text
68 case .failure:
69 throw OndeGuideError.unavailable
70 }
71 }
72 }
73
74 // MARK: - GuideAnswerService
75
76 struct GuideAnswerServiceTests {
77 private func makeService(behavior: StubGuideEngine.Behavior) -> (
78 service: GuideAnswerService, engine: StubGuideEngine
79 ) {
80 let entry = GuideEntry.fixture()
81 let repository = ContentRepository(entries: [entry], collections: [])
82 let engine = StubGuideEngine(behavior: behavior)
83 return (GuideAnswerService(repository: repository, guideEngine: engine), engine)
84 }
85
86 @Test
87 func usesEngineAnswerWhenAlreadyStructured() async {
88 let structured = """
89 Short answer: It is sweet.
90 What to expect: Coconut and palm sugar.
91 Try next: Onde-onde.
92 """
93 let (service, engine) = makeService(behavior: .success(structured))
94
95 let card = await service.answer(question: "How does it taste?", about: .fixture())
96
97 #expect(card.isGeneratedOnDevice)
98 #expect(card.body == structured)
99 #expect(engine.receivedPrompts.count == 1)
100 #expect(engine.receivedPrompts[0].contains("User question: How does it taste?"))
101 }
102
103 @Test
104 func wrapsUnstructuredEngineAnswerIntoSections() async {
105 let (service, _) = makeService(behavior: .success("Just a freeform sentence."))
106
107 let card = await service.answer(question: "Tell me about it", about: .fixture())
108
109 #expect(card.isGeneratedOnDevice)
110 #expect(card.body.contains("Short answer: Just a freeform sentence."))
111 #expect(card.body.contains("What to expect:"))
112 #expect(card.body.contains("Try next:"))
113 }
114
115 @Test
116 func fallsBackWhenEngineThrows() async {
117 let (service, _) = makeService(behavior: .failure)
118
119 let card = await service.answer(question: "Anything?", about: .fixture())
120
121 #expect(card.isGeneratedOnDevice == false)
122 #expect(card.body.contains("Short answer:"))
123 #expect(card.body.contains("What to expect:"))
124 #expect(card.body.contains("Try next:"))
125 }
126
127 @Test
128 func followUpSuggestionsExcludeTheAskedQuestion() async {
129 let entry = GuideEntry.fixture(
130 suggestedQuestions: ["What does klepon taste like?", "Where is it from?"])
131 let repository = ContentRepository(entries: [entry], collections: [])
132 let service = GuideAnswerService(
133 repository: repository, guideEngine: StubGuideEngine(behavior: .failure))
134
135 let card = await service.answer(
136 question: "What does klepon taste like?", about: entry)
137
138 #expect(!card.followUpSuggestions.contains("What does klepon taste like?"))
139 #expect(card.followUpSuggestions.contains("Where is it from?"))
140 }
141 }
142
143 // MARK: - SearchService
144
145 struct SearchServiceTests {
146 private func makeService(entries: [GuideEntry]) -> SearchService {
147 SearchService(repository: ContentRepository(entries: entries, collections: []))
148 }
149
150 @Test
151 func emptyQueryReturnsNoResults() {
152 let service = makeService(entries: [.fixture()])
153 #expect(service.results(for: " ").isEmpty)
154 }
155
156 @Test
157 func exactTitleMatchOutranksSubtitleMatch() {
158 let exact = GuideEntry.fixture(
159 id: "rendang", title: "Rendang", subtitle: "Slow-cooked beef", summary: "Rich curry",
160 aliases: [], tags: [], suggestedQuestions: [])
161 let subtitleOnly = GuideEntry.fixture(
162 id: "other", title: "Sambal", subtitle: "Goes well with Rendang", summary: "Chili paste",
163 aliases: [], tags: [], suggestedQuestions: [])
164 let service = makeService(entries: [subtitleOnly, exact])
165
166 let results = service.results(for: "Rendang")
167
168 #expect(results.first?.id == "rendang")
169 #expect(results.count == 2)
170 }
171
172 @Test
173 func unmatchedQueryReturnsEmpty() {
174 let service = makeService(entries: [.fixture()])
175 #expect(service.results(for: "zzz-no-match").isEmpty)
176 }
177 }
178
179 // MARK: - ContentRepository
180
181 struct ContentRepositoryTests {
182 @Test
183 func looksUpEntriesByIDAndRelations() {
184 let related = GuideEntry.fixture(id: "coconut", title: "Coconut")
185 let entry = GuideEntry.fixture(id: "klepon", relatedIDs: ["coconut"])
186 let repository = ContentRepository(entries: [entry, related], collections: [])
187
188 #expect(repository.entry(id: "klepon")?.title == "Klepon")
189 #expect(repository.entry(id: "missing") == nil)
190 #expect(repository.relatedEntries(for: entry).map(\.id) == ["coconut"])
191 }
192
193 @Test
194 func featuredEntriesFilterByFlag() {
195 let featured = GuideEntry.fixture(id: "a", isFeatured: true)
196 let hidden = GuideEntry.fixture(id: "b", isFeatured: false)
197 let repository = ContentRepository(entries: [featured, hidden], collections: [])
198
199 #expect(repository.featuredEntries.map(\.id) == ["a"])
200 }
201 }
202
203 // MARK: - FavoritesStore
204
205 @MainActor
206 struct FavoritesStoreTests {
207 private func makeDefaults() -> UserDefaults {
208 let suiteName = "klepon.tests.favorites"
209 let defaults = UserDefaults(suiteName: suiteName)!
210 defaults.removePersistentDomain(forName: suiteName)
211 return defaults
212 }
213
214 @Test
215 func toggleAddsThenRemovesAndPersists() {
216 let defaults = makeDefaults()
217 let store = FavoritesStore(defaults: defaults)
218
219 #expect(store.isFavorite("klepon") == false)
220
221 store.toggle("klepon")
222 #expect(store.isFavorite("klepon"))
223
224 // A fresh store reading the same defaults should see the persisted value.
225 let reloaded = FavoritesStore(defaults: defaults)
226 #expect(reloaded.isFavorite("klepon"))
227
228 store.toggle("klepon")
229 #expect(store.isFavorite("klepon") == false)
230 }
231 }