// // 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( predicate: #Predicate { ($0.endAt ?? farFuture) <= upper } )) for session in sessions { context.delete(session) } counts.sessions = sessions.count let entries = try context.fetch(FetchDescriptor( predicate: #Predicate { $0.timestamp < upper } )) for entry in entries { context.delete(entry) } counts.entries = entries.count // 일기 키는 달력일 자정 — 키끼리 직접 비교 (§4.1) let diaryEntries = try context.fetch(FetchDescriptor( 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 if let endDayKey { descriptor = FetchDescriptor(predicate: #Predicate { $0.dayKey <= endDayKey }) } else { descriptor = FetchDescriptor() } 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()) for entry in diaryEntries { context.delete(entry) } counts.diaryEntries = diaryEntries.count let templates = try context.fetch(FetchDescriptor()) for template in templates { context.delete(template) } counts.templates = templates.count let goals = try context.fetch(FetchDescriptor()) for goal in goals { context.delete(goal) } counts.goals = goals.count let actions = try context.fetch(FetchDescriptor()) for action in actions { context.delete(action) } counts.actions = actions.count let tags = try context.fetch(FetchDescriptor()) for tag in tags { context.delete(tag) } counts.tags = tags.count // 행동 cascade가 못 미치는 잔존 기록(행동 연결이 없는 고아 등)까지 정리 let sessions = try context.fetch(FetchDescriptor()) for session in sessions { context.delete(session) } counts.sessions = sessions.count let entries = try context.fetch(FetchDescriptor()) 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, ] 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())) ?? -1, 3) expect("범위: 남은 횟수", (try? context.fetchCount(FetchDescriptor())) ?? -1, 1) expect("범위: 남은 일기", (try? context.fetchCount(FetchDescriptor())) ?? -1, 1) expect("범위: 일기 페이지 cascade", (try? context.fetchCount(FetchDescriptor())) ?? -1, 0) expect("범위: 행동 보존", (try? context.fetchCount(FetchDescriptor())) ?? -1, 2) expect("범위: 꼬리표 보존", (try? context.fetchCount(FetchDescriptor())) ?? -1, 1) expect("범위: 목표 보존", (try? context.fetchCount(FetchDescriptor())) ?? -1, 1) expect("범위: 양식 보존", (try? context.fetchCount(FetchDescriptor())) ?? -1, 1) // ---- 전체 초기화 (인메모리라 LocalPrefs는 건드리지 않음) ---- _ = try DataReset.deleteAll(context: context, includeLocalPrefs: false) try context.save() expect("전체: 세션 0", (try? context.fetchCount(FetchDescriptor())) ?? -1, 0) expect("전체: 횟수 0", (try? context.fetchCount(FetchDescriptor())) ?? -1, 0) expect("전체: 행동 0", (try? context.fetchCount(FetchDescriptor())) ?? -1, 0) expect("전체: 꼬리표 0", (try? context.fetchCount(FetchDescriptor())) ?? -1, 0) expect("전체: 목표 0", (try? context.fetchCount(FetchDescriptor())) ?? -1, 0) expect("전체: 일기 0", (try? context.fetchCount(FetchDescriptor())) ?? -1, 0) expect("전체: 양식 0", (try? context.fetchCount(FetchDescriptor())) ?? -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())) ?? -1, 1) expect("일기만(날짜까지): 페이지 cascade", (try? context2.fetchCount(FetchDescriptor())) ?? -1, 1) expect("일기만(날짜까지): 기록 보존", (try? context2.fetchCount(FetchDescriptor())) ?? -1, 1) let diaryAll = try DataReset.deleteDiaries(upToDayKey: nil, context: context2) try context2.save() expect("일기만(전체): 삭제 수", diaryAll.diaryEntries, 1) expect("일기만(전체): 일기 0", (try? context2.fetchCount(FetchDescriptor())) ?? -1, 0) expect("일기만(전체): 기록 보존", (try? context2.fetchCount(FetchDescriptor())) ?? -1, 1) expect("일기만(전체): 행동 보존", (try? context2.fetchCount(FetchDescriptor())) ?? -1, 1) expect("일기만(전체): 양식 보존", (try? context2.fetchCount(FetchDescriptor())) ?? -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 }