맥 일기 (§6.7): - 사이드바에 일기 탭 개방 (AppTab.sidebarCases 맥 필터 제거) - 맥 노트 도구줄: 텍스트 우선 가로 스크롤 + '그리기' 토글(마우스·트랙패드 필기, drawingPolicy anyInput) - 그리기↔배치 상호 배타, 일기 잠금 설정 맥 노출 — 아이패드 경로는 코드 불변(회귀 차단) CSV 가져오기 (§6.8·프리미엄): - IOS/Core/CSVImport.swift: RFC4180 파서·데이터 행 모양 판별(언어 무관)·계획/반영 분리·초 단위 멱등 중복 처리 - 행동 연결: uuid 우선 → 이름+방식 폴백 → 새로 생성(uuid 보존), '측정 중' 행 제외, 추가 전용 - DataImportView: 파일 선택(복수) → 미리 보기 → 확인 → 저장 1회. 무료 한도 우회 방지 위해 프리미엄 게이트 - 자가 테스트 -csvImportTest 20건 ALL PASS, 진행률 자가 테스트 회귀 ALL PASS 문서·현지화·버전: - 도움말(일기 맥·CSV 복원)·프리미엄 기능 행·설정 문구 갱신, en/ja 42키 번역·stale 10키 정리 (missing 0) - CLAUDE.md §1/§6/§6.7/§6.8/§8/§14, Marketing 설명 ko/en/ja·whats-new-1.1, MARKETING_VERSION 1.1(4타깃) 스크린샷 전면 재제작 (ko/en/ja): - 아이폰 10장·아이패드 10장(신규: 기록 목록/주간 타임테이블/일기 양식/다크/목표)·워치 합성 1장 - 시간대 정합: SIMCTL_CHILD_TZ 부팅 주입(Asia/Bangkok) + -marketingTZ 프로브 검증 절차 확립(README) - frame_screenshots.swift: 문구 5종 추가·일기/동기화 문구 맥 반영·renderWatch 합성 추가 (총 63장) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FVeduv1eNdXjk1ay4tSgBg
585 lines
27 KiB
Swift
585 lines
27 KiB
Swift
//
|
|
// CSVImport.swift
|
|
// Haru_Danim
|
|
//
|
|
// CSV 데이터 가져오기 로직 (설정 → 데이터 → 데이터 가져오기, 프리미엄 — CLAUDE.md §7.1)
|
|
// 내보내기(DataExportView)가 만든 CSV에서 행동·시간 기록·횟수 기록을 복원한다.
|
|
//
|
|
// 원칙:
|
|
// - **추가 전용(additive-only)**: 기존 데이터를 수정·삭제하지 않는다 — 최악의 결함도
|
|
// "불필요한 추가"에 그치고, 충돌은 전부 '건너뜀'으로 처리한다.
|
|
// - **미리 보기 → 확인 → 1회 저장**: 계획(plan)과 반영(apply)을 분리하고, 반영 시점에
|
|
// 중복 검사를 다시 수행해 미리 보기 이후의 데이터 변화에도 안전하다.
|
|
// - **멱등**: 같은 파일을 두 번 가져와도 중복 기록이 생기지 않는다 — 내보내기가 초 단위라
|
|
// 기존 기록의 소수점 초와 어긋나는 유사 중복까지 잡도록 초 단위 절사로 비교한다.
|
|
// - **언어 무관**: 파일 종류는 헤더 문자열(언어별)이 아니라 데이터 행의 모양(UUID·ISO 날짜·
|
|
// time/count 원시값)으로 판별한다 — 내보낸 언어와 가져오는 언어가 달라도 동작.
|
|
// - 목표·다짐 CSV는 대상이 아니다: 다짐 CSV는 주기가 표시 문자열이라 재구성 불가하고,
|
|
// 기록만 복원되면 목표·다짐은 기간을 걸쳐 새로 만들 때 자동으로 집계된다 (2026-07-29 결정).
|
|
// - '측정 중'(종료 없음) 행은 건너뛴다 — 가져오기로 진행 중 측정이 생기면 Live Activity·
|
|
// 위젯이 유령 타이머를 띄우게 되기 때문.
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftData
|
|
|
|
@MainActor
|
|
enum CSVImport {
|
|
|
|
// MARK: 파일 종류
|
|
|
|
enum FileKind {
|
|
case actions, sessions, counts
|
|
|
|
var label: String {
|
|
switch self {
|
|
case .actions: return String(localized: "행동 파일")
|
|
case .sessions: return String(localized: "시간 기록 파일")
|
|
case .counts: return String(localized: "횟수 기록 파일")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 읽기에 성공해 종류까지 판별된 파일 (미리 보기와 반영이 같은 원본을 쓴다)
|
|
struct LoadedFile {
|
|
let name: String
|
|
let kind: FileKind
|
|
/// 헤더 행을 제외한 데이터 행들
|
|
let rows: [[String]]
|
|
}
|
|
|
|
// MARK: RFC 4180 파서 (따옴표·쉼표·줄바꿈 이스케이프, BOM 제거)
|
|
|
|
nonisolated static func parse(_ text: String) -> [[String]] {
|
|
var s = text
|
|
if s.hasPrefix("\u{FEFF}") { s.removeFirst() }
|
|
var rows: [[String]] = []
|
|
var row: [String] = []
|
|
var field = ""
|
|
var inQuotes = false
|
|
var i = s.startIndex
|
|
while i < s.endIndex {
|
|
let c = s[i]
|
|
if inQuotes {
|
|
if c == "\"" {
|
|
let next = s.index(after: i)
|
|
if next < s.endIndex, s[next] == "\"" {
|
|
field.append("\"")
|
|
i = next
|
|
} else {
|
|
inQuotes = false
|
|
}
|
|
} else {
|
|
field.append(c)
|
|
}
|
|
} else {
|
|
switch c {
|
|
case "\"": inQuotes = true
|
|
case ",": row.append(field); field = ""
|
|
case "\r": break
|
|
case "\n": row.append(field); field = ""; rows.append(row); row = []
|
|
default: field.append(c)
|
|
}
|
|
}
|
|
i = s.index(after: i)
|
|
}
|
|
if !field.isEmpty || !row.isEmpty {
|
|
row.append(field)
|
|
rows.append(row)
|
|
}
|
|
// 완전히 빈 줄(마지막 개행 등)은 제거
|
|
return rows.filter { !($0.count == 1 && $0[0].isEmpty) }
|
|
}
|
|
|
|
// MARK: 날짜 파싱 (내보내기의 ISO 8601 형식)
|
|
|
|
private nonisolated static let isoBasic: ISO8601DateFormatter = {
|
|
let f = ISO8601DateFormatter()
|
|
f.formatOptions = [.withInternetDateTime]
|
|
return f
|
|
}()
|
|
|
|
private nonisolated static let isoFractional: ISO8601DateFormatter = {
|
|
let f = ISO8601DateFormatter()
|
|
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
return f
|
|
}()
|
|
|
|
nonisolated static func date(_ raw: String) -> Date? {
|
|
let s = raw.trimmingCharacters(in: .whitespaces)
|
|
guard !s.isEmpty else { return nil }
|
|
return isoBasic.date(from: s) ?? isoFractional.date(from: s)
|
|
}
|
|
|
|
// MARK: 종류 판별 (헤더가 아니라 데이터 행 모양으로 — 언어 무관)
|
|
|
|
/// 첫 유효 데이터 행 기준:
|
|
/// - 6열 + UUID + 3열이 "time"/"count" → 행동
|
|
/// - 6열 + UUID + 3열이 ISO 날짜 → 시간 기록
|
|
/// - 5열 + UUID + 3열이 ISO 날짜 + 4열 정수 → 횟수 기록
|
|
/// (헤더 행은 UUID 파싱에 실패해 자연히 건너뛰어진다. 데이터가 없으면 nil — 가져올 것도 없음)
|
|
nonisolated static func classify(_ rows: [[String]]) -> FileKind? {
|
|
for row in rows {
|
|
guard row.count >= 5,
|
|
UUID(uuidString: row[0].trimmingCharacters(in: .whitespaces)) != nil else { continue }
|
|
if row.count >= 6 {
|
|
let third = row[2].trimmingCharacters(in: .whitespaces)
|
|
if third == "time" || third == "count" { return .actions }
|
|
if date(row[2]) != nil { return .sessions }
|
|
return nil
|
|
}
|
|
if date(row[2]) != nil, Int(row[3].trimmingCharacters(in: .whitespaces)) != nil {
|
|
return .counts
|
|
}
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MARK: 계획 (미리 보기용 값 타입 — 모델 생성은 apply에서만)
|
|
|
|
struct ActionStub {
|
|
let id: UUID
|
|
var name: String
|
|
var isCount: Bool
|
|
var isFavorite: Bool
|
|
var tagNames: [String]
|
|
var createdAt: Date?
|
|
}
|
|
|
|
struct SessionStub {
|
|
let actionID: UUID
|
|
let start: Date
|
|
let end: Date
|
|
let note: String
|
|
}
|
|
|
|
struct CountStub {
|
|
let actionID: UUID
|
|
let timestamp: Date
|
|
let amount: Int
|
|
let note: String
|
|
}
|
|
|
|
struct Plan {
|
|
var newActions: [ActionStub] = []
|
|
/// CSV의 uuid가 그대로 존재해 이어진 행동 수
|
|
var matchedByID = 0
|
|
/// uuid는 없지만 이름·방식이 같아 기존 행동으로 이어진 행동 수
|
|
var linkedByName = 0
|
|
var newTagNames: [String] = []
|
|
var sessions: [SessionStub] = []
|
|
var counts: [CountStub] = []
|
|
var duplicateCount = 0
|
|
var runningSkipped = 0
|
|
var invalidCount = 0
|
|
/// 문제 행 안내 (앞 몇 개만 UI에 표시)
|
|
var invalidSamples: [String] = []
|
|
var fileSummaries: [String] = []
|
|
|
|
var recordCount: Int { sessions.count + counts.count }
|
|
var hasWork: Bool { !newActions.isEmpty || recordCount > 0 }
|
|
}
|
|
|
|
struct Summary {
|
|
var actionsCreated = 0
|
|
var tagsCreated = 0
|
|
var sessionsAdded = 0
|
|
var countsAdded = 0
|
|
var duplicatesSkipped = 0
|
|
}
|
|
|
|
/// 기존 기록과의 중복 판정 키 (초 단위 절사)
|
|
private nonisolated static func sessionKey(_ actionID: UUID, _ start: Date, _ end: Date) -> String {
|
|
"s|\(actionID.uuidString)|\(Int(start.timeIntervalSince1970))|\(Int(end.timeIntervalSince1970))"
|
|
}
|
|
|
|
private nonisolated static func countKey(_ actionID: UUID, _ ts: Date, _ amount: Int) -> String {
|
|
"c|\(actionID.uuidString)|\(Int(ts.timeIntervalSince1970))|\(amount)"
|
|
}
|
|
|
|
/// 대상 행동들의 기존 기록 키 집합 (중복 건너뜀용)
|
|
private static func existingKeys(for actions: [Action]) -> Set<String> {
|
|
var keys = Set<String>()
|
|
for action in actions {
|
|
for session in action.sessions {
|
|
guard let end = session.endAt else { continue }
|
|
keys.insert(sessionKey(action.uuid, session.startAt, end))
|
|
}
|
|
for entry in action.countEntries {
|
|
keys.insert(countKey(action.uuid, entry.timestamp, entry.amount))
|
|
}
|
|
}
|
|
return keys
|
|
}
|
|
|
|
/// 파일들을 읽어 가져오기 계획을 만든다. DB는 조회만 하고 변경하지 않는다.
|
|
static func makePlan(files: [LoadedFile], context: ModelContext) -> Plan {
|
|
var plan = Plan()
|
|
let existingActions = (try? context.fetch(FetchDescriptor<Action>())) ?? []
|
|
var byID: [UUID: Action] = [:]
|
|
for action in existingActions { byID[action.uuid] = action }
|
|
|
|
/// CSV uuid → 최종 대상 uuid (기존 행동 또는 새로 만들 스텁)
|
|
var mapping: [UUID: UUID] = [:]
|
|
var stubsByID: [UUID: Int] = [:] // plan.newActions 인덱스
|
|
var matchedIDs = Set<UUID>()
|
|
var linkedIDs = Set<UUID>()
|
|
|
|
/// 이름·방식 폴백 매칭 — uuid가 없어도 같은 이름·같은 방식의 기존 행동이 있으면 잇는다
|
|
/// (앱을 재설치하고 행동을 다시 만든 경우 등. 동명이 여럿이면 먼저 만든 것)
|
|
func nameMatch(_ name: String, isCount: Bool) -> Action? {
|
|
existingActions
|
|
.filter { $0.name == name && ($0.trackingType == .count) == isCount }
|
|
.sorted { $0.createdAt < $1.createdAt }
|
|
.first
|
|
}
|
|
|
|
/// csv 행동을 대상 uuid로 해석 (필요하면 새 스텁 등록)
|
|
func resolve(id: UUID, name: String, isCount: Bool,
|
|
favorite: Bool = false, tags: [String] = [], createdAt: Date? = nil) -> UUID {
|
|
if let mapped = mapping[id] { return mapped }
|
|
if let existing = byID[id] {
|
|
mapping[id] = existing.uuid
|
|
matchedIDs.insert(id)
|
|
return existing.uuid
|
|
}
|
|
if let named = nameMatch(name, isCount: isCount) {
|
|
mapping[id] = named.uuid
|
|
linkedIDs.insert(id)
|
|
return named.uuid
|
|
}
|
|
// 새 행동 스텁 (csv uuid 보존 — 재가져오기·기기 간 멱등성)
|
|
mapping[id] = id
|
|
stubsByID[id] = plan.newActions.count
|
|
plan.newActions.append(ActionStub(
|
|
id: id, name: name, isCount: isCount,
|
|
isFavorite: favorite, tagNames: tags, createdAt: createdAt
|
|
))
|
|
return id
|
|
}
|
|
|
|
// ① 행동 파일 먼저 — 이름·즐겨찾기·꼬리표 정보가 있는 매핑을 우선 확보
|
|
for file in files where file.kind == .actions {
|
|
var count = 0
|
|
for (index, row) in file.rows.enumerated() {
|
|
guard row.count >= 6,
|
|
let id = UUID(uuidString: row[0].trimmingCharacters(in: .whitespaces)) else { continue }
|
|
let name = row[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let typeRaw = row[2].trimmingCharacters(in: .whitespaces)
|
|
guard !name.isEmpty, typeRaw == "time" || typeRaw == "count" else {
|
|
plan.invalidCount += 1
|
|
if plan.invalidSamples.count < 5 {
|
|
plan.invalidSamples.append(String(localized: "\(file.name) \(index + 1)행: 행동 이름 또는 방식이 잘못됐어요"))
|
|
}
|
|
continue
|
|
}
|
|
let tags = row[3].split(separator: ";").map { $0.trimmingCharacters(in: .whitespaces) }
|
|
.filter { !$0.isEmpty }
|
|
let stubIndexBefore = plan.newActions.count
|
|
_ = resolve(id: id, name: name, isCount: typeRaw == "count",
|
|
favorite: row[4].trimmingCharacters(in: .whitespaces) == "true",
|
|
tags: tags, createdAt: date(row[5]))
|
|
// 새 스텁으로 등록된 경우에만 꼬리표 생성 후보 수집 (기존 행동은 건드리지 않음)
|
|
if plan.newActions.count > stubIndexBefore {
|
|
for tag in tags where !plan.newTagNames.contains(tag) {
|
|
plan.newTagNames.append(tag)
|
|
}
|
|
}
|
|
count += 1
|
|
}
|
|
plan.fileSummaries.append(String(localized: "\(file.kind.label) · \(count)행"))
|
|
}
|
|
|
|
// ② 기록 파일 — 대상 행동을 해석하며 스텁 자동 생성, 중복·불량 행 판별
|
|
let targetIDs = Set(mapping.values)
|
|
var involvedActions = existingActions.filter { targetIDs.contains($0.uuid) }
|
|
// 기록 파일이 참조하는 기존 행동도 중복 검사 대상에 포함해야 하므로 아래에서 lazy하게 추가
|
|
var keys = existingKeys(for: involvedActions)
|
|
var includedKeySet = Set(involvedActions.map(\.uuid))
|
|
|
|
func includeExistingKeysIfNeeded(_ uuid: UUID) {
|
|
guard !includedKeySet.contains(uuid), let action = byID[uuid] else { return }
|
|
includedKeySet.insert(uuid)
|
|
involvedActions.append(action)
|
|
keys.formUnion(existingKeys(for: [action]))
|
|
}
|
|
|
|
for file in files where file.kind != .actions {
|
|
var added = 0
|
|
for (index, row) in file.rows.enumerated() {
|
|
guard let id = UUID(uuidString: row[0].trimmingCharacters(in: .whitespaces)) else { continue }
|
|
let name = row[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
if file.kind == .sessions {
|
|
guard row.count >= 6 else { continue }
|
|
guard let start = date(row[2]) else {
|
|
plan.invalidCount += 1
|
|
if plan.invalidSamples.count < 5 {
|
|
plan.invalidSamples.append(String(localized: "\(file.name) \(index + 1)행: 시작 시각을 읽을 수 없어요"))
|
|
}
|
|
continue
|
|
}
|
|
if row[3].trimmingCharacters(in: .whitespaces).isEmpty {
|
|
// 내보낼 당시 '측정 중'이던 기록 — 가져오면 유령 측정이 되므로 제외
|
|
plan.runningSkipped += 1
|
|
continue
|
|
}
|
|
guard let end = date(row[3]), end > start else {
|
|
plan.invalidCount += 1
|
|
if plan.invalidSamples.count < 5 {
|
|
plan.invalidSamples.append(String(localized: "\(file.name) \(index + 1)행: 종료 시각이 잘못됐어요"))
|
|
}
|
|
continue
|
|
}
|
|
let target = resolve(id: id, name: name.isEmpty ? String(localized: "가져온 행동") : name, isCount: false)
|
|
includeExistingKeysIfNeeded(target)
|
|
let key = sessionKey(target, start, end)
|
|
guard !keys.contains(key) else {
|
|
plan.duplicateCount += 1
|
|
continue
|
|
}
|
|
keys.insert(key)
|
|
plan.sessions.append(SessionStub(actionID: target, start: start, end: end,
|
|
note: row.count > 5 ? row[5] : ""))
|
|
added += 1
|
|
} else {
|
|
guard row.count >= 5 else { continue }
|
|
guard let ts = date(row[2]) else {
|
|
plan.invalidCount += 1
|
|
if plan.invalidSamples.count < 5 {
|
|
plan.invalidSamples.append(String(localized: "\(file.name) \(index + 1)행: 기록 시각을 읽을 수 없어요"))
|
|
}
|
|
continue
|
|
}
|
|
guard let amount = Int(row[3].trimmingCharacters(in: .whitespaces)),
|
|
(1...9999).contains(amount) else {
|
|
plan.invalidCount += 1
|
|
if plan.invalidSamples.count < 5 {
|
|
plan.invalidSamples.append(String(localized: "\(file.name) \(index + 1)행: 수량이 잘못됐어요"))
|
|
}
|
|
continue
|
|
}
|
|
let target = resolve(id: id, name: name.isEmpty ? String(localized: "가져온 행동") : name, isCount: true)
|
|
includeExistingKeysIfNeeded(target)
|
|
let key = countKey(target, ts, amount)
|
|
guard !keys.contains(key) else {
|
|
plan.duplicateCount += 1
|
|
continue
|
|
}
|
|
keys.insert(key)
|
|
plan.counts.append(CountStub(actionID: target, timestamp: ts, amount: amount,
|
|
note: row.count > 4 ? row[4] : ""))
|
|
added += 1
|
|
}
|
|
}
|
|
plan.fileSummaries.append(String(localized: "\(file.kind.label) · \(added)행"))
|
|
}
|
|
|
|
plan.matchedByID = matchedIDs.count
|
|
plan.linkedByName = linkedIDs.count
|
|
return plan
|
|
}
|
|
|
|
/// 계획을 실제로 반영한다. 중복 검사는 최신 DB 기준으로 다시 수행하고,
|
|
/// 삽입 후 저장은 호출부의 DataChange.commit 1회에 맡긴다.
|
|
@discardableResult
|
|
static func apply(_ plan: Plan, context: ModelContext) -> Summary {
|
|
var summary = Summary()
|
|
let existingActions = (try? context.fetch(FetchDescriptor<Action>())) ?? []
|
|
var byID: [UUID: Action] = [:]
|
|
for action in existingActions { byID[action.uuid] = action }
|
|
|
|
// 꼬리표 (새 행동용 — 기존 꼬리표는 이름으로 재사용)
|
|
let existingTags = (try? context.fetch(FetchDescriptor<Tag>())) ?? []
|
|
var tagsByName: [String: Tag] = [:]
|
|
for tag in existingTags where tagsByName[tag.name] == nil { tagsByName[tag.name] = tag }
|
|
var tagOrder = (existingTags.map(\.sortOrder).max() ?? -1) + 1
|
|
var tagCount = existingTags.count
|
|
|
|
func tag(named name: String) -> Tag {
|
|
if let found = tagsByName[name] { return found }
|
|
let preset = AppTheme.tagPresets[tagCount % AppTheme.tagPresets.count]
|
|
let created = Tag(name: name, colorHex: preset)
|
|
created.sortOrder = tagOrder
|
|
tagOrder += 1
|
|
tagCount += 1
|
|
context.insert(created)
|
|
tagsByName[name] = created
|
|
summary.tagsCreated += 1
|
|
return created
|
|
}
|
|
|
|
// 새 행동 생성 (uuid 보존)
|
|
var actionOrder = (existingActions.map(\.sortOrder).max() ?? -1) + 1
|
|
for stub in plan.newActions where byID[stub.id] == nil {
|
|
let action = Action(
|
|
name: stub.name,
|
|
symbolName: stub.isCount ? "number" : "timer",
|
|
trackingType: stub.isCount ? .count : .time,
|
|
sortOrder: actionOrder
|
|
)
|
|
actionOrder += 1
|
|
action.uuid = stub.id
|
|
action.isFavorite = stub.isFavorite
|
|
if let created = stub.createdAt { action.createdAt = created }
|
|
action.tags = stub.tagNames.map { tag(named: $0) }
|
|
context.insert(action)
|
|
byID[stub.id] = action
|
|
LocalPrefs.appendActionToOrder(stub.id)
|
|
summary.actionsCreated += 1
|
|
}
|
|
|
|
// 기록 삽입 — 반영 시점 기준으로 중복 재검사 (미리 보기 이후 변화 방어)
|
|
var involved: [Action] = []
|
|
var involvedIDs = Set<UUID>()
|
|
for id in Set(plan.sessions.map(\.actionID) + plan.counts.map(\.actionID)) {
|
|
if let action = byID[id], involvedIDs.insert(id).inserted {
|
|
involved.append(action)
|
|
}
|
|
}
|
|
var keys = existingKeys(for: involved)
|
|
|
|
for stub in plan.sessions {
|
|
guard let action = byID[stub.actionID] else { continue }
|
|
let key = sessionKey(stub.actionID, stub.start, stub.end)
|
|
guard !keys.contains(key) else {
|
|
summary.duplicatesSkipped += 1
|
|
continue
|
|
}
|
|
keys.insert(key)
|
|
let session = TimeSession(action: action, startAt: stub.start, endAt: stub.end)
|
|
session.note = stub.note
|
|
context.insert(session)
|
|
summary.sessionsAdded += 1
|
|
}
|
|
for stub in plan.counts {
|
|
guard let action = byID[stub.actionID] else { continue }
|
|
let key = countKey(stub.actionID, stub.timestamp, stub.amount)
|
|
guard !keys.contains(key) else {
|
|
summary.duplicatesSkipped += 1
|
|
continue
|
|
}
|
|
keys.insert(key)
|
|
let entry = CountEntry(action: action, timestamp: stub.timestamp, amount: stub.amount)
|
|
entry.note = stub.note
|
|
context.insert(entry)
|
|
summary.countsAdded += 1
|
|
}
|
|
return summary
|
|
}
|
|
}
|
|
|
|
// MARK: - 자가 테스트 (DEBUG, -csvImportTest YES)
|
|
|
|
#if DEBUG
|
|
extension CSVImport {
|
|
/// 인메모리 컨테이너에 픽스처 CSV를 흘려 파서·판별·계획·반영·멱등성을 검증한다.
|
|
/// 결과는 Documents/csv-import-test.txt (실데이터 무영향). 픽스처 문자열은 개발용 데이터라
|
|
/// String(localized:)를 쓰지 않는다 (카탈로그 오염 방지, §2.3).
|
|
static func selfTestIfRequested() {
|
|
guard UserDefaults.standard.bool(forKey: "csvImportTest") 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
|
|
|
|
// 기존 데이터: uuid A 행동(시간형, 세션 1개) + 이름 매칭용 횟수형 행동
|
|
let idA = UUID()
|
|
let preA = Action(name: "복원독서", symbolName: "book.fill", trackingType: .time, sortOrder: 0)
|
|
preA.uuid = idA
|
|
context.insert(preA)
|
|
let s0 = Date(timeIntervalSince1970: 1_700_000_000)
|
|
let s1 = s0.addingTimeInterval(3600)
|
|
context.insert(TimeSession(action: preA, startAt: s0, endAt: s1))
|
|
let preNamed = Action(name: "기존물", symbolName: "drop.fill", trackingType: .count, sortOrder: 1)
|
|
context.insert(preNamed)
|
|
try context.save()
|
|
|
|
let idB = UUID(), idC = UUID(), idD = UUID()
|
|
func iso(_ d: Date) -> String { d.formatted(.iso8601) }
|
|
|
|
let actionsCSV = """
|
|
id,name,type,tags,fav,created
|
|
\(idA.uuidString),복원독서,time,,true,\(iso(s0))
|
|
\(idB.uuidString),복원물,count,"생활; 건강",false,\(iso(s0))
|
|
"""
|
|
let sessionsCSV = """
|
|
actionId,action,start,end,sec,memo
|
|
\(idA.uuidString),복원독서,\(iso(s0)),\(iso(s1)),3600,중복행
|
|
\(idA.uuidString),복원독서,\(iso(s1)),\(iso(s1.addingTimeInterval(1800))),1800,"쉼표, 그리고
|
|
줄바꿈 메모"
|
|
\(idA.uuidString),복원독서,\(iso(s1)),\(iso(s0)),0,역전행
|
|
\(idA.uuidString),복원독서,\(iso(s0)),,999,측정중행
|
|
\(idC.uuidString),복원달리기,\(iso(s0)),\(iso(s0.addingTimeInterval(600))),600,
|
|
"""
|
|
let countsCSV = """
|
|
actionId,action,ts,amount,memo
|
|
\(idB.uuidString),복원물,\(iso(s0)),3,
|
|
\(idB.uuidString),복원물,\(iso(s0)),3,
|
|
\(idB.uuidString),복원물,\(iso(s1)),0,수량0
|
|
\(idD.uuidString),기존물,\(iso(s1)),2,이름연결
|
|
"""
|
|
|
|
let files: [LoadedFile] = [
|
|
LoadedFile(name: "actions.csv", kind: classify(parse(actionsCSV))!, rows: parse(actionsCSV)),
|
|
LoadedFile(name: "sessions.csv", kind: classify(parse(sessionsCSV))!, rows: parse(sessionsCSV)),
|
|
LoadedFile(name: "counts.csv", kind: classify(parse(countsCSV))!, rows: parse(countsCSV)),
|
|
]
|
|
expect("판별: 행동", files[0].kind == .actions ? 1 : 0, 1)
|
|
expect("판별: 시간", files[1].kind == .sessions ? 1 : 0, 1)
|
|
expect("판별: 횟수", files[2].kind == .counts ? 1 : 0, 1)
|
|
|
|
let plan = makePlan(files: files, context: context)
|
|
expect("uuid 일치 연결", plan.matchedByID, 1) // A
|
|
expect("이름 폴백 연결", plan.linkedByName, 1) // D → 기존물
|
|
expect("새 행동", plan.newActions.count, 2) // B, C
|
|
expect("새 꼬리표 후보", plan.newTagNames.count, 2) // 생활, 건강
|
|
expect("추가할 시간 기록", plan.sessions.count, 2) // 따옴표행 + C행
|
|
expect("추가할 횟수 기록", plan.counts.count, 2) // B 1건 + D 1건
|
|
expect("중복 건너뜀", plan.duplicateCount, 2) // A 중복행 + B 반복행
|
|
expect("측정 중 건너뜀", plan.runningSkipped, 1)
|
|
expect("불량 행", plan.invalidCount, 2) // 역전행 + 수량0
|
|
|
|
// 따옴표·줄바꿈 메모 보존 확인
|
|
let memoOK = plan.sessions.contains { $0.note.contains("쉼표, 그리고\n줄바꿈") }
|
|
expect("따옴표 메모 보존", memoOK ? 1 : 0, 1)
|
|
|
|
let summary = apply(plan, context: context)
|
|
try context.save()
|
|
expect("생성된 행동", summary.actionsCreated, 2)
|
|
expect("생성된 꼬리표", summary.tagsCreated, 2)
|
|
expect("추가된 시간 기록", summary.sessionsAdded, 2)
|
|
expect("추가된 횟수 기록", summary.countsAdded, 2)
|
|
|
|
// 멱등성: 같은 파일을 다시 계획하면 전부 중복/기존 처리
|
|
let replan = makePlan(files: files, context: context)
|
|
expect("재가져오기: 새 행동 없음", replan.newActions.count, 0)
|
|
expect("재가져오기: 새 기록 없음", replan.recordCount, 0)
|
|
// 시간 3행(중복행·따옴표행·C행) + 횟수 3행(B 2행·D 1행) 전부 중복 처리
|
|
expect("재가져오기: 중복 처리", replan.duplicateCount, 6)
|
|
} catch {
|
|
failures += 1
|
|
lines.append("FAIL 컨테이너: \(error)")
|
|
}
|
|
|
|
lines.append(failures == 0 ? "== ALL PASS ==" : "== \(failures) FAILURES ==")
|
|
for line in lines { print("[CSVImportTest] \(line)") }
|
|
if let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
|
|
try? lines.joined(separator: "\n")
|
|
.write(to: docs.appendingPathComponent("csv-import-test.txt"),
|
|
atomically: true, encoding: .utf8)
|
|
}
|
|
}
|
|
}
|
|
#endif
|