mycode/myApp/HaruDanim/IOS/Core/DebugSeed.swift
songyc macbook 78bad15852 feat(1.5-p3): 건강 다짐 — 8지표+운동 종목 20종, 값 공급 이음새로 §4.2 수학 무변경
- Quest 필드 3종(healthMetricRaw·수면 구간, CloudKit 기본값 규칙)+행동/꼬리표 우선 정규화
- HealthQuestTarget/HealthWorkoutKind(종목 큐레이션)·HealthCache 공유 계층 이전(위젯 읽기)
- QuestProgress 값 공급 단일 지점 3곳(value·spanValue·perDayValues)만 분기 — 기존 49건 무변경 그린
- 편집기 대상 3유형째(지표·종목·수면 구간 휠·단위별 목표 입력·프리미엄 게이트·하루 전용)
- §8ⓒⓕ: 데이터 없는 기기 모수 제외+'데이터 없음' 표기(목표 행·모음 카드·위젯 ②③④)
  +자동/수동 판정 보류, 위젯 값 라벨 지표 단위화, HealthStore 백필(400일)·운동 조회·부트스트랩
- 검증: progressSelfTest 61건(H 시리즈 12 신규)·20·27·10 ALL PASS(26.5+18.5),
  Debug/Store/워치 빌드, 시각 QA(목표 행·편집기·타일). 신규 인자 3종 §14 기록

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
2026-08-22 06:07:25 +09:00

987 lines
56 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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<Action>(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<Action>(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<Goal>(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<Quest>())) ?? []
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>` : 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<Goal>(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>` : 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<Action>(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>` : N ( )
static func pinGoalsIfRequested(context: ModelContext) {
let n = UserDefaults.standard.integer(forKey: "pinGoals")
guard n > 0 else { return }
let descriptor = FetchDescriptor<Goal>(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<Goal>()))?.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 }
let title = "건강 습관 만들기"
let existing = (try? context.fetch(FetchDescriptor<Goal>())) ?? []
guard !existing.contains(where: { $0.title == title }) else { 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)
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<Quest>())) ?? []
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<Goal>()))?.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<Action>())) ?? 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