| 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 | } |