// // DebugSeed.swift // Haru_Danim // // 개발 검증용 데모 데이터. 런치 인자 `-seedDemo YES`가 있을 때만 동작. (DEBUG 전용) // 시드 이름·메모는 String(localized:) — 마케팅 스크린샷용으로 실행 로케일(-AppleLanguages)을 따른다. // 이름으로 찾는 인자(-autoStart, -openStatsFor, -excludeActions, -endGoalYesterday)는 그 로케일의 이름을 넘길 것. // #if DEBUG import Foundation import SwiftData private extension Error { /// 인텐트 오류를 사람이 읽을 문구로 (인텐트 오류 타입의 localizedStringResource 우선) var asIntentMessage: LocalizedStringResource { if let convertible = self as? any CustomLocalizedStringResourceConvertible { return convertible.localizedStringResource } return "\(localizedDescription)" } } enum DebugSeed { /// `-autoStart <행동이름>` 런치 인자가 있으면 해당 행동의 시간 측정을 시작 (Live Activity 검증용) static func autoStartIfRequested(context: ModelContext) { guard let name = UserDefaults.standard.string(forKey: "autoStart") else { return } let descriptor = FetchDescriptor(predicate: #Predicate { $0.name == name }) guard let action = try? context.fetch(descriptor).first else { return } if action.runningSession == nil { context.insert(TimeSession(action: action, startAt: .now)) } LiveActivityManager.sync(context: context) } /// `-intentSmokeTest YES` 런치 인자: 시리 단축어 인텐트 6종의 perform()을 앱 안에서 /// 직접 실행해 보는 스모크 테스트. 결과는 [IntentTest] 프리픽스로 콘솔에 출력된다 /// (`xcrun simctl launch --console`로 확인). 데모 데이터(-seedDemo) 위에서 실행할 것. @MainActor static func runIntentSmokeTestIfRequested() async { guard UserDefaults.standard.bool(forKey: "intentSmokeTest") else { return } var lines: [String] = [] func log(_ name: String, _ result: String) { print("[IntentTest] \(name): \(result)") lines.append("\(name): \(result)") } defer { // CLI에서 읽을 수 있게 결과를 Documents에도 남긴다 if let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { try? lines.joined(separator: "\n") .write(to: docs.appendingPathComponent("intent-smoke-test.txt"), atomically: true, encoding: .utf8) } } func entity(_ name: String) -> ActionEntity? { IntentStore.actions().first { $0.name == name }.map(ActionEntity.init) } guard let running = entity(String(localized: "달리기")), let water = entity(String(localized: "물 마시기")) else { log("준비", "실패 — 데모 행동 없음 (-seedDemo 필요)") return } let math = DayMath() let agg = Aggregator(math: math) // ① 시간 측정 시작 → 진행 세션 생김 do { let intent = StartTimeActionIntent() intent.action = running _ = try await intent.perform() let model = try IntentStore.action(running.id) log("측정 시작", model.isRunning ? "OK — 진행 중 세션 생성" : "실패 — 세션 없음") } catch { log("측정 시작", "throw: \(String(localized: error.asIntentMessage))") } // ② 이미 측정 중일 때 다시 시작 → 중복 세션이 생기면 안 됨 do { let intent = StartTimeActionIntent() intent.action = running _ = try await intent.perform() let count = try IntentStore.action(running.id).sessions.filter { $0.endAt == nil }.count log("중복 시작 방지", count == 1 ? "OK — 진행 세션 1개 유지" : "실패 — 진행 세션 \(count)개") } catch { log("중복 시작 방지", "throw: \(String(localized: error.asIntentMessage))") } // ③ 측정 종료 → 진행 세션 없어짐 do { let intent = StopTimeActionIntent() intent.action = running _ = try await intent.perform() let model = try IntentStore.action(running.id) log("측정 종료", model.isRunning ? "실패 — 아직 진행 중" : "OK — 종료됨") } catch { log("측정 종료", "throw: \(String(localized: error.asIntentMessage))") } // ④ 횟수 추가 → 오늘 누적 +3 do { let before = agg.count(for: try IntentStore.action(water.id), in: math.dayRange(containing: .now)) let intent = AddCountIntent() intent.action = water intent.amount = 3 _ = try await intent.perform() let after = agg.count(for: try IntentStore.action(water.id), in: math.dayRange(containing: .now)) log("횟수 추가", after == before + 3 ? "OK — \(before) → \(after)" : "실패 — \(before) → \(after)") } catch { log("횟수 추가", "throw: \(String(localized: error.asIntentMessage))") } // ⑤ 타입 불일치 방어: 시간형 행동에 횟수 추가 → IntentTargetError do { let intent = AddCountIntent() intent.action = running intent.amount = 1 _ = try await intent.perform() log("타입 방어", "실패 — 시간형에 횟수 추가가 통과됨") } catch { log("타입 방어", "OK — 거부: \(String(localized: error.asIntentMessage))") } // ⑤-2 위젯 실행 버튼(RunActionIntent): 시간형 시작 → 다시 실행하면 종료 토글 do { _ = try await RunActionIntent(actionID: running.id.uuidString).perform() let startedOK = try IntentStore.action(running.id).isRunning _ = try await RunActionIntent(actionID: running.id.uuidString).perform() let stoppedOK = try !IntentStore.action(running.id).isRunning log("위젯 실행 토글", startedOK && stoppedOK ? "OK — 시작 후 토글 종료" : "실패 — 시작=\(startedOK) 종료=\(stoppedOK)") } catch { log("위젯 실행 토글", "throw: \(String(localized: error.asIntentMessage))") } // ⑥ 조회 인텐트 3종 — throw 없이 완료되는지 do { let total = ActionTotalIntent() total.action = running total.span = .day _ = try await total.perform() log("누적값 조회", "OK — 하루 \(Format.durationShort(agg.seconds(for: try IntentStore.action(running.id), in: math.dayRange(containing: .now))))") } catch { log("누적값 조회", "throw: \(String(localized: error.asIntentMessage))") } do { guard let goal = IntentStore.goals().first(where: { !$0.quests.isEmpty }) else { log("목표 진행률", "건너뜀 — 다짐 있는 목표 없음"); return } let intent = GoalProgressIntent() intent.goal = GoalEntity(goal) intent.span = .day _ = try await intent.perform() log("목표 진행률", "OK — '\(goal.title)' 하루 \(Int((goal.combinedSpanRatio(.day) * 100).rounded()))%") if let quest = goal.sortedQuests.first { let questIntent = QuestProgressIntent() questIntent.quest = QuestEntity(quest) questIntent.span = .week _ = try await questIntent.perform() log("다짐 진행률", "OK — '\(quest.targetName)' 주간 \(Format.percent(QuestProgress(quest: quest).spanProgress(.week).displayRatio))") } } catch { log("목표/다짐 진행률", "throw: \(String(localized: error.asIntentMessage))") } // ⑦ 프리미엄 게이트: 잠금 상태에서 실행 → PremiumRequiredError let wasPremium = PremiumGate.isPremium PremiumGate.cache(false) do { let intent = StartTimeActionIntent() intent.action = running _ = try await intent.perform() log("프리미엄 게이트", "실패 — 잠금 상태에서 실행됨") } catch { log("프리미엄 게이트", "OK — 거부: \(String(localized: error.asIntentMessage))") } PremiumGate.cache(wasPremium) log("완료", "스모크 테스트 끝") } /// `-progressSelfTest YES` 런치 인자: 다짐 진행률(spanProgress) 수치 검증. /// 인메모리 컨테이너에 시나리오들을 구성해 하루/주간/월간의 퍼센트 문구(displayRatio)· /// 게이지(ratio)·값을 손계산 기대값과 대조하고 결과를 Documents/progress-self-test.txt로 남긴다. /// 설정은 주 시작 월요일·하루 시작 00:00으로 고정하고 기준 시각은 이번 주 목요일 12:00 — /// 기기 설정·실행 날짜와 무관하게 항상 같은 판정이 나온다 (실데이터 무영향). /// 핵심 규칙: 주기와 다른 span의 환산 목표는 '이상 달성' 초과 표기를 100%에서 캡, /// 자기 주기 span·특정 기간은 초과 표기 유지, '이하 유지'는 값 무캡 (§4.2). @MainActor static func runProgressSelfTestIfRequested() { guard UserDefaults.standard.bool(forKey: "progressSelfTest") else { return } var lines: [String] = [] var failures = 0 func expect(_ label: String, _ actual: Double, _ expected: Double) { let pass = abs(actual - expected) < 0.0005 if !pass { failures += 1 } lines.append("\(pass ? "PASS" : "FAIL") \(label): actual \(String(format: "%.4f", actual)) / expected \(String(format: "%.4f", expected))") } do { let config = ModelConfiguration(isStoredInMemoryOnly: true) let container = try ModelContainer(for: DataStore.schema, configurations: [config]) let context = container.mainContext let math = DayMath(settings: TrackingSettings(weekStartWeekday: 2, dayStartMinutes: 0)) let cal = math.calendar let weekStart = math.weekRange(containing: .now).lowerBound /// 주 시작(월) + offset일의 hour:minute 시각 func at(_ dayOffset: Int, _ hour: Int, _ minute: Int = 0) -> Date { cal.date(byAdding: .minute, value: hour * 60 + minute, to: cal.date(byAdding: .day, value: dayOffset, to: weekStart)!)! } let now = at(3, 12) // 이번 주 목요일 12:00 let monthDays = Double(math.dayKeys(in: math.monthRange(containing: now)).count) lines.append("기준: 목요일 12:00 (\(now)), 이번 달 일수 \(Int(monthDays))") /// 시나리오 하나 = 전용 목표 + 전용 행동 + 다짐 (교차 오염 방지) func makeQuest(_ name: String, type: TrackingType, period: QuestPeriod, direction: QuestDirection = .atLeast, targetSeconds: Double = 3600, targetCount: Int = 1, configure: (Quest) -> Void = { _ in }) -> (Action, Quest) { let goal = Goal(title: name, symbolName: "flag.fill", colorHex: "#2F6B4F", startDate: cal.date(byAdding: .day, value: -30, to: now)!, endDate: nil) context.insert(goal) let action = Action(name: name, symbolName: "star.fill", trackingType: type, sortOrder: 0) context.insert(action) let quest = Quest(goal: goal) quest.targetAction = action quest.measure = type quest.period = period quest.direction = direction quest.targetSeconds = targetSeconds quest.targetCount = targetCount configure(quest) context.insert(quest) return (action, quest) } func add(_ action: Action, _ when: Date, amount: Int = 1) { context.insert(CountEntry(action: action, timestamp: when, amount: amount)) } func addSession(_ action: Action, _ start: Date, _ end: Date) { context.insert(TimeSession(action: action, startAt: start, endAt: end)) } func progress(_ quest: Quest, _ span: StatSpan) -> QuestProgressResult { QuestProgress(quest: quest, math: math).spanProgress(span, now: now) } // ── A. 주 2회 다짐, 오늘 1회 (보고된 버그: 하루 350% → 100%) let (aAct, aQuest) = makeQuest("A 주2회", type: .count, period: .weekly, targetCount: 2) add(aAct, at(3, 10)) expect("A 하루 표기(버그 수정)", progress(aQuest, .day).displayRatio, 1.0) expect("A 하루 게이지", progress(aQuest, .day).ratio, 1.0) expect("A 주간 표기", progress(aQuest, .week).displayRatio, 0.5) expect("A 월간 표기(환산 캡 이하)", progress(aQuest, .month).displayRatio, 7.0 / (2.0 * monthDays)) if let goal = aQuest.goal { expect("A 목표 하루 진행률(회귀 무영향)", goal.combinedSpanRatio(.day, math: math, now: now), 1.0) expect("A 목표 주간 진행률", goal.combinedSpanRatio(.week, math: math, now: now), 0.5) } // ── B. 주 2회 다짐, 월·화·목 3회 (자기 주기 초과 표기 유지) let (bAct, bQuest) = makeQuest("B 주2회 3번", type: .count, period: .weekly, targetCount: 2) add(bAct, at(0, 10)); add(bAct, at(1, 10)); add(bAct, at(3, 10)) expect("B 주간 표기(자기 주기 150% 유지)", progress(bQuest, .week).displayRatio, 1.5) expect("B 주간 게이지", progress(bQuest, .week).ratio, 1.0) expect("B 하루 표기(캡)", progress(bQuest, .day).displayRatio, 1.0) // ── C. 주 7시간 다짐, 오늘 2시간 (시간형도 동일 규칙: 하루 200% → 100%) let (cAct, cQuest) = makeQuest("C 주7시간", type: .time, period: .weekly, targetSeconds: 7 * 3600) addSession(cAct, at(3, 9), at(3, 11)) expect("C 하루 표기(캡)", progress(cQuest, .day).displayRatio, 1.0) expect("C 주간 표기", progress(cQuest, .week).displayRatio, 2.0 / 7.0) // ── D. 하루 5회 다짐, 오늘 10회 (자기 주기 200% 표기 유지 + 주간 하루 기여 캡) let (dAct, dQuest) = makeQuest("D 하루5회", type: .count, period: .daily, targetCount: 5) add(dAct, at(3, 9), amount: 10) expect("D 하루 표기(자기 주기 200% 유지)", progress(dQuest, .day).displayRatio, 2.0) expect("D 하루 게이지", progress(dQuest, .day).ratio, 1.0) expect("D 주간 표기(하루 기여 캡)", progress(dQuest, .week).displayRatio, 5.0 / 35.0) // ── E. 월수금 하루 5회 다짐: 월 5회, 수 10회(5로 캡), 목 5회(비수행일 — 집계 제외) let (eAct, eQuest) = makeQuest("E 월수금5회", type: .count, period: .daily, targetCount: 5) { $0.scheduleMode = .weekdays $0.weekdays = [2, 4, 6] } add(eAct, at(0, 9), amount: 5); add(eAct, at(2, 9), amount: 10); add(eAct, at(3, 9), amount: 5) expect("E 주간 표기(목요일 기록 제외)", progress(eQuest, .week).displayRatio, 10.0 / 15.0) expect("E 오늘 수행일 아님 판정", QuestProgress(quest: eQuest, math: math).isScheduled(on: now) ? 1 : 0, 0) // ── F. 월 10회 다짐, 오늘 1회 (하루 환산 캡 + 주간 환산 정확도) let (fAct, fQuest) = makeQuest("F 월10회", type: .count, period: .monthly, targetCount: 10) add(fAct, at(3, 10)) expect("F 하루 표기(캡)", progress(fQuest, .day).displayRatio, 1.0) expect("F 주간 표기", progress(fQuest, .week).displayRatio, monthDays / 70.0) expect("F 월간 표기", progress(fQuest, .month).displayRatio, 0.1) // ── G. 월 10회 다짐, 오늘 12회 (자기 주기 120% 유지, 하루·주간은 캡) let (gAct, gQuest) = makeQuest("G 월10회 12번", type: .count, period: .monthly, targetCount: 10) add(gAct, at(3, 10), amount: 12) expect("G 월간 표기(자기 주기 120% 유지)", progress(gQuest, .month).displayRatio, 1.2) expect("G 하루 표기(캡)", progress(gQuest, .day).displayRatio, 1.0) expect("G 주간 표기(캡)", progress(gQuest, .week).displayRatio, 1.0) // ── H. '이하 유지' 주 7시간, 오늘 3시간 (값 무캡 — 하루 페이스 경고 0%, 주간 100%) let (hAct, hQuest) = makeQuest("H 이하7시간", type: .time, period: .weekly, direction: .atMost, targetSeconds: 7 * 3600) addSession(hAct, at(3, 9), at(3, 12)) expect("H 하루 게이지(환산 한도 초과 경고)", progress(hQuest, .day).ratio, 0.0) expect("H 하루 값(무캡 — UI 초과 판정용)", progress(hQuest, .day).value, 3 * 3600) expect("H 주간 게이지(한도 안)", progress(hQuest, .week).ratio, 1.0) // ── I. 특정 기간(10일) 10회 다짐, 오늘 12회 (환산 없음 — 전 span 초과 표기 유지) let (iAct, iQuest) = makeQuest("I 기간10회", type: .count, period: .custom, targetCount: 10) { $0.customStart = at(0, 0) $0.customEnd = at(9, 0) } add(iAct, at(3, 10), amount: 12) expect("I 하루 표기(기간 다짐 무캡)", progress(iQuest, .day).displayRatio, 1.2) expect("I 주간 표기(기간 다짐 무캡)", progress(iQuest, .week).displayRatio, 1.2) // ── J. 주간 다짐에 잔존 deadlineMinutes (하루 단위 전용 — 무시돼 A와 동일해야 함) let (jAct, jQuest) = makeQuest("J 주2회+잔존마감", type: .count, period: .weekly, targetCount: 2) { $0.deadlineMinutes = 480 } add(jAct, at(3, 10)) expect("J 하루 표기(마감 무시)", progress(jQuest, .day).displayRatio, 1.0) expect("J 주간 표기(마감 무시)", progress(jQuest, .week).displayRatio, 0.5) // ── K. 하루 1회 + 마감 08:00 (마감 회귀 확인: 07:30은 인정, 08:10은 제외) let (kAct, kQuest) = makeQuest("K 마감내", type: .count, period: .daily, targetCount: 1) { $0.deadlineMinutes = 480 } add(kAct, at(3, 7, 30)) expect("K 하루 표기(마감 안 1회)", progress(kQuest, .day).displayRatio, 1.0) let (k2Act, k2Quest) = makeQuest("K2 마감밖", type: .count, period: .daily, targetCount: 1) { $0.deadlineMinutes = 480 } add(k2Act, at(3, 8, 10)) expect("K2 하루 표기(마감 뒤 제외)", progress(k2Quest, .day).displayRatio, 0.0) // ── L. 기록 없는 주간 다짐 (0% 기본 + 주/월 다짐은 매일이 수행일) let (_, lQuest) = makeQuest("L 무기록", type: .count, period: .weekly, targetCount: 2) expect("L 하루 표기", progress(lQuest, .day).displayRatio, 0.0) expect("L 주간 표기", progress(lQuest, .week).displayRatio, 0.0) expect("L 주간 다짐 오늘 수행일 판정", QuestProgress(quest: lQuest, math: math).isScheduled(on: now) ? 1 : 0, 1) // ── M. '이하 유지' 월수금 하루 1시간: 월 30분(한도 안), 목 3시간(비수행일 — 한도 소비 아님) // 목표량이 수행일 수 기준이므로 값도 수행일만 집계해야 정합 (비대칭 수정 확인) let (mAct, mQuest) = makeQuest("M 월수금 이하1시간", type: .time, period: .daily, direction: .atMost, targetSeconds: 3600) { $0.scheduleMode = .weekdays $0.weekdays = [2, 4, 6] } addSession(mAct, at(0, 9), at(0, 9, 30)) addSession(mAct, at(3, 13), at(3, 16)) expect("M 주간 값(목요일 시청 제외)", progress(mQuest, .week).value, 30 * 60) expect("M 주간 게이지(한도 안 100%)", progress(mQuest, .week).ratio, 1.0) // ── N. 특정 기간이 이미 끝난 다짐 — 오늘은 기간 밖 (수행일 아님 판정) let (_, nQuest) = makeQuest("N 지난기간", type: .count, period: .custom, targetCount: 5) { $0.customStart = at(-10, 0) $0.customEnd = at(-3, 0) } expect("N 기간 밖 수행일 판정", QuestProgress(quest: nQuest, math: math).isScheduled(on: now) ? 1 : 0, 0) // ── O. 주 3회 다짐을 월~수에 채움, 오늘(목) 기록 없음 — 주기 몫 완료 상태 (§4.2) // spanProgress 수치는 불변(하루 0%)이고, 완료 판정과 목표 하루 평균만 달성으로 반영 let (oAct, oQuest) = makeQuest("O 주3회 완료", type: .count, period: .weekly, targetCount: 3) add(oAct, at(0, 9)); add(oAct, at(1, 9)); add(oAct, at(2, 9)) expect("O 하루 표기(수치 불변 0%)", progress(oQuest, .day).displayRatio, 0.0) expect("O 주기 몫 완료 판정", QuestProgress(quest: oQuest, math: math).isPeriodFulfilled(asOf: now) ? 1 : 0, 1) if let goal = oQuest.goal { expect("O 목표 하루 평균(완료 반영 100%)", goal.combinedSpanRatio(.day, math: math, now: now), 1.0) expect("O 목표 주간 평균(무영향)", goal.combinedSpanRatio(.week, math: math, now: now), 1.0) } // 비대상 다짐들의 완료 판정 false 유지 (미달 주간·하루 단위·이하 유지·특정 기간 / 월간 완료는 true) expect("O2 주간 미달 false", QuestProgress(quest: aQuest, math: math).isPeriodFulfilled(asOf: now) ? 1 : 0, 0) expect("O3 하루 다짐 false", QuestProgress(quest: dQuest, math: math).isPeriodFulfilled(asOf: now) ? 1 : 0, 0) expect("O4 이하 유지 false", QuestProgress(quest: hQuest, math: math).isPeriodFulfilled(asOf: now) ? 1 : 0, 0) expect("O5 특정 기간 false", QuestProgress(quest: iQuest, math: math).isPeriodFulfilled(asOf: now) ? 1 : 0, 0) expect("O6 월간 완료 true", QuestProgress(quest: gQuest, math: math).isPeriodFulfilled(asOf: now) ? 1 : 0, 1) // ── P. 한 목표에 하루 다짐(오늘 50%) + 주기 몫 완료 주간 다짐 — 하루 평균 (0.5+1)/2 let (pAct, pQuest) = makeQuest("P 하루2회", type: .count, period: .daily, targetCount: 2) add(pAct, at(3, 9)) if let goal = pQuest.goal { let p2 = Quest(goal: goal) p2.targetAction = oAct // O의 월~수 3회 기록 재사용 (완료 상태) p2.measure = .count p2.period = .weekly p2.direction = .atLeast p2.targetCount = 3 context.insert(p2) expect("P 목표 하루 평균(혼합 75%)", goal.combinedSpanRatio(.day, math: math, now: now), 0.75) } // ── Q. 과거 날짜 기준 조회(일기 카드): 몫을 나중에 채운 주간 다짐은 그 전 날짜 기준으론 미완료 let (qAct, qQuest) = makeQuest("Q 주1회 목요일", type: .count, period: .weekly, targetCount: 1) add(qAct, at(3, 10)) expect("Q 오늘(목) 기준 완료", QuestProgress(quest: qQuest, math: math).isPeriodFulfilled(asOf: now) ? 1 : 0, 1) expect("Q 화요일 기준 미완료(소급 차단)", QuestProgress(quest: qQuest, math: math).isPeriodFulfilled(asOf: at(1, 12)) ? 1 : 0, 0) // ── H. 건강 다짐 (1.5) — 값 공급만 HealthCache(testOverride 주입)로 바뀌고 // 수학(캡·수행일 합산·연속·판정)은 기록 다짐과 동일해야 한다 (plan §10 R2) func makeHealthQuest(_ name: String, raw: String, isDuration: Bool, targetSeconds: Double = 0, targetCount: Int = 0, direction: QuestDirection = .atLeast, configure: (Quest) -> Void = { _ in }) -> Quest { let goal = Goal(title: name, symbolName: "heart.fill", colorHex: "#E0558C", startDate: cal.date(byAdding: .day, value: -30, to: now)!, endDate: nil) context.insert(goal) let quest = Quest(goal: goal) quest.healthMetricRaw = raw quest.measure = isDuration ? .time : .count quest.period = .daily quest.direction = direction quest.targetSeconds = targetSeconds quest.targetCount = targetCount configure(quest) context.insert(quest) return quest } func dayToken(_ offset: Int) -> String { HealthCache.dayToken(math.dayKey(for: at(offset, 12))) } // 캐시 주입: 걸음 — 월 8000·화 9000(캡 대상)·수 0·목(오늘) 4000 HealthQuestValues.testOverride = [ "steps": [dayToken(0): 8000, dayToken(1): 9000, dayToken(3): 4000], "workout.running": [dayToken(2): 45 * 60, dayToken(3): 30 * 60], "sleep@1260-540": [dayToken(1): 6 * 3600, dayToken(2): 8 * 3600, dayToken(3): 8 * 3600], ] defer { HealthQuestValues.testOverride = nil } // H1. 걸음 8000 이상 — 오늘 4000 = 하루 50% let hSteps = makeHealthQuest("H 걸음", raw: "steps", isDuration: false, targetCount: 8000) expect("H1 걸음 하루 표기(50%)", progress(hSteps, .day).displayRatio, 0.5) // H2. 주간 환산: 매일 다짐이라 수행일 7일 × 8000 = 56000 분모, // 기여는 하루 캡(월 8000 + 화 min(9000,8000)=8000 + 수 0 + 목 4000) = 20000 expect("H2 걸음 주간(하루 기여 캡)", progress(hSteps, .week).displayRatio, 20000.0 / 56000.0) // H3. 운동 종목(달리기 30분 이상) — 오늘 정확히 목표 = 100%, 어제 45분은 어제 몫 let hRun = makeHealthQuest("H 달리기", raw: "workout.running", isDuration: true, targetSeconds: 30 * 60) expect("H3 달리기 하루 100%", progress(hRun, .day).displayRatio, 1.0) // H4. 수면 8시간 이상 + 요일 스케줄(월·수·목만) — 화 6시간 미달은 비수행일이라 무영향 let hSleep = makeHealthQuest("H 수면", raw: "sleep", isDuration: true, targetSeconds: 8 * 3600) { quest in quest.scheduleMode = .weekdays quest.weekdays = [2, 4, 5] // 월·수·목 } expect("H4 수면 오늘 100%", progress(hSleep, .day).displayRatio, 1.0) // 주간: 수행일 월(0)+수(8h)+목(8h) 대 목표 3일×8h → 16/24 expect("H4 수면 주간(수행일만 합산)", progress(hSleep, .week).displayRatio, 16.0 / 24.0) // H5. 연속: 수면 다짐 — 수·목 달성, 월 0(끊김 지점) → 연속 2일 let hStreak = QuestProgress(quest: hSleep, math: math).streak(now: now) expect("H5 수면 연속 2일", Double(hStreak?.count ?? -1), 2) // H6. atMost: 걸음 5000 이하 — 오늘 4000 = 한도 안(100%), 화 9000은 초과였음(어제 몫) let hMost = makeHealthQuest("H 걸음한도", raw: "steps", isDuration: false, targetCount: 5000, direction: .atMost) expect("H6 걸음 한도 안(오늘)", progress(hMost, .day).displayRatio, 1.0) // H7. 판정: 어제(수요일) 종료 목표의 달리기 다짐 — 수요일 45분 ≥ 30분 → 달성 1.0 let hJudge = makeHealthQuest("H 판정", raw: "workout.running", isDuration: true, targetSeconds: 30 * 60) if let goal = hJudge.goal { goal.endDate = at(2, 12) // 수요일 expect("H7 종료일 기준 판정(달성)", goal.questAchievementRatio(math: math, now: math.dayRange(forKey: math.dayKey(for: at(2, 12))).upperBound.addingTimeInterval(-1)), 1.0) } // H8. 정규화: 행동이 함께 지정된 이중 상태는 행동이 우선 (구버전 편집 호환) let (dualAct, dualQuest) = makeQuest("H 이중상태", type: .count, period: .daily, targetCount: 2) dualQuest.healthMetricRaw = "steps" add(dualAct, at(3, 9)) expect("H8 이중 상태는 행동 우선(기록 1/2)", progress(dualQuest, .day).displayRatio, 0.5) // H9. 수면 구간 순수 함수 — 합집합 병합(겹침 30분 중복 제거)·창 계산 let mergeBase = at(3, 0) let merged = HealthDataStore.mergedDuration([ (mergeBase, mergeBase.addingTimeInterval(3600)), (mergeBase.addingTimeInterval(1800), mergeBase.addingTimeInterval(5400)), (mergeBase.addingTimeInterval(7200), mergeBase.addingTimeInterval(9000)), ]) expect("H9 수면 합집합 병합", merged, 5400 + 1800) let window = HealthDataStore.sleepWindow( forDayRange: math.dayRange(forKey: math.dayKey(for: now)), endMinutes: 540, length: 12 * 3600 ) expect("H9 수면 창 끝(오늘 09:00)", window.upperBound.timeIntervalSince(at(3, 9)), 0) expect("H9 수면 창 길이(12시간)", window.upperBound.timeIntervalSince(window.lowerBound), 12 * 3600) } catch { failures += 1 lines.append("FAIL 컨테이너 생성: \(error)") } lines.append(failures == 0 ? "== ALL PASS ==" : "== \(failures) FAILURES ==") for line in lines { print("[ProgressTest] \(line)") } if let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { try? lines.joined(separator: "\n") .write(to: docs.appendingPathComponent("progress-self-test.txt"), atomically: true, encoding: .utf8) } } /// `-seedBulk YES` 런치 인자: 성능 검증용 대량 데이터 생성 — /// 과거 500일에 걸쳐 시간 행동 2개(하루 2세션) + 횟수 행동 2개(하루 8건), /// 총 약 1만 건. 범위 조회 전환 후에도 기록·통계 탭이 쾌적한지 확인하는 용도. static func seedBulkIfRequested(context: ModelContext) { guard UserDefaults.standard.bool(forKey: "seedBulk") else { return } let marker = "벌크 시간 1" let existing = (try? context.fetchCount( FetchDescriptor(predicate: #Predicate { $0.name == marker }) )) ?? 0 guard existing == 0 else { return } let tag = Tag(name: "벌크", colorHex: "#4F8A8B") tag.sortOrder = 90 context.insert(tag) var timeActions: [Action] = [] for (index, name) in ["벌크 시간 1", "벌크 시간 2"].enumerated() { let action = Action(name: name, symbolName: "clock.fill", trackingType: .time, sortOrder: 90 + index) action.tags = [tag] context.insert(action) timeActions.append(action) } var countActions: [Action] = [] for (index, name) in ["벌크 횟수 1", "벌크 횟수 2"].enumerated() { let action = Action(name: name, symbolName: "number", trackingType: .count, sortOrder: 92 + index) action.tags = [tag] context.insert(action) countActions.append(action) } let cal = Calendar.current let today = cal.startOfDay(for: .now) for dayOffset in 1...500 { guard let day = cal.date(byAdding: .day, value: -dayOffset, to: today) else { continue } for (slot, action) in timeActions.enumerated() { for hour in [9 + slot * 2, 19 + slot] { let start = day.addingTimeInterval(TimeInterval(hour) * 3600) context.insert(TimeSession(action: action, startAt: start, endAt: start.addingTimeInterval(35 * 60))) } } for action in countActions { for slot in 0..<8 { context.insert(CountEntry( action: action, timestamp: day.addingTimeInterval(TimeInterval(8 + slot) * 3600) )) } } } try? context.save() } /// `-endGoalYesterday <목표 제목>` 런치 인자: 해당 목표의 종료일을 어제로 변경 /// (목표 탭을 열지 않아도 실행 시점에 자동 판정되는지 검증용) static func endGoalYesterdayIfRequested(context: ModelContext) { guard let title = UserDefaults.standard.string(forKey: "endGoalYesterday") else { return } let descriptor = FetchDescriptor(predicate: #Predicate { $0.title == title }) guard let goal = try? context.fetch(descriptor).first else { return } goal.endDate = Calendar.current.date(byAdding: .day, value: -1, to: .now) try? context.save() } /// `-streakDump` 런치 인자: 모든 다짐의 연속 달성 계산 결과를 /// Documents/streak-dump.txt로 기록 (CLI에서 수치 검증용) static func dumpStreaksIfRequested(context: ModelContext) { guard UserDefaults.standard.bool(forKey: "streakDump") else { return } let quests = (try? context.fetch(FetchDescriptor())) ?? [] var lines: [String] = [] for quest in quests { let goal = quest.goal let streak = QuestProgress(quest: quest).streak() lines.append( "goal=\(goal?.title ?? "-") start=\(goal?.startDate.formatted(date: .numeric, time: .omitted) ?? "-") " + "quest=\(quest.targetName) period=\(quest.period) direction=\(quest.direction) " + "streak=\(streak.map { "\($0.count) (\($0.unit))" } ?? "nil") deadline=\(quest.deadlineMinutes)" ) } let url = URL.documentsDirectory.appending(path: "streak-dump.txt") try? lines.joined(separator: "\n").write(to: url, atomically: true, encoding: .utf8) } /// `-symbolAuditDump` 런치 인자: 아이콘 카탈로그에서 현재 OS에 없는 심볼을 /// Documents/symbol-audit.txt로 기록 — 하위 OS(iOS 18) QA용. 선택기는 어차피 /// 런타임 필터로 걸러지므로, 이 목록은 "26 기기에서 고른 심볼이 18 기기에서 /// 빈 아이콘이 될 수 있는 후보"를 뜻한다 (비어 있으면 교차 기기 위험 없음). static func dumpMissingSymbolsIfRequested() { guard UserDefaults.standard.bool(forKey: "symbolAuditDump") else { return } let missing = SymbolCatalog.missingSymbolsOnThisOS let lines = ["missing=\(missing.count)"] + missing let url = URL.documentsDirectory.appending(path: "symbol-audit.txt") try? lines.joined(separator: "\n").write(to: url, atomically: true, encoding: .utf8) } /// `-watchGoals ` 런치 인자: 생성순 목표 N개를 '애플워치에서 보기'로 설정 /// (0이면 전체 해제 — 워치 컴플리케이션 필터 검증용. 설정 후 스냅숏을 즉시 푸시) static func watchGoalsIfRequested(context: ModelContext) { guard UserDefaults.standard.object(forKey: "watchGoals") != nil else { return } let n = UserDefaults.standard.integer(forKey: "watchGoals") let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.createdAt)]) guard let goals = try? context.fetch(descriptor) else { return } LocalPrefs.defaults.set( LocalPrefs.rawValue(goals.prefix(n).map(\.uuid)), forKey: LocalPrefsKeys.watchGoals ) WatchSyncManager.shared.pushSnapshot() } /// `-watchActions ` 런치 인자: 배치순 행동 N개를 '행동 기록' 컴플리케이션에 올린다 (1.5) /// (0이면 전체 해제 — 표시 기간은 하루→이번 주→이번 달→지난 7일→지난 30일 순환으로 /// 다양하게 지정해 렌더 검증에 쓴다. 설정 후 스냅숏을 즉시 푸시) static func watchActionsIfRequested(context: ModelContext) { guard UserDefaults.standard.object(forKey: "watchActions") != nil else { return } let n = UserDefaults.standard.integer(forKey: "watchActions") let actions = LocalPrefs.orderedActions( (try? context.fetch(FetchDescriptor(sortBy: [SortDescriptor(\.createdAt)]))) ?? [] ) LocalPrefs.defaults.removeObject(forKey: LocalPrefsKeys.watchActions) let periods = WatchActionPeriod.allCases for (index, action) in actions.prefix(n).enumerated() { LocalPrefs.setWatchActionPeriod(action.uuid, raw: periods[index % periods.count].rawValue) } WatchSyncManager.shared.pushSnapshot() } /// `-pinGoals ` 런치 인자: 생성순으로 목표 N개를 모음 탭에 표시 (다중 목표 카드 검증용) static func pinGoalsIfRequested(context: ModelContext) { let n = UserDefaults.standard.integer(forKey: "pinGoals") guard n > 0 else { return } let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.createdAt)]) guard let goals = try? context.fetch(descriptor) else { return } // 노출 목표는 기기별 로컬 설정 (LocalPrefs 참고) LocalPrefs.defaults.set( LocalPrefs.rawValue(goals.prefix(n).map(\.uuid)), forKey: LocalPrefsKeys.pinnedGoals ) } /// `-seedPeriodFulfilled YES`: 주기 몫을 이미 채운 주간·월간 다짐이 있는 목표 1개 추가 — /// 하루 게이지의 "이번 주/달 달성" 상태 표기 검증용 (기본 시드와 분리, 이름은 검증 전용이라 비현지화). /// 주간 다짐 기록은 이번 주 첫날, 월간 다짐 기록은 이번 달 첫날 09:00에 둔다 — /// 실행일이 그 첫날이면 오늘 기록이 되지만 완료 상태 표기가 우선이라 화면 확인에는 지장 없음. static func seedPeriodFulfilledIfRequested(context: ModelContext) { guard UserDefaults.standard.bool(forKey: "seedPeriodFulfilled") else { return } let title = "주기 몫 완료 검증" let existing = (try? context.fetch(FetchDescriptor()))?.contains { $0.title == title } ?? false guard !existing else { return } let math = DayMath() let weekStart = math.weekRange(containing: .now).lowerBound let monthStart = math.monthRange(containing: .now).lowerBound let goal = Goal(title: title, symbolName: "checkmark.seal.fill", colorHex: "#4A7A9D", startDate: min(weekStart, monthStart), endDate: nil) context.insert(goal) let weeklyAction = Action(name: "주간 완료 행동", symbolName: "w.circle.fill", trackingType: .count, sortOrder: 80) let monthlyAction = Action(name: "월간 완료 행동", symbolName: "m.circle.fill", trackingType: .count, sortOrder: 81) context.insert(weeklyAction) context.insert(monthlyAction) let weekly = Quest(goal: goal) weekly.targetAction = weeklyAction weekly.measure = .count weekly.period = .weekly weekly.targetCount = 3 context.insert(weekly) let monthly = Quest(goal: goal) monthly.targetAction = monthlyAction monthly.measure = .count monthly.period = .monthly monthly.targetCount = 5 context.insert(monthly) context.insert(CountEntry(action: weeklyAction, timestamp: weekStart.addingTimeInterval(9 * 3600), amount: 3)) context.insert(CountEntry(action: monthlyAction, timestamp: monthStart.addingTimeInterval(9 * 3600), amount: 5)) try? context.save() } /// `-seedHealthQuest YES`: 건강 다짐 목표 1개 시드 (1.5 — 걸음·수면·달리기·물 4종). /// `-seedHealthCache YES`와 함께 쓰면 캐시 시드 값으로 진행률·연속이 렌더된다. /// 목표 제목은 실행 로케일을 따름(마케팅 촬영 겸용), 이미 있으면 재생성하지 않음(멱등) static func seedHealthQuestIfRequested(context: ModelContext) { guard UserDefaults.standard.bool(forKey: "seedHealthQuest") else { return } // 마케팅 촬영에 쓰이므로 실행 로케일을 따른다 (-seedDemo 시드 이름과 같은 규칙) let title = String(localized: "건강 습관 만들기") let existing = (try? context.fetch(FetchDescriptor())) ?? [] if let seeded = existing.first(where: { $0.title == title }) { // 재실행(멱등) 경로: 걸음 다짐에 지정 색이 없으면 입혀 준다 — 표시 색(1.5(7))의 // 렌더 검증 + 새 healthColorHex 필드의 CloudKit 내보내기(스키마 생성) 트리거 겸용 if let steps = seeded.quests.first(where: { $0.healthMetricRaw == HealthMetric.steps.rawValue }), steps.healthColorHex.isEmpty { steps.healthColorHex = "#3E7CB1" DataChange.commit(context: context) } return } let goal = Goal(title: title, symbolName: "heart.fill", colorHex: "#E0558C", startDate: Calendar.current.date(byAdding: .day, value: -7, to: .now) ?? .now, endDate: nil) context.insert(goal) func addQuest(_ raw: String, seconds: Double = 0, count: Int = 0, isDuration: Bool) { let quest = Quest(goal: goal) quest.healthMetricRaw = raw quest.measure = isDuration ? .time : .count if isDuration { quest.targetSeconds = seconds } else { quest.targetCount = count } quest.period = .daily quest.sortOrder = (goal.quests.map(\.sortOrder).max() ?? -1) + 1 context.insert(quest) } addQuest(HealthMetric.steps.rawValue, count: 8000, isDuration: false) // 걸음 다짐에 지정 색 시드 — 나머지는 기본 분홍 유지 (색 지정/기본의 대비 렌더 검증, 1.5(7)) goal.quests.first { $0.healthMetricRaw == HealthMetric.steps.rawValue }?.healthColorHex = "#3E7CB1" addQuest(HealthMetric.sleep.rawValue, seconds: 8 * 3600, isDuration: true) addQuest("workout.running", seconds: 30 * 60, isDuration: true) addQuest(HealthMetric.water.rawValue, count: 2000, isDuration: false) DataChange.commit(context: context) } /// `-healthQuestDump YES`: 건강 다짐들의 캐시 키·오늘 값·하루 진행률을 덤프 (1.5 디버그) static func dumpHealthQuestsIfRequested(context: ModelContext) { guard UserDefaults.standard.bool(forKey: "healthQuestDump") else { return } var lines: [String] = [] let math = DayMath() let today = math.dayKey(for: .now) lines.append("todayToken=\(HealthCache.dayToken(today)) deviceAvailable=\(HealthSupport.deviceAvailable)") let quests = (try? context.fetch(FetchDescriptor())) ?? [] for quest in quests where !quest.healthMetricRaw.isEmpty { let target = quest.healthTarget let key = target.map { HealthQuestValues.cacheKey(target: $0, quest: quest) } ?? "nil-target" let dayValue = target.flatMap { HealthQuestValues.dayValue(target: $0, quest: quest, dayKey: today) } let result = QuestProgress(quest: quest, math: math).spanProgress(.day) lines.append("raw=\(quest.healthMetricRaw) key=\(key) window=\(quest.sleepWindowStartMinutes)-\(quest.sleepWindowEndMinutes) measure=\(quest.measureRaw) dayValue=\(dayValue.map { String($0) } ?? "nil") value=\(result.value) target=\(result.target) display=\(result.displayRatio)") } if let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { try? lines.joined(separator: "\n") .write(to: docs.appendingPathComponent("health-quest-dump.txt"), atomically: true, encoding: .utf8) } } /// `-seedEmptyGoal YES`: 다짐 없는 진행 중 목표 1개 추가 — /// 위젯 ②·일기 카드의 '다짐 없음' 상태 표기 검증용 (기본 시드·마케팅 스크린샷과 분리) static func seedEmptyGoalIfRequested(context: ModelContext) { guard UserDefaults.standard.bool(forKey: "seedEmptyGoal") else { return } let title = String(localized: "새해 다짐 정리하기") let existing = (try? context.fetch(FetchDescriptor()))?.contains { $0.title == title } ?? false guard !existing else { return } let goal = Goal(title: title, symbolName: "sparkles", colorHex: "#D9A621", startDate: .now, endDate: nil) context.insert(goal) try? context.save() } static func seedIfRequested(context: ModelContext) { guard UserDefaults.standard.bool(forKey: "seedDemo") else { return } let existing = (try? context.fetchCount(FetchDescriptor())) ?? 0 guard existing == 0 else { return } let study = Tag(name: String(localized: "공부"), colorHex: "#4A7A9D") let workout = Tag(name: String(localized: "운동"), colorHex: "#9D5B4A") let life = Tag(name: String(localized: "생활"), colorHex: "#2F6B4F") context.insert(study) context.insert(workout) context.insert(life) let reading = Action(name: String(localized: "독서"), symbolName: "book.fill", trackingType: .time, sortOrder: 0) reading.tags = [study] reading.isFavorite = true reading.promptsForNote = true let english = Action(name: String(localized: "영어 공부"), symbolName: "graduationcap.fill", trackingType: .time, sortOrder: 1) english.tags = [study] let running = Action(name: String(localized: "달리기"), symbolName: "figure.run", trackingType: .time, sortOrder: 2) running.tags = [workout] running.isFavorite = true let pushup = Action(name: String(localized: "팔굽혀펴기"), symbolName: "dumbbell.fill", trackingType: .count, sortOrder: 3) pushup.tags = [workout] let water = Action(name: String(localized: "물 마시기"), symbolName: "drop.fill", trackingType: .count, sortOrder: 4) water.tags = [life] water.promptsForNote = true water.isFavorite = true // '이하 유지' 다짐 검증용 (하루 1시간 이하 목표, 일부러 초과하는 날 포함) let video = Action(name: String(localized: "영상 시청"), symbolName: "play.rectangle.fill", trackingType: .time, sortOrder: 5) video.tags = [life] for action in [reading, english, running, pushup, water, video] { context.insert(action) } let now = Date.now let cal = Calendar.current func at(daysAgo: Int, hour: Int, minute: Int = 0) -> Date { let day = cal.date(byAdding: .day, value: -daysAgo, to: now)! return cal.date(bySettingHour: hour, minute: minute, second: 0, of: day)! } // 시간 기록 (며칠치) for daysAgo in 0...6 { context.insert(TimeSession(action: english, startAt: at(daysAgo: daysAgo, hour: 8), endAt: at(daysAgo: daysAgo, hour: 9, minute: 10))) if daysAgo % 2 == 0 { context.insert(TimeSession(action: running, startAt: at(daysAgo: daysAgo, hour: 7), endAt: at(daysAgo: daysAgo, hour: 7, minute: 40))) } if daysAgo % 3 != 0 { context.insert(TimeSession(action: reading, startAt: at(daysAgo: daysAgo, hour: 21), endAt: at(daysAgo: daysAgo, hour: 22, minute: 15))) } // 영상 시청: 짝수 날은 40분(1시간 이하 유지), 홀수 날은 1시간 30분(초과) if daysAgo % 2 == 0 { context.insert(TimeSession(action: video, startAt: at(daysAgo: daysAgo, hour: 12, minute: 30), endAt: at(daysAgo: daysAgo, hour: 13, minute: 10))) } else { context.insert(TimeSession(action: video, startAt: at(daysAgo: daysAgo, hour: 12, minute: 30), endAt: at(daysAgo: daysAgo, hour: 14, minute: 0))) } } // 자정을 걸치는 세션 (하루 경계 분할 확인용) + 메모 표시 확인용 let crossing = TimeSession(action: reading, startAt: at(daysAgo: 1, hour: 23, minute: 20), endAt: at(daysAgo: 0, hour: 0, minute: 40)) crossing.note = String(localized: "자기 전에 소설 읽음. 재밌어서 늦게 잠") context.insert(crossing) // 진행 중 세션 context.insert(TimeSession(action: reading, startAt: now.addingTimeInterval(-25 * 60))) // 횟수 기록 for daysAgo in 0...6 { for i in 0..<(3 + daysAgo % 4) { context.insert(CountEntry(action: water, timestamp: at(daysAgo: daysAgo, hour: 9 + i * 2))) } if daysAgo % 2 == 0 { context.insert(CountEntry(action: pushup, timestamp: at(daysAgo: daysAgo, hour: 19), amount: 20)) } } // 메모가 달린 횟수 기록 (기록 탭 메모 표시 확인용) let notedWater = CountEntry(action: water, timestamp: at(daysAgo: 0, hour: 20)) notedWater.note = String(localized: "자기 전 물 한 컵") context.insert(notedWater) // 목표 + 다짐 let toeic = Goal( title: String(localized: "토익 700점 이상 받기"), symbolName: "graduationcap.fill", colorHex: "#4A7A9D", startDate: cal.date(byAdding: .day, value: -10, to: now)!, endDate: cal.date(byAdding: .day, value: 30, to: now)! ) context.insert(toeic) let englishQuest = Quest(goal: toeic) englishQuest.targetAction = english englishQuest.measure = .time englishQuest.period = .daily englishQuest.targetSeconds = 3600 englishQuest.direction = .atLeast context.insert(englishQuest) let health = Goal( title: String(localized: "건강한 생활 습관 만들기"), symbolName: "heart.fill", colorHex: "#2F6B4F", startDate: cal.date(byAdding: .day, value: -20, to: now)!, endDate: nil ) // 다짐 3개(이하 유지 포함)라 모음 탭 목표 카드 검증에 적합 health.showsOnMain = true context.insert(health) let waterQuest = Quest(goal: health) waterQuest.targetAction = water waterQuest.measure = .count waterQuest.period = .daily waterQuest.targetCount = 8 waterQuest.direction = .atLeast context.insert(waterQuest) let runQuest = Quest(goal: health) runQuest.targetAction = running runQuest.measure = .time runQuest.period = .daily runQuest.scheduleMode = .weekdays runQuest.weekdays = [2, 4, 6] runQuest.targetSeconds = 30 * 60 runQuest.direction = .atLeast context.insert(runQuest) // 다짐 4개째: 모음 탭 목표 카드의 '더 보기' 접힘/펼침 검증용 let pushupQuest = Quest(goal: health) pushupQuest.targetAction = pushup pushupQuest.measure = .count pushupQuest.period = .daily pushupQuest.targetCount = 20 pushupQuest.direction = .atLeast context.insert(pushupQuest) // '이하 유지' 다짐: 영상 시청 하루 1시간 이하 (달성률 로직 검증용) let videoQuest = Quest(goal: health) videoQuest.targetAction = video videoQuest.measure = .time videoQuest.period = .daily videoQuest.targetSeconds = 3600 videoQuest.direction = .atMost context.insert(videoQuest) // 세 번째 목표 (모음 탭 다중 목표 카드 검증용) let habit = Goal( title: String(localized: "매일 밤 독서 습관"), symbolName: "book.fill", colorHex: "#9D5B4A", startDate: cal.date(byAdding: .day, value: -5, to: now)!, endDate: cal.date(byAdding: .day, value: 60, to: now)! ) context.insert(habit) let readingQuest = Quest(goal: habit) readingQuest.targetAction = reading readingQuest.measure = .time readingQuest.period = .daily readingQuest.targetSeconds = 45 * 60 readingQuest.direction = .atLeast context.insert(readingQuest) // 네 번째 목표 (모음 탭 목표 카드 개수 제한 해제 검증용) let run = Goal( title: String(localized: "꾸준한 달리기"), symbolName: "figure.run", colorHex: "#4A7A9D", startDate: cal.date(byAdding: .day, value: -14, to: now)!, endDate: cal.date(byAdding: .day, value: 45, to: now)! ) context.insert(run) let weeklyRunQuest = Quest(goal: run) weeklyRunQuest.targetAction = running weeklyRunQuest.measure = .time weeklyRunQuest.period = .weekly weeklyRunQuest.targetSeconds = 2 * 3600 weeklyRunQuest.direction = .atLeast context.insert(weeklyRunQuest) // 다섯 번째 목표: 최근 시작 + 초과한 날 없는 '이하 유지' 다짐 // (연속 계산이 목표 시작일 이전으로 새지 않는지 검증용 — 기대값 연속 4일, 버그면 401일) let screen = Goal( title: String(localized: "스크린 타임 관리"), symbolName: "iphone", colorHex: "#4A7A9D", startDate: cal.date(byAdding: .day, value: -3, to: now)!, endDate: nil ) context.insert(screen) let screenQuest = Quest(goal: screen) screenQuest.targetAction = video screenQuest.measure = .time screenQuest.period = .daily screenQuest.targetSeconds = 3 * 3600 // 기록 최대 90분이라 절대 초과하지 않음 screenQuest.direction = .atMost context.insert(screenQuest) // 여섯 번째 목표: 마감 시각 다짐 검증용 — "오전 8시까지 일어나기 1회 이상" // 기록: 오늘 07:30(창 안)+08:10(창 밖 — 집계 제외돼 하루 100%가 200%로 뻥튀기되면 버그), // 어제 08:30(창 밖 → 그날 실패), 그저께 07:00(창 안). // 기대값: 하루 진행률 100%, 연속 1일(어제 실패로 끊김), streak-dump에 deadline=480. let wake = Action(name: String(localized: "일찍 일어나기"), symbolName: "sunrise.fill", trackingType: .count, sortOrder: 6) wake.tags = [life] context.insert(wake) context.insert(CountEntry(action: wake, timestamp: at(daysAgo: 0, hour: 7, minute: 30))) context.insert(CountEntry(action: wake, timestamp: at(daysAgo: 0, hour: 8, minute: 10))) context.insert(CountEntry(action: wake, timestamp: at(daysAgo: 1, hour: 8, minute: 30))) context.insert(CountEntry(action: wake, timestamp: at(daysAgo: 2, hour: 7, minute: 0))) let morning = Goal( title: String(localized: "아침 루틴 지키기"), symbolName: "sunrise.fill", colorHex: "#D9A621", startDate: cal.date(byAdding: .day, value: -7, to: now)!, endDate: nil ) context.insert(morning) let wakeQuest = Quest(goal: morning) wakeQuest.targetAction = wake wakeQuest.measure = .count wakeQuest.period = .daily wakeQuest.targetCount = 1 wakeQuest.direction = .atLeast wakeQuest.deadlineMinutes = 8 * 60 context.insert(wakeQuest) // 목표 탭 정렬 순서 부여 for (index, goal) in [toeic, health, habit, run, screen, morning].enumerated() { goal.sortOrder = index } // 완료된 목표 (완료 목표 분리 화면 검증용 — 달성 1개 + 미달성 1개) let sleep = Goal( title: String(localized: "일찍 자기 챌린지"), symbolName: "moon.zzz.fill", colorHex: "#2F6B4F", startDate: cal.date(byAdding: .day, value: -60, to: now)!, endDate: cal.date(byAdding: .day, value: -30, to: now)! ) sleep.status = .achieved sleep.sortOrder = 6 context.insert(sleep) let diet = Goal( title: String(localized: "한 달 다이어트"), symbolName: "fork.knife", colorHex: "#9D5B4A", startDate: cal.date(byAdding: .day, value: -50, to: now)!, endDate: cal.date(byAdding: .day, value: -20, to: now)! ) diet.status = .notAchieved diet.sortOrder = 7 context.insert(diet) // 기기별 로컬 UI 설정 시드: 메모 창 행동, 모음 탭 노출 목표 (LocalPrefs 참고) LocalPrefs.setPromptsForNote(reading.uuid, enabled: true) LocalPrefs.setPromptsForNote(water.uuid, enabled: true) LocalPrefs.defaults.set( LocalPrefs.rawValue([health.uuid]), forKey: LocalPrefsKeys.pinnedGoals ) } } #endif