Skip to content

Commit b829663

Browse files
committed
Refactor localization, add tests, update docs
Refactor Bundle localization lookup and PrivacyPolicy URL fallback; remove redundant Insights onChange. Add and expand unit/UI tests (CycleEnums, CycleManager, ExportService, NotificationSettingsViewLogic, SplashScreen, ViewBranch, CoverageRuntimeUITests) and adjust UI test timing/assertions. Update .gitignore to ignore xcresult files and coverage_export. Revise coverage and test docs and README to split unit & UI schemes, export/merge coverage reports, and update coverage snapshot and test scheme names to CycleOne-Unit. These changes improve localization resolution, test coverage reporting, and robustness of UI/test behavior.
1 parent 59958ea commit b829663

15 files changed

Lines changed: 250 additions & 88 deletions

.gitignore

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,11 @@ Pods/
5353
.git/
5454

5555
## Test results
56-
TestResults.xcresult/
57-
TestResults-*
58-
TestResults_*
56+
*.xcresult
5957

6058
## Logs
6159
*.log
6260

6361
## Coverage
6462
coverage.json
63+
coverage_export/

CycleOne/Core/Localization/AppLanguage.swift

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -151,27 +151,14 @@ enum AppLanguage: String, CaseIterable, Identifiable {
151151

152152
private extension Bundle {
153153
static func localizedBundle(for languageCode: String) -> Bundle? {
154-
if
155-
let directPath = Bundle.main.path(
156-
forResource: languageCode,
157-
ofType: "lproj"
158-
),
159-
let bundle = Bundle(path: directPath)
160-
{
161-
return bundle
162-
}
163-
164-
if
165-
let groupedPath = Bundle.main.path(
166-
forResource: languageCode,
167-
ofType: "lproj",
168-
inDirectory: "LocalizationResources"
169-
),
170-
let bundle = Bundle(path: groupedPath)
171-
{
172-
return bundle
173-
}
174-
175-
return nil
154+
let localizationPath = Bundle.main.path(
155+
forResource: languageCode,
156+
ofType: "lproj",
157+
inDirectory: "LocalizationResources"
158+
) ?? Bundle.main.path(
159+
forResource: languageCode,
160+
ofType: "lproj"
161+
)
162+
return localizationPath.flatMap(Bundle.init(path:))
176163
}
177164
}

CycleOne/Views/Insights/InsightsView.swift

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -211,9 +211,6 @@ struct InsightsView: View {
211211
.onAppear {
212212
viewModel.calculateStats()
213213
}
214-
.onChange(of: selectedLanguageCode, initial: false) { _, _ in
215-
viewModel.calculateStats()
216-
}
217214
}
218215
.id("insights-stack-\(selectedLanguageCode)")
219216
.environment(

CycleOne/Views/Shared/PrivacyPolicyView.swift

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,15 @@ struct PrivacyPolicyView: View {
3333
static func defaultPolicyURL(
3434
language: AppLanguage = AppLanguage.currentSelection()
3535
) -> URL? {
36-
if let url = language.localizedResourceURL(
36+
let fallbackURL = Bundle.main.url(
3737
forResource: "PrivacyPolicy",
3838
withExtension: "html"
39-
) {
40-
return url
41-
}
39+
)
4240

43-
return Bundle.main.url(
41+
return language.localizedResourceURL(
4442
forResource: "PrivacyPolicy",
4543
withExtension: "html"
46-
)
44+
) ?? fallbackURL
4745
}
4846

4947
static func fallbackMessage(for policyURL: URL?) -> String? {

CycleOneDocs/coverage-command.md

Lines changed: 41 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,53 @@
1-
# Coverage percentage commands (target-level)
1+
# Coverage commands (split schemes + merged report)
22

3-
# 1) Generate a fresh coverage bundle
4-
make test
3+
# 1) Run unit tests with coverage output
4+
xcodebuild test \
5+
-project CycleOne.xcodeproj \
6+
-scheme CycleOne-Unit \
7+
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
8+
-parallel-testing-enabled YES \
9+
-derivedDataPath build/deriveddata \
10+
-enableCodeCoverage YES \
11+
-resultBundlePath UnitCoverage.xcresult
512

6-
# 2) Show coverage % for every target in TestResults.xcresult
7-
xcrun xccov view --report --json TestResults.xcresult \
8-
| jq -r '.targets[] | "\(.name): \((.lineCoverage * 100) | tostring)% (\(.coveredLines)/\(.executableLines))"'
13+
# 2) Run UI tests with coverage output
14+
xcodebuild test \
15+
-project CycleOne.xcodeproj \
16+
-scheme CycleOne-UI \
17+
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
18+
-parallel-testing-enabled YES \
19+
-derivedDataPath build/deriveddata \
20+
-enableCodeCoverage YES \
21+
-resultBundlePath UICoverage.xcresult
922

10-
# 3) Show only the main project targets
11-
xcrun xccov view --report --json TestResults.xcresult \
12-
| jq -r '.targets[]
13-
| select(.name == "CycleOne.app" or .name == "CycleOneTests.xctest" or .name == "CycleOneUITests.xctest")
14-
| "\(.name): \((.lineCoverage * 100) | tostring)% (\(.coveredLines)/\(.executableLines))"'
23+
# 3) Export coverage payloads and merge unit + UI reports
24+
rm -rf coverage_export
25+
mkdir -p coverage_export/unit coverage_export/ui coverage_export/merged
1526

16-
# 4) Enforce minimum target percentage (example: 100)
17-
MIN_PERCENT=100
18-
xcrun xccov view --report --json TestResults.xcresult \
19-
| jq -e --argjson min "$MIN_PERCENT" '
20-
.targets
21-
| map(select(.name == "CycleOne.app" or .name == "CycleOneTests.xctest" or .name == "CycleOneUITests.xctest"))
22-
| all((.lineCoverage * 100) >= $min)
23-
'
27+
xcrun xcresulttool export coverage --path UnitCoverage.xcresult --output-path coverage_export/unit
28+
xcrun xcresulttool export coverage --path UICoverage.xcresult --output-path coverage_export/ui
2429

25-
# jq exits 0 when all targets meet the threshold, non-zero otherwise.
30+
xcrun xccov merge \
31+
--outReport coverage_export/merged/merged.xccovreport \
32+
--outArchive coverage_export/merged/merged.xccovarchive \
33+
'coverage_export/unit/0_Test_iPhone 17 Pro_CoverageReport' \
34+
'coverage_export/unit/0_Test_iPhone 17 Pro_CoverageArchive' \
35+
'coverage_export/ui/0_Test_iPhone 17 Pro_CoverageReport' \
36+
'coverage_export/ui/0_Test_iPhone 17 Pro_CoverageArchive'
2637

27-
# 5) Run localization integrity checks
38+
# 4) Show merged target coverage percentages
39+
xcrun xccov view --report --json coverage_export/merged/merged.xccovreport \
40+
| jq -r '.targets[] | "\(.name): \(((.lineCoverage * 100) | tostring))% (\(.coveredLines)/\(.executableLines))"'
41+
42+
# 5) Enforce 100% for app target
43+
xcrun xccov view --report --json coverage_export/merged/merged.xccovreport \
44+
| jq -e '.targets[] | select(.name == "CycleOne.app") | (.coveredLines == .executableLines)'
45+
46+
# 6) Run localization integrity checks (unit scheme)
2847
DEST_ID=$(xcrun simctl list devices available | awk -F '[()]' '/iPhone/ {print $2; exit}')
2948
if [ -z "$DEST_ID" ]; then echo "No available iPhone simulator found."; exit 1; fi
3049

31-
xcodebuild test -project CycleOne.xcodeproj -scheme CycleOne -destination "id=${DEST_ID}" \
50+
xcodebuild test -project CycleOne.xcodeproj -scheme CycleOne-Unit -destination "id=${DEST_ID}" \
3251
-parallel-testing-enabled NO \
3352
-only-testing:CycleOneTests/LocalizationCoverageTests \
3453
-only-testing:CycleOneTests/AppLanguageTests

CycleOneDocs/test-command.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
DEST_ID=$(xcrun simctl list devices available | awk -F '[()]' '/iPhone/ {print $2; exit}')
22
if [ -z "$DEST_ID" ]; then echo "No available iPhone simulator found."; exit 1; fi
3-
xcodebuild test -project CycleOne.xcodeproj -scheme CycleOne -destination "id=${DEST_ID}" -parallel-testing-enabled NO -only-testing:CycleOneTests
3+
xcodebuild test -project CycleOne.xcodeproj -scheme CycleOne-Unit -destination "id=${DEST_ID}" -parallel-testing-enabled NO -only-testing:CycleOneTests

CycleOneDocs/test_checklist.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
# CycleOne Test Checklist
22

3-
## Current Automated Status (2026-04-02)
3+
## Current Automated Status (2026-04-08)
44
- `make check` passes end-to-end.
55
- `pre-commit run --all-files` passes all hooks.
66
- Latest run counts:
77
- `CycleOneTests.xctest`: 182 tests, 0 failures.
88
- `CycleOneUITests.xctest`: 22 tests, 0 failures.
9-
- Coverage from latest stable full run (`TestResults.xcresult`):
10-
- `CycleOne.app`: 10,313 / 10,313 lines (100.00%)
11-
- `CycleOneTests.xctest`: 3,682 / 3,682 lines (100.00%)
12-
- `CycleOneUITests.xctest`: 773 / 773 lines (100.00%)
9+
- Coverage from latest merged run (`UnitCoverage.xcresult` + `UICoverage.xcresult`):
10+
- `CycleOne.app`: 10,893 / 10,893 lines (100.00%)
11+
- `CycleOneTests.xctest`: 4,298 / 4,316 lines (99.58%)
12+
- `CycleOneUITests.xctest`: 955 / 962 lines (99.27%)
1313

1414
## Security and Edge-Case Checks (2026-04-02)
1515
- [x] CSV export formula injection mitigation verified (`=`, `+`, `-`, `@` leading values are prefixed safely)

CycleOneTests/CycleEnumsTests.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,19 @@ final class CycleEnumsTests: XCTestCase {
7171
XCTAssertTrue(defaults.contains(where: { $0.id == "mood_swings" && $0.category == .mood }))
7272
XCTAssertTrue(defaults.contains(where: { $0.id == "insomnia" && $0.category == .other }))
7373
}
74+
75+
func testSymptomLocalizedName_forNameCoversMatchAndFallback() {
76+
XCTAssertEqual(
77+
SymptomType.localizedName(forName: "Cramps"),
78+
L10n.string("symptom.cramps", default: "Cramps")
79+
)
80+
XCTAssertEqual(
81+
SymptomType.localizedName(forName: "cRaMpS"),
82+
L10n.string("symptom.cramps", default: "Cramps")
83+
)
84+
XCTAssertEqual(
85+
SymptomType.localizedName(forName: "Unknown Symptom"),
86+
"Unknown Symptom"
87+
)
88+
}
7489
}

CycleOneTests/CycleManagerTests.swift

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,46 @@ final class CycleManagerTests: XCTestCase {
135135
XCTAssertGreaterThanOrEqual(throwingContext.saveCallCount, 2)
136136
}
137137

138+
func testRebuildAllCycles_shortGapDoesNotCreateSecondCycle() throws {
139+
let start = Date().adding(days: -12).startOfDay
140+
141+
let first = DayLog(context: context)
142+
first.id = UUID()
143+
first.date = start
144+
first.flowLevel = FlowLevel.light.rawValue
145+
146+
let second = DayLog(context: context)
147+
second.id = UUID()
148+
second.date = start.adding(days: 10)
149+
second.flowLevel = FlowLevel.heavy.rawValue
150+
151+
try context.save()
152+
153+
CycleManager.shared.rebuildAllCycles(in: context)
154+
155+
let request: NSFetchRequest<Cycle> = Cycle.fetchRequest()
156+
let cycles = try context.fetch(request)
157+
XCTAssertEqual(cycles.count, 1)
158+
}
159+
160+
func testRebuildAllCycles_skipsInvalidDateLog() throws {
161+
let validLog = DayLog(context: context)
162+
validLog.id = UUID()
163+
validLog.date = Date().adding(days: -20).startOfDay
164+
validLog.flowLevel = FlowLevel.light.rawValue
165+
166+
let invalidLog = DayLog(context: context)
167+
invalidLog.id = UUID()
168+
invalidLog.date = nil
169+
invalidLog.flowLevel = FlowLevel.heavy.rawValue
170+
171+
CycleManager.shared.rebuildAllCycles(in: context)
172+
173+
let request: NSFetchRequest<Cycle> = Cycle.fetchRequest()
174+
let cycles = try context.fetch(request)
175+
XCTAssertEqual(cycles.count, 1)
176+
}
177+
138178
func testUpdateCycleMetrics_periodLengthStopsAtNonFlowDay() throws {
139179
let startDate = Date().adding(days: -6).startOfDay
140180

@@ -215,6 +255,24 @@ final class CycleManagerTests: XCTestCase {
215255
XCTAssertTrue(true)
216256
}
217257

258+
func testUpdateCycleMetrics_skipsCycleWithMissingStartDate() throws {
259+
let validCycle = Cycle(context: context)
260+
validCycle.id = UUID()
261+
validCycle.startDate = Date().adding(days: -30).startOfDay
262+
validCycle.createdAt = Date()
263+
264+
let invalidCycle = Cycle(context: context)
265+
invalidCycle.id = UUID()
266+
invalidCycle.startDate = nil
267+
invalidCycle.createdAt = Date()
268+
269+
CycleManager.shared.updateCycleMetrics(in: context)
270+
271+
let request: NSFetchRequest<Cycle> = Cycle.fetchRequest()
272+
let cycles = try context.fetch(request)
273+
XCTAssertEqual(cycles.count, 2)
274+
}
275+
218276
func testDebugExtractHelpers_coverValueVariants() {
219277
let manager = CycleManager.shared
220278
let today = Date().startOfDay

CycleOneTests/ExportServiceTests.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,27 @@ final class ExportServiceTests: XCTestCase {
242242
XCTAssertNil(url)
243243
}
244244

245+
func testGenerateCSV_skipsRowsWithMissingDate() throws {
246+
let validLog = DayLog(context: context)
247+
validLog.id = UUID()
248+
validLog.date = Date().startOfDay
249+
validLog.flowLevel = FlowLevel.light.rawValue
250+
251+
let missingDateLog = DayLog(context: context)
252+
missingDateLog.id = UUID()
253+
missingDateLog.date = nil
254+
missingDateLog.flowLevel = FlowLevel.medium.rawValue
255+
256+
let url = ExportService.shared.generateCSV(context: context)
257+
XCTAssertNotNil(url)
258+
259+
if let url {
260+
let content = try String(contentsOf: url)
261+
let lines = content.split(whereSeparator: \ .isNewline)
262+
XCTAssertEqual(lines.count, 2)
263+
}
264+
}
265+
245266
func testSymptomsText_helper_handlesNilAndValues() {
246267
XCTAssertEqual(ExportService.symptomsText(from: nil), "")
247268

0 commit comments

Comments
 (0)