docs(siri): add Shortcuts help topic and an in-app intent smoke test

시리 단축어 기능 점검:
- 도움말(프리미엄 그룹)에 '시리·단축어로 기록하기' 항목 추가 — 그동안
  도움말에 시리 단축어 안내가 없었다. 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
songyc macbook 2026-07-13 20:51:30 +09:00
parent 1b36607ba1
commit ecfd008229
5 changed files with 165 additions and 2 deletions

View File

@ -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 <N>` `-openStatsFor "이름"` `-openHistoryFor "이름"` `-autoStart "이름"` |
| 목표 | `-goalShowEditor` `-goalShowFinished` `-goalReorder` `-goalScrollBottom` `-endGoalYesterday "제목"`(종료일을 어제로 — 실행 시점 자동 판정 검증) |
| 기록/통계 | `-historyMode timetable` `-historyWeekly` `-excludeActions "이름,이름"` `-historyShowFilter` `-statShowFilter` `-statSpan <day|week|month>` `-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 <N>` `-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` |

View File

@ -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") {

View File

@ -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) {

View File

@ -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": {

View File

@ -259,6 +259,11 @@ struct HelpView: View {
title: "무료와 프리미엄의 차이",
body: "무료로는 행동 10개·목표 2개·목표당 다짐 3개까지 만들 수 있어요. 프리미엄은 개수 제한이 풀리고 위젯, 애플워치 앱, 기기 간 동기화, 아이패드 일기를 쓸 수 있어요. 월·년 구독 또는 한 번만 결제하는 평생 이용권 중에 고르면 돼요."
),
HelpTopic(
symbol: "mic.fill",
title: "시리·단축어로 기록하기",
body: "단축어 앱에서 '하루 다님'을 검색하면 측정 시작·종료, 횟수 추가, 행동 누적값 가져오기, 목표·다짐 진행률 가져오기 동작을 조합해 나만의 자동화를 만들 수 있어요. \"시리야, 하루 다님에서 측정 시작\"처럼 말로도 실행할 수 있고, 어떤 행동인지 물으면 이름으로 대답하면 돼요. 진행률 동작은 값을 돌려주므로 다른 단축어와 이어 붙일 수 있어요."
),
HelpTopic(
symbol: "arrow.counterclockwise.circle",
title: "구매 복원과 구독 관리",