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