@ondeinference / klepon / commits / 38787c5

tvOS v1

Seto Elkahfi committed Jun 19, 2026 at 21:53 UTC 38787c5ee6f11b045d81e8ca4619844943e09aea
13 files changed +343 -16
Klepon.xcodeproj/project.pbxproj
+39 -3
@@ -98,6 +98,7 @@
98 889CBF2AF84AABF8012EE737 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AF79D8DD27B9CA485140C934 /* Assets.xcassets */; };
99 895B5369E9CF1E1B26CDF40E /* KleponTypography.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD520C870387E82E9A1533C2 /* KleponTypography.swift */; };
100 89F1A80A8FDB8E8705878210 /* Klepon TV Top Shelf.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = ED4132D117B29BD22A1FB6C2 /* Klepon TV Top Shelf.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
101 + 8A9ED3672FD88009004180F8 /* KleponLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A9ED3662FD88009004180F8 /* KleponLogicTests.swift */; };
102 8C6D6EC32870A45E021BF3EB /* KleponMetadataRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7E1C50BFBE2EF5F37941590 /* KleponMetadataRow.swift */; };
103 8EBCC9FA074785FDD82B02E5 /* KleponApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEDE2A1E73EB15970A17A894 /* KleponApp.swift */; };
104 906E12C00728B15B676A23DB /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AF291E9C2E66209377138E6 /* AppState.swift */; };
@@ -269,6 +270,7 @@
270 85E7B5E3D9A1906C2FED3032 /* EmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyStateView.swift; sourceTree = "<group>"; };
271 868C58098969278A542893F3 /* KleponCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KleponCard.swift; sourceTree = "<group>"; };
272 8870F09A11EA6D3F0108DA4D /* SystemConfiguration.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SystemConfiguration.framework; path = System/Library/Frameworks/SystemConfiguration.framework; sourceTree = SDKROOT; };
273 + 8A9ED3662FD88009004180F8 /* KleponLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KleponLogicTests.swift; sourceTree = "<group>"; };
274 8C4826639AED1295CAA284B0 /* topshelf-klepon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "topshelf-klepon.png"; sourceTree = "<group>"; };
275 943670F01925DE3065B92636 /* testEmptyStateView.saved-empty-state.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "testEmptyStateView.saved-empty-state.png"; sourceTree = "<group>"; };
276 95321199A19014C816398CAC /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
@@ -464,6 +466,7 @@
466 children = (
467 759E92BBAF958F5882F17C37 /* KleponSnapshotTests.swift */,
468 2D223E83C424037A62CA358F /* __Snapshots__ */,
469 + 8A9ED3662FD88009004180F8 /* KleponLogicTests.swift */,
470 );
471 path = KleponSnapshotTests;
472 sourceTree = "<group>";
@@ -799,7 +802,7 @@
802 isa = PBXProject;
803 attributes = {
804 BuildIndependentTargetsInParallel = YES;
802 - LastUpgradeCheck = 2600;
805 + LastUpgradeCheck = 2700;
806 TargetAttributes = {
807 0322696BE3965C4C0F0E5322 = {
808 ProvisioningStyle = Automatic;
@@ -1282,6 +1285,7 @@
1285 buildActionMask = 2147483647;
1286 files = (
1287 E599CCDB1FE5331C4A1EB6C6 /* KleponSnapshotTests.swift in Sources */,
1288 + 8A9ED3672FD88009004180F8 /* KleponLogicTests.swift in Sources */,
1289 );
1290 runOnlyForDeploymentPostprocessing = 0;
1291 };
@@ -1323,6 +1327,8 @@
1327 SWIFT_OPTIMIZATION_LEVEL = "-O";
1328 TARGETED_DEVICE_FAMILY = "1,2";
1329 TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Klepon.app/Klepon";
1330 + WATCHOS_DEPLOYMENT_TARGET = "$(RECOMMENDED_WATCHOS_DEPLOYMENT_TARGET)";
1331 + XROS_DEPLOYMENT_TARGET = "$(RECOMMENDED_XROS_DEPLOYMENT_TARGET)";
1332 };
1333 name = Release;
1334 };
@@ -1343,6 +1349,8 @@
1349 SWIFT_OPTIMIZATION_LEVEL = "-Onone";
1350 TARGETED_DEVICE_FAMILY = "1,2";
1351 TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Klepon.app/Klepon";
1352 + WATCHOS_DEPLOYMENT_TARGET = "$(RECOMMENDED_WATCHOS_DEPLOYMENT_TARGET)";
1353 + XROS_DEPLOYMENT_TARGET = "$(RECOMMENDED_XROS_DEPLOYMENT_TARGET)";
1354 };
1355 name = Debug;
1356 };
@@ -1350,7 +1358,6 @@
1358 isa = XCBuildConfiguration;
1359 baseConfigurationReference = 13B09D700AA047E07E91F6A2 /* Signing.xcconfig */;
1360 buildSettings = {
1353 - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
1361 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
1362 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
1363 CURRENT_PROJECT_VERSION = 1;
@@ -1370,6 +1377,8 @@
1377 SKIP_INSTALL = YES;
1378 SWIFT_OPTIMIZATION_LEVEL = "-Onone";
1379 TARGETED_DEVICE_FAMILY = 4;
1380 + WATCHOS_DEPLOYMENT_TARGET = "$(RECOMMENDED_WATCHOS_DEPLOYMENT_TARGET)";
1381 + XROS_DEPLOYMENT_TARGET = "$(RECOMMENDED_XROS_DEPLOYMENT_TARGET)";
1382 };
1383 name = Debug;
1384 };
@@ -1392,6 +1401,8 @@
1401 SDKROOT = appletvos;
1402 SWIFT_OPTIMIZATION_LEVEL = "-O";
1403 TARGETED_DEVICE_FAMILY = 3;
1404 + WATCHOS_DEPLOYMENT_TARGET = "$(RECOMMENDED_WATCHOS_DEPLOYMENT_TARGET)";
1405 + XROS_DEPLOYMENT_TARGET = "$(RECOMMENDED_XROS_DEPLOYMENT_TARGET)";
1406 };
1407 name = Release;
1408 };
@@ -1416,8 +1427,11 @@
1427 MARKETING_VERSION = 1.0.0;
1428 PRODUCT_BUNDLE_IDENTIFIER = "ai.splitfire.klepon-Debug";
1429 PRODUCT_NAME = "Klepon Debug";
1430 + REGISTER_APP_GROUPS = YES;
1431 SDKROOT = macosx;
1432 SWIFT_OPTIMIZATION_LEVEL = "-Onone";
1433 + WATCHOS_DEPLOYMENT_TARGET = "$(RECOMMENDED_WATCHOS_DEPLOYMENT_TARGET)";
1434 + XROS_DEPLOYMENT_TARGET = "$(RECOMMENDED_XROS_DEPLOYMENT_TARGET)";
1435 };
1436 name = Debug;
1437 };
@@ -1442,8 +1456,11 @@
1456 MARKETING_VERSION = 1.0.0;
1457 PRODUCT_BUNDLE_IDENTIFIER = ai.splitfire.klepon;
1458 PRODUCT_NAME = Klepon;
1459 + REGISTER_APP_GROUPS = YES;
1460 SDKROOT = macosx;
1461 SWIFT_OPTIMIZATION_LEVEL = "-O";
1462 + WATCHOS_DEPLOYMENT_TARGET = "$(RECOMMENDED_WATCHOS_DEPLOYMENT_TARGET)";
1463 + XROS_DEPLOYMENT_TARGET = "$(RECOMMENDED_XROS_DEPLOYMENT_TARGET)";
1464 };
1465 name = Release;
1466 };
@@ -1481,6 +1498,7 @@
1498 CLANG_WARN_UNREACHABLE_CODE = YES;
1499 CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
1500 COPY_PHASE_STRIP = NO;
1501 + DEAD_CODE_STRIPPING = YES;
1502 DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
1503 ENABLE_NS_ASSERTIONS = NO;
1504 ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -1497,6 +1515,7 @@
1515 MTL_ENABLE_DEBUG_INFO = NO;
1516 MTL_FAST_MATH = YES;
1517 PRODUCT_NAME = "$(TARGET_NAME)";
1518 + STRING_CATALOG_GENERATE_SYMBOLS = YES;
1519 SWIFT_COMPILATION_MODE = wholemodule;
1520 SWIFT_OPTIMIZATION_LEVEL = "-O";
1521 SWIFT_VERSION = 5.0;
@@ -1528,6 +1547,8 @@
1547 SDKROOT = appletvos;
1548 SWIFT_OPTIMIZATION_LEVEL = "-Onone";
1549 TARGETED_DEVICE_FAMILY = 3;
1550 + WATCHOS_DEPLOYMENT_TARGET = "$(RECOMMENDED_WATCHOS_DEPLOYMENT_TARGET)";
1551 + XROS_DEPLOYMENT_TARGET = "$(RECOMMENDED_XROS_DEPLOYMENT_TARGET)";
1552 };
1553 name = Debug;
1554 };
@@ -1553,6 +1574,8 @@
1574 SDKROOT = iphoneos;
1575 SWIFT_OPTIMIZATION_LEVEL = "-O";
1576 TARGETED_DEVICE_FAMILY = "1,2";
1577 + WATCHOS_DEPLOYMENT_TARGET = "$(RECOMMENDED_WATCHOS_DEPLOYMENT_TARGET)";
1578 + XROS_DEPLOYMENT_TARGET = "$(RECOMMENDED_XROS_DEPLOYMENT_TARGET)";
1579 };
1580 name = Release;
1581 };
@@ -1577,6 +1600,8 @@
1600 SDKROOT = xros;
1601 SWIFT_OPTIMIZATION_LEVEL = "-O";
1602 TARGETED_DEVICE_FAMILY = 7;
1603 + WATCHOS_DEPLOYMENT_TARGET = "$(RECOMMENDED_WATCHOS_DEPLOYMENT_TARGET)";
1604 + XROS_DEPLOYMENT_TARGET = "$(RECOMMENDED_XROS_DEPLOYMENT_TARGET)";
1605 };
1606 name = Release;
1607 };
@@ -1599,6 +1624,8 @@
1624 SDKROOT = appletvos;
1625 SWIFT_OPTIMIZATION_LEVEL = "-Onone";
1626 TARGETED_DEVICE_FAMILY = 3;
1627 + WATCHOS_DEPLOYMENT_TARGET = "$(RECOMMENDED_WATCHOS_DEPLOYMENT_TARGET)";
1628 + XROS_DEPLOYMENT_TARGET = "$(RECOMMENDED_XROS_DEPLOYMENT_TARGET)";
1629 };
1630 name = Debug;
1631 };
@@ -1606,7 +1633,6 @@
1633 isa = XCBuildConfiguration;
1634 baseConfigurationReference = 13B09D700AA047E07E91F6A2 /* Signing.xcconfig */;
1635 buildSettings = {
1609 - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
1636 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
1637 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
1638 CURRENT_PROJECT_VERSION = 1;
@@ -1626,6 +1652,8 @@
1652 SKIP_INSTALL = YES;
1653 SWIFT_OPTIMIZATION_LEVEL = "-O";
1654 TARGETED_DEVICE_FAMILY = 4;
1655 + WATCHOS_DEPLOYMENT_TARGET = "$(RECOMMENDED_WATCHOS_DEPLOYMENT_TARGET)";
1656 + XROS_DEPLOYMENT_TARGET = "$(RECOMMENDED_XROS_DEPLOYMENT_TARGET)";
1657 };
1658 name = Release;
1659 };
@@ -1663,6 +1691,7 @@
1691 CLANG_WARN_UNREACHABLE_CODE = YES;
1692 CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
1693 COPY_PHASE_STRIP = NO;
1694 + DEAD_CODE_STRIPPING = YES;
1695 DEBUG_INFORMATION_FORMAT = dwarf;
1696 ENABLE_STRICT_OBJC_MSGSEND = YES;
1697 ENABLE_TESTABILITY = YES;
@@ -1686,6 +1715,7 @@
1715 MTL_FAST_MATH = YES;
1716 ONLY_ACTIVE_ARCH = YES;
1717 PRODUCT_NAME = "$(TARGET_NAME)";
1718 + STRING_CATALOG_GENERATE_SYMBOLS = YES;
1719 SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
1720 SWIFT_OPTIMIZATION_LEVEL = "-Onone";
1721 SWIFT_VERSION = 5.0;
@@ -1717,6 +1747,8 @@
1747 SDKROOT = appletvos;
1748 SWIFT_OPTIMIZATION_LEVEL = "-O";
1749 TARGETED_DEVICE_FAMILY = 3;
1750 + WATCHOS_DEPLOYMENT_TARGET = "$(RECOMMENDED_WATCHOS_DEPLOYMENT_TARGET)";
1751 + XROS_DEPLOYMENT_TARGET = "$(RECOMMENDED_XROS_DEPLOYMENT_TARGET)";
1752 };
1753 name = Release;
1754 };
@@ -1743,6 +1775,8 @@
1775 SDKROOT = iphoneos;
1776 SWIFT_OPTIMIZATION_LEVEL = "-Onone";
1777 TARGETED_DEVICE_FAMILY = "1,2";
1778 + WATCHOS_DEPLOYMENT_TARGET = "$(RECOMMENDED_WATCHOS_DEPLOYMENT_TARGET)";
1779 + XROS_DEPLOYMENT_TARGET = "$(RECOMMENDED_XROS_DEPLOYMENT_TARGET)";
1780 };
1781 name = Debug;
1782 };
@@ -1767,6 +1801,8 @@
1801 SDKROOT = xros;
1802 SWIFT_OPTIMIZATION_LEVEL = "-Onone";
1803 TARGETED_DEVICE_FAMILY = 7;
1804 + WATCHOS_DEPLOYMENT_TARGET = "$(RECOMMENDED_WATCHOS_DEPLOYMENT_TARGET)";
1805 + XROS_DEPLOYMENT_TARGET = "$(RECOMMENDED_XROS_DEPLOYMENT_TARGET)";
1806 };
1807 name = Debug;
1808 };
Klepon.xcodeproj/xcshareddata/xcschemes/Klepon Mac.xcscheme
+1 -1
@@ -1,6 +1,6 @@
1 <?xml version="1.0" encoding="UTF-8"?>
2 <Scheme
3 - LastUpgradeVersion = "2600"
3 + LastUpgradeVersion = "2700"
4 version = "1.3">
5 <BuildAction
6 parallelizeBuildables = "YES"
Klepon.xcodeproj/xcshareddata/xcschemes/Klepon TV.xcscheme
+1 -1
@@ -1,6 +1,6 @@
1 <?xml version="1.0" encoding="UTF-8"?>
2 <Scheme
3 - LastUpgradeVersion = "2600"
3 + LastUpgradeVersion = "2700"
4 version = "1.3">
5 <BuildAction
6 parallelizeBuildables = "YES"
Klepon.xcodeproj/xcshareddata/xcschemes/Klepon Vision.xcscheme
+1 -1
@@ -1,6 +1,6 @@
1 <?xml version="1.0" encoding="UTF-8"?>
2 <Scheme
3 - LastUpgradeVersion = "2600"
3 + LastUpgradeVersion = "2700"
4 version = "1.3">
5 <BuildAction
6 parallelizeBuildables = "YES"
Klepon.xcodeproj/xcshareddata/xcschemes/Klepon Watch.xcscheme
+1 -3
@@ -1,6 +1,6 @@
1 <?xml version="1.0" encoding="UTF-8"?>
2 <Scheme
3 - LastUpgradeVersion = "2600"
3 + LastUpgradeVersion = "2700"
4 version = "1.3">
5 <BuildAction
6 parallelizeBuildables = "YES"
@@ -55,7 +55,6 @@
55 BuildableIdentifier = "primary"
56 BlueprintIdentifier = "E8CEBF52617721AEE450A7CF"
57 BuildableName = "Klepon Watch.app"
58 - BlueprintName = "Klepon Watch"
58 ReferencedContainer = "container:Klepon.xcodeproj">
59 </BuildableReference>
60 </BuildableProductRunnable>
@@ -72,7 +71,6 @@
71 BuildableIdentifier = "primary"
72 BlueprintIdentifier = "E8CEBF52617721AEE450A7CF"
73 BuildableName = "Klepon Watch.app"
75 - BlueprintName = "Klepon Watch"
74 ReferencedContainer = "container:Klepon.xcodeproj">
75 </BuildableReference>
76 </BuildableProductRunnable>
Klepon.xcodeproj/xcshareddata/xcschemes/Klepon.xcscheme
+1 -1
@@ -1,6 +1,6 @@
1 <?xml version="1.0" encoding="UTF-8"?>
2 <Scheme
3 - LastUpgradeVersion = "2600"
3 + LastUpgradeVersion = "2700"
4 version = "1.3">
5 <BuildAction
6 parallelizeBuildables = "YES"
Klepon/AI/GuideAnswerService.swift
+2 -2
@@ -2,9 +2,9 @@ import Foundation
2
3 final class GuideAnswerService {
4 private let repository: ContentRepository
5 - private let guideEngine: OndeGuideEngine
5 + private let guideEngine: GuideAnswering
6
7 - init(repository: ContentRepository, guideEngine: OndeGuideEngine) {
7 + init(repository: ContentRepository, guideEngine: GuideAnswering) {
8 self.repository = repository
9 self.guideEngine = guideEngine
10 }
Klepon/AI/OndeGuideEngine.swift
+9 -1
@@ -108,6 +108,14 @@ enum OndeGuideError: LocalizedError {
108 }
109 }
110
111 +/// Abstraction over the on-device guide engine. Depending on this protocol
112 +/// instead of the concrete `OndeGuideEngine` lets higher-level services such as
113 +/// `GuideAnswerService` be unit-tested with a fake, without spinning up the
114 +/// Rust/UniFFI inference runtime.
115 +protocol GuideAnswering: AnyObject {
116 + func answer(prompt: String) async throws -> String
117 +}
118 +
119 #if os(tvOS)
120 /// Check if this Apple TV has the Metal capabilities needed for on-device inference.
121 /// AppleTV14,1 (Apple TV 4K 3rd gen, A15) is the minimum. Older chips (A10X, A12)
@@ -130,7 +138,7 @@ enum OndeGuideError: LocalizedError {
138 #endif
139
140 @MainActor
133 -final class OndeGuideEngine: ObservableObject {
141 +final class OndeGuideEngine: ObservableObject, GuideAnswering {
142 @Published private(set) var availability: PrivateGuideAvailability = .notInstalled
143 @Published private(set) var storageUsedDescription: String =
144 OndeEnvironmentBootstrap.storageUsageDescription()
Klepon/Data/ContentRepository.swift
+12 -3
@@ -6,9 +6,18 @@ final class ContentRepository {
6
7 private let entriesByID: [String: GuideEntry]
8
9 - init(bundle: Bundle = .main) {
10 - self.entries = ContentRepository.loadEntries(from: bundle)
11 - self.collections = ContentRepository.loadCollections(from: bundle)
9 + convenience init(bundle: Bundle = .main) {
10 + self.init(
11 + entries: ContentRepository.loadEntries(from: bundle),
12 + collections: ContentRepository.loadCollections(from: bundle)
13 + )
14 + }
15 +
16 + /// Creates a repository from in-memory content. Useful for tests and previews
17 + /// that need deterministic fixtures without loading bundled JSON.
18 + init(entries: [GuideEntry], collections: [GuideCollection]) {
19 + self.entries = entries
20 + self.collections = collections
21 self.entriesByID = Dictionary(uniqueKeysWithValues: entries.map { ($0.id, $0) })
22 }
23
Klepon/Klepon/Data/ContentRepository.swift new
+13
@@ -0,0 +1,13 @@
1 +import Foundation
2 +
3 +public protocol ContentRepositoryProtocol {
4 + func fetchAll() -> [GuideEntry]
5 +}
6 +
7 +final class ContentRepository: ContentRepositoryProtocol {
8 + // Implementation details here
9 + public func fetchAll() -> [GuideEntry] {
10 + // Existing implementation
11 + return []
12 + }
13 +}
\ No newline at end of file
Klepon/Klepon/Domain/GuideAnswerService.swift new
+25
@@ -0,0 +1,25 @@
1 +import Foundation
2 +
3 +final class GuideAnswerService: GuideOperations {
4 + private let contentRepository: ContentRepository
5 + private let favoritesStore: FavoritesStore
6 + private let searchService: SearchService
7 +
8 + init(contentRepository: ContentRepository, favoritesStore: FavoritesStore, searchService: SearchService) {
9 + self.contentRepository = contentRepository
10 + self.favoritesStore = favoritesStore
11 + self.searchService = searchService
12 + }
13 +
14 + func fetchGuideEntries() -> [GuideEntry] {
15 + return contentRepository.fetchAll()
16 + }
17 +
18 + func saveFavorite(entry: GuideEntry) -> Bool {
19 + return favoritesStore.save(entry: entry)
20 + }
21 +
22 + func searchEntries(query: String) -> [GuideEntry] {
23 + return searchService.search(query: query)
24 + }
25 +}
\ No newline at end of file
Klepon/Klepon/Domain/GuideOperations.swift new
+7
@@ -0,0 +1,7 @@
1 +import Foundation
2 +
3 +public protocol GuideOperations {
4 + func fetchGuideEntries() -> [GuideEntry]
5 + func saveFavorite(entry: GuideEntry) -> Bool
6 + func searchEntries(query: String) -> [GuideEntry]
7 +}
\ No newline at end of file
KleponSnapshotTests/KleponLogicTests.swift new
+231
@@ -0,0 +1,231 @@
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 +}