mycode/myApp/HaruDanim/IOS/Core/DataReset.swift
songyc macbook 92afa69f6d feat(1.5-p1): 워치 '행동 기록' 컴플리케이션 — 기간 옵트인·미니 행동 화면 딥링크
- WatchActionPeriod(하루/이번 주/이번 달/지난 7일/지난 30일) + WatchActionInfo에
  optional 기간 필드 3종(구버전 캐시 호환 — isFavorite 패턴)
- LocalPrefs.watchActions(uuid→기간 맵, 기기 로컬) + 전체 초기화 청소 목록 반영
- makeSnapshot: 옵트인 행동만 기간값 계산(rollingRange 재사용, StatSpan 무변경)
- 신규 ActionValueComplication(4패밀리·측정 중 노랑+타이머·유령 타이머 안전장치·
  빈 상태 안내·recommendations verbatim·widgetURL 딥링크)
- 워치 미니 행동 화면(WatchActionDetailView) + NavigationPath 딥링크 착지
  (뒤로가기 한 번에 메인 복귀), 행동 편집기 애플워치 섹션(토글+기간)
- 검증 인자: -watchActions <N>·-actionShowEditor·-watchOpenActionDetail
- 검증: Debug/Store/워치(26.2·11.5) 빌드, 자가 검증 49·20·27·10 ALL PASS(26.5+18.5 심),
  시각 QA 5장. 신규 문구 l10n은 계획 §10 R6대로 도움말 단계에서 일괄
- CLAUDE.md: 시뮬레이터 세트 리셋 반영(신규 UUID)·검증 인자 추가

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

281 lines
15 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// DataReset.swift
// Haru_Danim
//
// ·(1.4) .
// CloudKit .
// (§5.1). ·
// DataChange.commit .
//
// ( , 1.4):
// - " " · , (··
// ·)
// - ( )
// ,
// - ( )
//
import Foundation
import SwiftData
enum DataReset {
struct Counts {
var sessions = 0
var entries = 0
var diaryEntries = 0
var actions = 0
var tags = 0
var goals = 0
var templates = 0
}
// MARK: ( ~ endDayKey)
/// endDayKey( , ) · . .
@MainActor
static func deleteRecords(upToDayKey endDayKey: Date, context: ModelContext,
math: DayMath) throws -> Counts {
var counts = Counts()
let upper = math.dayRange(forKey: endDayKey).upperBound
let farFuture = Date.distantFuture
// ( nil farFuture )
let sessions = try context.fetch(FetchDescriptor<TimeSession>(
predicate: #Predicate { ($0.endAt ?? farFuture) <= upper }
))
for session in sessions { context.delete(session) }
counts.sessions = sessions.count
let entries = try context.fetch(FetchDescriptor<CountEntry>(
predicate: #Predicate { $0.timestamp < upper }
))
for entry in entries { context.delete(entry) }
counts.entries = entries.count
// (§4.1)
let diaryEntries = try context.fetch(FetchDescriptor<DiaryEntry>(
predicate: #Predicate { $0.dayKey <= endDayKey }
))
for entry in diaryEntries { context.delete(entry) }
counts.diaryEntries = diaryEntries.count
return counts
}
// MARK: (1.4(2) ·· )
/// . endDayKey nil , ().
/// cascade ·· · , .
@MainActor
static func deleteDiaries(upToDayKey endDayKey: Date?, context: ModelContext) throws -> Counts {
var counts = Counts()
let descriptor: FetchDescriptor<DiaryEntry>
if let endDayKey {
descriptor = FetchDescriptor<DiaryEntry>(predicate: #Predicate { $0.dayKey <= endDayKey })
} else {
descriptor = FetchDescriptor<DiaryEntry>()
}
let entries = try context.fetch(descriptor)
for entry in entries { context.delete(entry) }
counts.diaryEntries = entries.count
return counts
}
// MARK:
/// (cascade: , , ·· ).
/// includeLocalPrefs=true · App Group
/// ( false defaults ).
@MainActor
static func deleteAll(context: ModelContext, includeLocalPrefs: Bool = true) throws -> Counts {
var counts = Counts()
let diaryEntries = try context.fetch(FetchDescriptor<DiaryEntry>())
for entry in diaryEntries { context.delete(entry) }
counts.diaryEntries = diaryEntries.count
let templates = try context.fetch(FetchDescriptor<DiaryTemplate>())
for template in templates { context.delete(template) }
counts.templates = templates.count
let goals = try context.fetch(FetchDescriptor<Goal>())
for goal in goals { context.delete(goal) }
counts.goals = goals.count
let actions = try context.fetch(FetchDescriptor<Action>())
for action in actions { context.delete(action) }
counts.actions = actions.count
let tags = try context.fetch(FetchDescriptor<Tag>())
for tag in tags { context.delete(tag) }
counts.tags = tags.count
// cascade ( )
let sessions = try context.fetch(FetchDescriptor<TimeSession>())
for session in sessions { context.delete(session) }
counts.sessions = sessions.count
let entries = try context.fetch(FetchDescriptor<CountEntry>())
for entry in entries { context.delete(entry) }
counts.entries = entries.count
if includeLocalPrefs {
let keys = [
LocalPrefsKeys.actionOrder, LocalPrefsKeys.pinnedGoals,
LocalPrefsKeys.collapsedGoals, LocalPrefsKeys.notePromptActions,
LocalPrefsKeys.watchGoals, LocalPrefsKeys.mainOthersCollapsed,
LocalPrefsKeys.watchActions,
]
for key in keys { LocalPrefs.defaults.removeObject(forKey: key) }
}
return counts
}
// MARK: (-dataResetTest · , )
#if DEBUG
@MainActor
static func selfTestIfRequested() {
guard UserDefaults.standard.bool(forKey: "dataResetTest") else { return }
var lines: [String] = []
var failures = 0
func expect(_ label: String, _ actual: Int, _ expected: Int) {
let pass = actual == expected
if !pass { failures += 1 }
lines.append("\(pass ? "PASS" : "FAIL") \(label): actual \(actual) / expected \(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))
// : (cut) · ·· + + +
let cal = math.calendar
let today = cal.startOfDay(for: Date(timeIntervalSince1970: 1_754_000_000)) //
let cutKey = cal.date(byAdding: .day, value: -7, to: today)! // 7
let cutUpper = math.dayRange(forKey: cutKey).upperBound
let tag = Tag(name: "테스트", colorHex: "#2F6B4F")
context.insert(tag)
let action = Action(name: "검증행동", symbolName: "book.fill", trackingType: .time, sortOrder: 0)
context.insert(action)
let countAction = Action(name: "검증횟수", symbolName: "number", trackingType: .count, sortOrder: 1)
context.insert(countAction)
let goal = Goal(title: "검증목표", symbolName: "flag.fill", colorHex: "#2F6B4F",
startDate: cutUpper.addingTimeInterval(-86400 * 30), endDate: nil)
context.insert(goal)
// ( )
context.insert(TimeSession(action: action,
startAt: cutUpper.addingTimeInterval(-7200),
endAt: cutUpper.addingTimeInterval(-3600)))
// ( , )
context.insert(TimeSession(action: action,
startAt: cutUpper.addingTimeInterval(-1800),
endAt: cutUpper.addingTimeInterval(1800)))
// ()
context.insert(TimeSession(action: action,
startAt: cutUpper.addingTimeInterval(3600),
endAt: cutUpper.addingTimeInterval(7200)))
//
context.insert(TimeSession(action: action,
startAt: cutUpper.addingTimeInterval(-600), endAt: nil))
// : 2 + 1
context.insert(CountEntry(action: countAction,
timestamp: cutUpper.addingTimeInterval(-100), amount: 1))
context.insert(CountEntry(action: countAction,
timestamp: cutUpper.addingTimeInterval(-200), amount: 2))
context.insert(CountEntry(action: countAction,
timestamp: cutUpper.addingTimeInterval(100), amount: 3))
// : ()· = , = . cascade 1
let diaryOld = DiaryEntry(dayKey: cal.date(byAdding: .day, value: -1, to: cutKey)!)
context.insert(diaryOld)
let page = DiaryPage(index: 1)
page.entry = diaryOld
context.insert(page)
let diaryCut = DiaryEntry(dayKey: cutKey)
context.insert(diaryCut)
let diaryKeep = DiaryEntry(dayKey: cal.date(byAdding: .day, value: 1, to: cutKey)!)
context.insert(diaryKeep)
let template = DiaryTemplate(name: "양식", kind: .image)
context.insert(template)
try context.save()
// ---- ----
let rangeCounts = try DataReset.deleteRecords(upToDayKey: cutKey, context: context, math: math)
try context.save()
expect("범위: 삭제된 세션 수(완전 포함만)", rangeCounts.sessions, 1)
expect("범위: 삭제된 횟수 수", rangeCounts.entries, 2)
expect("범위: 삭제된 일기 수(기준일 포함)", rangeCounts.diaryEntries, 2)
expect("범위: 남은 세션(걸침+밖+진행 중)",
(try? context.fetchCount(FetchDescriptor<TimeSession>())) ?? -1, 3)
expect("범위: 남은 횟수", (try? context.fetchCount(FetchDescriptor<CountEntry>())) ?? -1, 1)
expect("범위: 남은 일기", (try? context.fetchCount(FetchDescriptor<DiaryEntry>())) ?? -1, 1)
expect("범위: 일기 페이지 cascade", (try? context.fetchCount(FetchDescriptor<DiaryPage>())) ?? -1, 0)
expect("범위: 행동 보존", (try? context.fetchCount(FetchDescriptor<Action>())) ?? -1, 2)
expect("범위: 꼬리표 보존", (try? context.fetchCount(FetchDescriptor<Tag>())) ?? -1, 1)
expect("범위: 목표 보존", (try? context.fetchCount(FetchDescriptor<Goal>())) ?? -1, 1)
expect("범위: 양식 보존", (try? context.fetchCount(FetchDescriptor<DiaryTemplate>())) ?? -1, 1)
// ---- ( LocalPrefs ) ----
_ = try DataReset.deleteAll(context: context, includeLocalPrefs: false)
try context.save()
expect("전체: 세션 0", (try? context.fetchCount(FetchDescriptor<TimeSession>())) ?? -1, 0)
expect("전체: 횟수 0", (try? context.fetchCount(FetchDescriptor<CountEntry>())) ?? -1, 0)
expect("전체: 행동 0", (try? context.fetchCount(FetchDescriptor<Action>())) ?? -1, 0)
expect("전체: 꼬리표 0", (try? context.fetchCount(FetchDescriptor<Tag>())) ?? -1, 0)
expect("전체: 목표 0", (try? context.fetchCount(FetchDescriptor<Goal>())) ?? -1, 0)
expect("전체: 일기 0", (try? context.fetchCount(FetchDescriptor<DiaryEntry>())) ?? -1, 0)
expect("전체: 양식 0", (try? context.fetchCount(FetchDescriptor<DiaryTemplate>())) ?? -1, 0)
// ---- 2: ( · ) ----
let container2 = try ModelContainer(
for: DataStore.schema,
configurations: [ModelConfiguration(isStoredInMemoryOnly: true)]
)
let context2 = container2.mainContext
let action2 = Action(name: "일기검증행동", symbolName: "book.fill", trackingType: .time, sortOrder: 0)
context2.insert(action2)
context2.insert(TimeSession(action: action2,
startAt: cutUpper.addingTimeInterval(-7200),
endAt: cutUpper.addingTimeInterval(-3600)))
let template2 = DiaryTemplate(name: "양식2", kind: .image)
context2.insert(template2)
for offset in [-1, 0, 1] {
let entry = DiaryEntry(dayKey: cal.date(byAdding: .day, value: offset, to: cutKey)!)
context2.insert(entry)
let page = DiaryPage(index: 1)
page.entry = entry
context2.insert(page)
}
try context2.save()
let diaryRange = try DataReset.deleteDiaries(upToDayKey: cutKey, context: context2)
try context2.save()
expect("일기만(날짜까지): 삭제 수(기준일 포함)", diaryRange.diaryEntries, 2)
expect("일기만(날짜까지): 남은 일기", (try? context2.fetchCount(FetchDescriptor<DiaryEntry>())) ?? -1, 1)
expect("일기만(날짜까지): 페이지 cascade", (try? context2.fetchCount(FetchDescriptor<DiaryPage>())) ?? -1, 1)
expect("일기만(날짜까지): 기록 보존", (try? context2.fetchCount(FetchDescriptor<TimeSession>())) ?? -1, 1)
let diaryAll = try DataReset.deleteDiaries(upToDayKey: nil, context: context2)
try context2.save()
expect("일기만(전체): 삭제 수", diaryAll.diaryEntries, 1)
expect("일기만(전체): 일기 0", (try? context2.fetchCount(FetchDescriptor<DiaryEntry>())) ?? -1, 0)
expect("일기만(전체): 기록 보존", (try? context2.fetchCount(FetchDescriptor<TimeSession>())) ?? -1, 1)
expect("일기만(전체): 행동 보존", (try? context2.fetchCount(FetchDescriptor<Action>())) ?? -1, 1)
expect("일기만(전체): 양식 보존", (try? context2.fetchCount(FetchDescriptor<DiaryTemplate>())) ?? -1, 1)
} catch {
failures += 1
lines.append("FAIL 예외: \(error)")
}
lines.append(failures == 0 ? "== ALL PASS ==" : "== \(failures) FAILURES ==")
let url = URL.documentsDirectory.appending(path: "data-reset-test.txt")
try? lines.joined(separator: "\n").write(to: url, atomically: true, encoding: .utf8)
}
#endif
}