From ecfd0082296220b02a3b6b91e1fb1c15cf6111aa Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Mon, 13 Jul 2026 20:51:30 +0900 Subject: [PATCH] docs(siri): add Shortcuts help topic and an in-app intent smoke test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 시리 단축어 기능 점검: - 도움말(프리미엄 그룹)에 '시리·단축어로 기록하기' 항목 추가 — 그동안 도움말에 시리 단축어 안내가 없었다. en/ja 번역 포함 - DEBUG 검증용 -intentSmokeTest 런치 인자 추가: 인텐트 6종의 perform()을 앱 안에서 9개 시나리오(시작/중복 방지/종료/횟수 추가/타입 방어/조회 3종/프리미엄 게이트)로 실행하고 결과를 Documents/intent-smoke-test.txt에 남긴다. 시뮬레이터 실행 결과 9개 전부 통과 - CLAUDE.md §12·§14에 검증 방법과 현재 한계(AppShortcuts 지역화 미구성, '선택 안 함' 센티널의 단축어 앱 노출) 기록 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7 --- myApp/HaruDanim/CLAUDE.md | 4 +- myApp/HaruDanim/IOS/ContentView.swift | 1 + myApp/HaruDanim/IOS/Core/DebugSeed.swift | 125 ++++++++++++++++++++++ myApp/HaruDanim/IOS/Localizable.xcstrings | 32 ++++++ myApp/HaruDanim/IOS/Views/HelpView.swift | 5 + 5 files changed, 165 insertions(+), 2 deletions(-) diff --git a/myApp/HaruDanim/CLAUDE.md b/myApp/HaruDanim/CLAUDE.md index ea2c1ad..f4ea571 100644 --- a/myApp/HaruDanim/CLAUDE.md +++ b/myApp/HaruDanim/CLAUDE.md @@ -259,7 +259,7 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${files[@]}" ## 12. 시리 단축어 (`Shared/HaruDanimIntents.swift`, 프리미엄 게이트) -인텐트: 측정 시작/종료(짧은 기록 무시 규칙 동일 적용) · 횟수 추가(1~999) · 행동 누적값 조회 · 목표 진행률 조회 · 다짐 진행률 조회(atMost는 한도 문구). `AppShortcutsProvider`에 대표 문구 등록. 엔티티(Action/Goal/Quest)는 EntityStringQuery + '선택 안 함' 항목. +인텐트: 측정 시작/종료(짧은 기록 무시 규칙 동일 적용) · 횟수 추가(1~999) · 행동 누적값 조회 · 목표 진행률 조회 · 다짐 진행률 조회(atMost는 한도 문구). `AppShortcutsProvider`에 대표 문구 등록(현재 한국어 전용 — AppShortcuts 지역화 미구성, 빌드 로그 `--no-app-shortcuts-localization`). 엔티티(Action/Goal/Quest)는 EntityStringQuery + '선택 안 함' 항목(위젯 설정용 센티널이 단축어 앱 선택지에도 노출됨). 도움말 프리미엄 그룹에 사용법 항목 있음. 로직 검증은 `-intentSmokeTest` (§14) — perform() 9개 시나리오를 앱 안에서 실행하고 결과를 Documents/intent-smoke-test.txt로 남긴다. --- @@ -287,7 +287,7 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${files[@]}" | 모음 | `-startEditing` `-expandGoalCard` `-pinGoals ` `-openStatsFor "이름"` `-openHistoryFor "이름"` `-autoStart "이름"` | | 목표 | `-goalShowEditor` `-goalShowFinished` `-goalReorder` `-goalScrollBottom` `-endGoalYesterday "제목"`(종료일을 어제로 — 실행 시점 자동 판정 검증) | | 기록/통계 | `-historyMode timetable` `-historyWeekly` `-excludeActions "이름,이름"` `-historyShowFilter` `-statShowFilter` `-statSpan ` `-statScrollBottom` `-showExport` `-exportDump`(Documents/export-dump.png) | -| 설정/기타 | `-settingsScrollGoal` `-settingsScrollPremium` `-premiumPreview` `-helpPreview` `-widgetPreview YES|lock` `-widgetPreviewScroll <앵커>` `cloudSync`(standard bool로 강제) | +| 설정/기타 | `-settingsScrollGoal` `-settingsScrollPremium` `-premiumPreview` `-helpPreview` `-widgetPreview YES|lock` `-widgetPreviewScroll <앵커>` `cloudSync`(standard bool로 강제) `-intentSmokeTest`(시리 인텐트 9개 시나리오 실행 → Documents/intent-smoke-test.txt, -seedDemo·-premium과 함께) | | 버블 | `-radialExpanded` | | 일기 | `-diarySeed` `-diaryOpenToday`(**`-startTab diary` 필수**) `-diaryPage ` `-diaryShowConfig` `-diaryShowExport`(달력 화면 onAppear — `-diaryOpenToday`와 함께 쓰면 안 뜸) `-diaryExportRun pdf|image`(결과를 Documents로 복사) `-diaryShowCalendarPicker` `-diarySeedEvents` `-diaryHideFirstAction` `-diaryShowActionFilter` / 섹션 강제: `-diary.sectionOrder "timetable,hero,goals"` `-diary.hiddenSections "mood,todos,records,bars"` | | 워치 | `-complicationPreview` `-complicationScroll` `-autoRunFirstAction` | diff --git a/myApp/HaruDanim/IOS/ContentView.swift b/myApp/HaruDanim/IOS/ContentView.swift index a969592..594e11e 100644 --- a/myApp/HaruDanim/IOS/ContentView.swift +++ b/myApp/HaruDanim/IOS/ContentView.swift @@ -45,6 +45,7 @@ struct ContentView: View { DebugSeed.autoStartIfRequested(context: context) DebugSeed.pinGoalsIfRequested(context: context) DebugSeed.endGoalYesterdayIfRequested(context: context) + await DebugSeed.runIntentSmokeTestIfRequested() // 검증용: -themeAutoToggle YES → 3초 후 설정 화면과 같은 경로(App Group defaults)로 // 테마를 반전시켜 '재시작 없이 즉시 적용'되는지 확인 if UserDefaults.standard.bool(forKey: "themeAutoToggle") { diff --git a/myApp/HaruDanim/IOS/Core/DebugSeed.swift b/myApp/HaruDanim/IOS/Core/DebugSeed.swift index fa36b3e..68e2200 100644 --- a/myApp/HaruDanim/IOS/Core/DebugSeed.swift +++ b/myApp/HaruDanim/IOS/Core/DebugSeed.swift @@ -9,6 +9,16 @@ 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) { @@ -21,6 +31,121 @@ enum DebugSeed { 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))") } + + // ⑥ 조회 인텐트 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) { diff --git a/myApp/HaruDanim/IOS/Localizable.xcstrings b/myApp/HaruDanim/IOS/Localizable.xcstrings index e5d637b..91da3ed 100644 --- a/myApp/HaruDanim/IOS/Localizable.xcstrings +++ b/myApp/HaruDanim/IOS/Localizable.xcstrings @@ -2886,6 +2886,22 @@ } } }, + "단축어 앱에서 '하루 다님'을 검색하면 측정 시작·종료, 횟수 추가, 행동 누적값 가져오기, 목표·다짐 진행률 가져오기 동작을 조합해 나만의 자동화를 만들 수 있어요. \"시리야, 하루 다님에서 측정 시작\"처럼 말로도 실행할 수 있고, 어떤 행동인지 물으면 이름으로 대답하면 돼요. 진행률 동작은 값을 돌려주므로 다른 단축어와 이어 붙일 수 있어요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search for 'HaruDanim' in the Shortcuts app to combine actions — start/stop tracking, add counts, get an action's totals, and get goal or quest progress — into your own automations. You can also just say \"Hey Siri, start tracking in HaruDanim\"; when Siri asks which action, answer with its name. The progress actions return a value, so you can chain them into other shortcuts." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ショートカットアプリで「ハルダニム」を検索すると、計測の開始・終了、回数の追加、アクションの累計取得、目標・クエストの達成率取得を組み合わせて自分だけの自動化が作れます。「Hey Siri、ハルダニムで計測開始」のように声でも実行でき、どのアクションか聞かれたら名前で答えるだけ。達成率のアクションは値を返すので、他のショートカットにつなげられます。" + } + } + } + }, "닫기": { "localizations": { "en": { @@ -5494,6 +5510,22 @@ } } }, + "시리·단축어로 기록하기": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Track with Siri & Shortcuts" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Siri・ショートカットで記録する" + } + } + } + }, "시작": { "localizations": { "en": { diff --git a/myApp/HaruDanim/IOS/Views/HelpView.swift b/myApp/HaruDanim/IOS/Views/HelpView.swift index 6ab5993..0591928 100644 --- a/myApp/HaruDanim/IOS/Views/HelpView.swift +++ b/myApp/HaruDanim/IOS/Views/HelpView.swift @@ -259,6 +259,11 @@ struct HelpView: View { title: "무료와 프리미엄의 차이", body: "무료로는 행동 10개·목표 2개·목표당 다짐 3개까지 만들 수 있어요. 프리미엄은 개수 제한이 풀리고 위젯, 애플워치 앱, 기기 간 동기화, 아이패드 일기를 쓸 수 있어요. 월·년 구독 또는 한 번만 결제하는 평생 이용권 중에 고르면 돼요." ), + HelpTopic( + symbol: "mic.fill", + title: "시리·단축어로 기록하기", + body: "단축어 앱에서 '하루 다님'을 검색하면 측정 시작·종료, 횟수 추가, 행동 누적값 가져오기, 목표·다짐 진행률 가져오기 동작을 조합해 나만의 자동화를 만들 수 있어요. \"시리야, 하루 다님에서 측정 시작\"처럼 말로도 실행할 수 있고, 어떤 행동인지 물으면 이름으로 대답하면 돼요. 진행률 동작은 값을 돌려주므로 다른 단축어와 이어 붙일 수 있어요." + ), HelpTopic( symbol: "arrow.counterclockwise.circle", title: "구매 복원과 구독 관리",