- HealthMetric 8종(표준 단위·표기) + HealthStore(HK 조회: 누적 통계·스탠드·마음챙김· 수면 구간[합집합 병합, 깨어난 날 귀속 — plan §3.4 규칙]) + HealthCache(App Group, 지표×dayKey, 400일 보존 — 5.1.3 준수: CloudKit 비저장) - 모음 탭 건강 섹션(타일 줄+접기·연결 CTA[명시적 탭 권한]·안내 시트[사용자 요구 가이드]· 지표 선택 시트[체크+드래그 순서]) — actionGrid·레이아웃 금지구역 무접촉, 편집 모드 숨김 - 3섹션(즐겨찾기·나머지·건강) 순서 렌더러 + 설정 탭 표시 토글·순서 화면(기기 로컬) - HealthKit 엔타이틀먼트(iOS 앱만)·NSHealthShareUsageDescription, 전체 초기화에 건강 키·캐시 청소 추가 - 검증: Debug/Store 빌드, 자가 검증 106건 ALL PASS(26.5+18.5), 시각 QA 7장 (타일·순서·설정·안내·지표·CTA·18.5 빈 상태). 신규 인자 3종 §14 기록 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
285 lines
15 KiB
Swift
285 lines
15 KiB
Swift
//
|
||
// 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,
|
||
// 1.5 건강 보기 설정 — 값 자체는 건강 앱 소유라 캐시·표시 설정만 정리
|
||
LocalPrefsKeys.healthTilesEnabled, LocalPrefsKeys.mainSectionOrder,
|
||
LocalPrefsKeys.healthCollapsed, LocalPrefsKeys.healthMetrics,
|
||
]
|
||
for key in keys { LocalPrefs.defaults.removeObject(forKey: key) }
|
||
HealthCache.clearAll()
|
||
}
|
||
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
|
||
}
|