-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathNotificationPhraseTests.swift
More file actions
124 lines (117 loc) · 6.46 KB
/
Copy pathNotificationPhraseTests.swift
File metadata and controls
124 lines (117 loc) · 6.46 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
import XCTest
@testable import YameteCore
@testable import ResponseKit
/// Notifications use an `Events.strings` table keyed by reaction kind to look
/// up `title_<kind>_<n>` / `body_<kind>_<n>` pools. If a kind is missing from
/// the table the responder falls back to the kind raw-value as title and an
/// empty body — which still posts but ships an embarrassingly broken UI.
///
/// In the SPM test bundle there are no `.lproj` resources, so the loader is
/// driven via the test seam (`NotificationPhrase._testInject`). The seam only
/// covers the impact-tier pools, not the event pools, so for non-impact kinds
/// we assert the documented fallback shape: title falls back to `key`, body
/// falls back to empty. For `.impact`, we inject pools and assert both fields
/// resolve non-empty.
@MainActor
final class NotificationPhraseTests: XCTestCase {
override func setUp() {
super.setUp()
NotificationPhrase._testClear()
}
override func tearDown() {
NotificationPhrase._testClear()
super.tearDown()
}
/// Impact tiers route through `Moans.strings` pools. With pools injected,
/// both title and body must resolve to a non-empty string for every tier.
func testImpactPhrasingNonEmptyForEveryTier() {
// Inject one entry per pool for both prefixes × every tier.
var pools: [String: [String]] = [:]
for slug in ["tap", "light", "medium", "firm", "hard"] {
pools["title_\(slug)"] = ["title-\(slug)"]
pools["moan_\(slug)"] = ["moan-\(slug)"]
}
NotificationPhrase._testInject(pools: pools, for: "en")
let intensities: [Float] = [0.05, 0.25, 0.5, 0.75, 1.0]
for intensity in intensities {
let reaction = Reaction.impact(FusedImpact(
timestamp: Date(), intensity: intensity, confidence: 1.0, sources: []
))
let phrase = NotificationPhrase.phrasing(for: reaction, preferredLocale: "en")
XCTAssertFalse(phrase.title.isEmpty, "impact intensity=\(intensity) title must be non-empty")
XCTAssertFalse(phrase.body.isEmpty, "impact intensity=\(intensity) body must be non-empty")
}
}
/// Documented fallback for non-impact kinds when no `Events.strings`
/// resource is available: title falls back to the raw-value key string,
/// so the title field is always non-empty and the kind is recognizable
/// in the banner. The body is empty under the fallback. This locks the
/// kind→key mapping so a missing dispatch case can't silently produce a
/// blank banner.
///
/// The fallback path only runs when both `eventPools(for:preferred)`
/// and `eventPools(for:fallback)` come back empty. Under SPM the test
/// bundle has no `.lproj` resources so this is automatic; under
/// host-app `Bundle.main` is the real `Yamete.app` which ships
/// `Events.strings`, and without intervention the loader hands back
/// authored strings instead. `_testClearAndDisableLoad` short-circuits
/// the bundle-driven loader so the fallback is exercised under both
/// build environments.
func testEventFallbackUsesKindRawValueAsTitle() {
NotificationPhrase._testClearAndDisableLoad()
for kind in ReactionKind.allCases where kind != .impact {
let reaction = ReactionForKind.make(kind: kind)
let phrase = NotificationPhrase.phrasing(for: reaction, preferredLocale: "en")
XCTAssertEqual(phrase.title, kind.rawValue,
"[\(kind.rawValue)] title must fall back to raw-value when Events.strings has no entry")
// Body is documented as fallback-empty; the assertion locks that
// contract so future regressions to "nil" or non-string don't
// pass silently.
XCTAssertEqual(phrase.body, "",
"[\(kind.rawValue)] body must be empty when Events.strings has no entry")
}
}
}
/// Tiny domain helper: build a synthetic `Reaction` for any kind. Owned by a
/// nested type rather than a free function per the global rule.
enum ReactionForKind {
@MainActor
static func make(kind: ReactionKind) -> Reaction {
switch kind {
case .impact:
return .impact(FusedImpact(timestamp: Date(), intensity: 0.5, confidence: 1.0, sources: []))
case .usbAttached: return .usbAttached(.init(name: "n", vendorID: 0, productID: 0))
case .usbDetached: return .usbDetached(.init(name: "n", vendorID: 0, productID: 0))
case .acConnected: return .acConnected
case .acDisconnected: return .acDisconnected
case .audioPeripheralAttached: return .audioPeripheralAttached(.init(uid: "u", name: "n"))
case .audioPeripheralDetached: return .audioPeripheralDetached(.init(uid: "u", name: "n"))
case .bluetoothConnected: return .bluetoothConnected(.init(address: "a", name: "n"))
case .bluetoothDisconnected: return .bluetoothDisconnected(.init(address: "a", name: "n"))
case .thunderboltAttached: return .thunderboltAttached(.init(name: "n"))
case .thunderboltDetached: return .thunderboltDetached(.init(name: "n"))
case .displayConfigured: return .displayConfigured
case .willSleep: return .willSleep
case .didWake: return .didWake
case .trackpadTouching: return .trackpadTouching
case .trackpadSliding: return .trackpadSliding
case .trackpadContact: return .trackpadContact
case .trackpadTapping: return .trackpadTapping
case .trackpadCircling: return .trackpadCircling
case .mouseClicked: return .mouseClicked
case .mouseScrolled: return .mouseScrolled
case .keyboardTyped: return .keyboardTyped
case .gyroSpike: return .gyroSpike
case .lidOpened: return .lidOpened
case .lidClosed: return .lidClosed
case .lidSlammed: return .lidSlammed
case .alsCovered: return .alsCovered
case .lightsOff: return .lightsOff
case .lightsOn: return .lightsOn
case .thermalNominal: return .thermalNominal
case .thermalFair: return .thermalFair
case .thermalSerious: return .thermalSerious
case .thermalCritical: return .thermalCritical
}
}
}