// // DebugSeed.swift // Haru_Danim // // 개발 검증용 데모 데이터. 런치 인자 `-seedDemo YES`가 있을 때만 동작. (DEBUG 전용) // #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("달리기"), let water = entity("물 마시기") 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("완료", "스모크 테스트 끝") } /// `-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")" ) } let url = URL.documentsDirectory.appending(path: "streak-dump.txt") try? lines.joined(separator: "\n").write(to: url, atomically: true, encoding: .utf8) } /// `-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 ) } 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: "공부", colorHex: "#4A7A9D") let workout = Tag(name: "운동", colorHex: "#9D5B4A") let life = Tag(name: "생활", colorHex: "#2F6B4F") context.insert(study) context.insert(workout) context.insert(life) let reading = Action(name: "독서", symbolName: "book.fill", trackingType: .time, sortOrder: 0) reading.tags = [study] reading.isFavorite = true reading.promptsForNote = true let english = Action(name: "영어 공부", symbolName: "graduationcap.fill", trackingType: .time, sortOrder: 1) english.tags = [study] let running = Action(name: "달리기", symbolName: "figure.run", trackingType: .time, sortOrder: 2) running.tags = [workout] let pushup = Action(name: "팔굽혀펴기", symbolName: "dumbbell.fill", trackingType: .count, sortOrder: 3) pushup.tags = [workout] let water = Action(name: "물 마시기", symbolName: "drop.fill", trackingType: .count, sortOrder: 4) water.tags = [life] water.promptsForNote = true // '이하 유지' 다짐 검증용 (하루 1시간 이하 목표, 일부러 초과하는 날 포함) let video = Action(name: "영상 시청", 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 = "자기 전에 소설 읽음. 재밌어서 늦게 잠" 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 = "자기 전 물 한 컵" context.insert(notedWater) // 목표 + 다짐 let toeic = Goal( title: "토익 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: "건강한 생활 습관 만들기", 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: "매일 밤 독서 습관", 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: "꾸준한 달리기", 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: "스크린 타임 관리", 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) // 목표 탭 정렬 순서 부여 for (index, goal) in [toeic, health, habit, run, screen].enumerated() { goal.sortOrder = index } // 완료된 목표 (완료 목표 분리 화면 검증용 — 달성 1개 + 미달성 1개) let sleep = Goal( title: "일찍 자기 챌린지", 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 = 5 context.insert(sleep) let diet = Goal( title: "한 달 다이어트", 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 = 6 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