dsh-motion 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 需求卡点:从 brief 推断该问什么、该给哪些选项。
4
+ *
5
+ * node scripts/plan-questions.mjs "做一条竖屏抖音短片,讲导数,要旁白" --json
6
+ * node scripts/plan-questions.mjs "用 3b1b 讲矩阵"
7
+ *
8
+ * 原则:能推断的不问;选项按上下文生成,不写死。
9
+ * 输出可直接交给 ask_user_question;最多 4 题。
10
+ */
11
+ import { loadRegistry, routeStyle } from './route-style.mjs'
12
+
13
+ const MAX_QUESTIONS = 4
14
+
15
+ const ASPECTS = {
16
+ '16:9': {
17
+ label: '16:9 横屏 · 1920×1080',
18
+ description: '课件、网页、B站/YouTube 最常用;现有风格验证最充分。',
19
+ },
20
+ '9:16': {
21
+ label: '9:16 竖屏 · 1080×1920',
22
+ description: '抖音 / Reels / Shorts;现有风格还没验过竖版,先出帧确认。',
23
+ },
24
+ '1:1': {
25
+ label: '1:1 方屏 · 1080×1080',
26
+ description: '信息流 / 方图;同样需要重新验帧。',
27
+ },
28
+ }
29
+
30
+ const ASPECT_HINTS = [
31
+ { key: '9:16', words: ['竖屏', '抖音', '短视频', '手机', 'reels', 'shorts', 'tiktok', '快手', '视频号'] },
32
+ { key: '1:1', words: ['方图', '正方形', '1:1', 'instagram', 'ig', '朋友圈'] },
33
+ { key: '16:9', words: ['横屏', 'b站', 'bilibili', 'youtube', '课件', '投影', '网页', '16:9'] },
34
+ ]
35
+
36
+ const DELIVERY = {
37
+ frames: {
38
+ label: '先出帧(默认)',
39
+ description: '只出 PNG 帧,几秒看版式;不生成视频。',
40
+ },
41
+ silent: {
42
+ label: '静音 mp4',
43
+ description: '生成无音轨的成片;适合循环、背景、无解说。',
44
+ },
45
+ narrated: {
46
+ label: '旁白 + 字幕(HF)',
47
+ description: 'TTS/录音、字幕、混音;需要 HyperFrames,交付最慢。',
48
+ },
49
+ }
50
+
51
+ const NARRATION_WORDS = ['旁白', '配音', '讲解', '口播', '字幕', 'voiceover', 'narration', '声音']
52
+ const SILENT_WORDS = ['静音', '无声', '不要声音', '只要画面', 'loop', '循环']
53
+
54
+ const MATH_WORDS = ['数学', '公式', '导数', '几何', '概率', '微积分', '线代', '矩阵', '向量', '证明',
55
+ '三角', '函数', '物理', '化学', '生物', '算法', '统计', '复数', '单位圆', '欧拉']
56
+ const AUDIENCE_WORDS = ['大众', '科普', '入门', '高中', '初中', '大学', '专业', '研究生', '论文', '工程师', '学生']
57
+
58
+ const AUDIENCES = {
59
+ general: { label: '大众科普', description: '少符号、多比喻;先讲清"为什么"。' },
60
+ high: { label: '高中', description: '保留公式,控制推导密度;默认按课标。' },
61
+ university: { label: '大学 / 专业', description: '可以直接上定义、证明、矩阵;密度更高。' },
62
+ }
63
+
64
+ function hits(brief, words) {
65
+ const q = brief.toLowerCase()
66
+ return words.filter((word) => q.includes(word.toLowerCase())).length
67
+ }
68
+
69
+ function aspectBlock(brief) {
70
+ const scored = Object.keys(ASPECTS).map((key) => ({
71
+ key,
72
+ score: hits(brief, ASPECT_HINTS.find((h) => h.key === key)?.words ?? []),
73
+ }))
74
+ scored.sort((a, b) => b.score - a.score || (a.key === '16:9' ? -1 : 1))
75
+ const explicit = scored.filter((item) => item.score > 0)
76
+ const top = scored[0].key
77
+ if (explicit.length === 1) {
78
+ return { assumption: top, question: null }
79
+ }
80
+ const second = top === '16:9' ? '9:16' : '16:9'
81
+ return {
82
+ assumption: top,
83
+ question: {
84
+ id: 'aspect',
85
+ header: '画幅',
86
+ question: '这条片子要横屏、竖屏还是方屏?',
87
+ options: [top, second].map((key) => ({ ...ASPECTS[key] })),
88
+ },
89
+ }
90
+ }
91
+
92
+ function deliveryBlock(brief) {
93
+ const narration = hits(brief, NARRATION_WORDS)
94
+ const silent = hits(brief, SILENT_WORDS)
95
+ if (narration > 0 && silent === 0) return { assumption: 'narrated', question: null }
96
+ if (silent > 0 && narration === 0) return { assumption: 'silent', question: null }
97
+ const order = narration > 0 && silent > 0
98
+ ? ['frames', 'silent', 'narrated']
99
+ : ['frames', 'silent', 'narrated']
100
+ return {
101
+ assumption: order[0],
102
+ question: {
103
+ id: 'delivery',
104
+ header: '交付',
105
+ question: '做到哪一步?',
106
+ options: order.map((key) => ({ ...DELIVERY[key] })),
107
+ },
108
+ }
109
+ }
110
+
111
+ function durationOf(brief) {
112
+ const match = /(\d+(?:\.\d+)?)\s*(秒|s\b|sec|分钟|min)/i.exec(brief)
113
+ if (!match) return null
114
+ const n = Number(match[1])
115
+ return /分钟|min/i.test(match[2]) ? Math.round(n * 60) : Math.round(n)
116
+ }
117
+
118
+ function hasNamedStyle(brief, registry) {
119
+ const q = brief.toLowerCase()
120
+ return registry.styles.some((style) =>
121
+ q.includes(style.slug.toLowerCase()) || q.includes(String(style.name_cn).toLowerCase()))
122
+ }
123
+
124
+ function styleBlock(brief, registry) {
125
+ const routed = routeStyle(brief, registry)
126
+ const bySlug = new Map(registry.styles.map((style) => [style.slug, style]))
127
+ const picked = routed.ranked.filter((item) => item.score > 0).slice(0, 3)
128
+ if (hasNamedStyle(brief, registry) || picked.length === 0) {
129
+ const fallback = bySlug.get(registry.default)
130
+ const options = picked.map(({ slug, reasons }) => {
131
+ const style = bySlug.get(slug)
132
+ return {
133
+ label: style.slug === registry.default ? `${style.name_cn}(默认)` : style.name_cn,
134
+ description: `${style.uses_cn?.join('、') ?? ''}${reasons.length ? `;命中:${reasons.join('、')}` : ''}`,
135
+ }
136
+ })
137
+ if (picked.length === 0) {
138
+ options.push(
139
+ {
140
+ label: `${fallback.name_cn}(默认)`,
141
+ description: `题材没有明显命中;先用默认。${fallback.uses_cn?.join('、') ?? ''}`,
142
+ },
143
+ { label: '让你决定(我先按默认做)', description: '不用现在选;我先按默认风格出 3 张帧,后面再换。' },
144
+ )
145
+ }
146
+ return {
147
+ assumption: picked[0]?.slug ?? registry.default,
148
+ question: hasNamedStyle(brief, registry) ? null : {
149
+ id: 'style',
150
+ header: '风格',
151
+ question: '风格选哪个?选项是按题材推的,不是固定清单。',
152
+ options,
153
+ },
154
+ }
155
+ }
156
+ const options = picked.map(({ slug, reasons }) => {
157
+ const style = bySlug.get(slug)
158
+ return {
159
+ label: style.slug === registry.default ? `${style.name_cn}(默认)` : style.name_cn,
160
+ description: `${style.uses_cn?.join('、') ?? ''}${reasons.length ? `;命中:${reasons.join('、')}` : ''}`,
161
+ }
162
+ })
163
+ if (options.length < 2) {
164
+ const fallback = bySlug.get(registry.default)
165
+ options.push({
166
+ label: `${fallback.name_cn}(默认)`,
167
+ description: `保底选项;${fallback.uses_cn?.join('、') ?? ''}`,
168
+ })
169
+ }
170
+ return {
171
+ assumption: picked[0].slug,
172
+ question: {
173
+ id: 'style',
174
+ header: '风格',
175
+ question: '风格选哪个?选项是按题材推的,不是固定清单。',
176
+ options,
177
+ },
178
+ }
179
+ }
180
+
181
+ function isMathTopic(brief) {
182
+ return hits(brief, MATH_WORDS) > 0
183
+ }
184
+
185
+ function audienceBlock(brief, aspect) {
186
+ if (!isMathTopic(brief) || hits(brief, AUDIENCE_WORDS) > 0) return null
187
+ const order = aspect === '9:16'
188
+ ? ['general', 'high', 'university']
189
+ : ['high', 'university', 'general']
190
+ return {
191
+ assumption: order[0],
192
+ question: {
193
+ id: 'audience',
194
+ header: '受众',
195
+ question: '这条片给谁看?它决定符号密度和推导长度。',
196
+ options: order.map((key) => ({ ...AUDIENCES[key] })),
197
+ },
198
+ }
199
+ }
200
+
201
+ function narrationBlock(brief, delivery) {
202
+ if (delivery !== 'narrated') return null
203
+ if (/english|英文|中文|chinese|双语|bilingual/i.test(brief)) return null
204
+ const options = [
205
+ { label: '中文 · 中性', description: '默认:跟随用户语言,冷静、克制。' },
206
+ { label: '中文 · 温和', description: '更适合科普、课程。' },
207
+ { label: '中文 · 有力', description: '更适合宣传、结论型短片。' },
208
+ { label: '英文 · 中性', description: '需要英文旁白与英文字幕。' },
209
+ ]
210
+ return {
211
+ assumption: { language: 'zh', voice: 'neutral' },
212
+ question: {
213
+ id: 'narration',
214
+ header: '旁白',
215
+ question: '旁白用什么语言和声音?语言默认跟随你。',
216
+ options,
217
+ },
218
+ }
219
+ }
220
+
221
+ export function planQuestions(brief, registry = loadRegistry()) {
222
+ const aspect = aspectBlock(brief)
223
+ const delivery = deliveryBlock(brief)
224
+ const style = styleBlock(brief, registry)
225
+ const audience = audienceBlock(brief, aspect.assumption)
226
+ const narration = narrationBlock(brief, delivery.assumption)
227
+
228
+ const questions = [aspect.question, delivery.question, style.question, audience?.question, narration?.question]
229
+ .filter(Boolean)
230
+ .slice(0, MAX_QUESTIONS)
231
+
232
+ const durationSec = durationOf(brief) ?? 10
233
+ const gateMode = durationSec > 60 ? 'full' : (delivery.assumption === 'narrated' || durationSec > 15) ? 'standard' : 'light'
234
+ const language = /english|英文/i.test(brief) ? 'en' : 'zh'
235
+ return {
236
+ brief,
237
+ questions,
238
+ assumptions: {
239
+ aspect: aspect.assumption,
240
+ delivery: delivery.assumption,
241
+ style: style.assumption,
242
+ audience: audience?.assumption ?? null,
243
+ narration: narration?.assumption ?? (delivery.assumption === 'narrated' ? { language, voice: 'neutral' } : null),
244
+ durationSec,
245
+ gateMode,
246
+ language,
247
+ captions: delivery.assumption === 'narrated',
248
+ note: '未指定的项按 assumptions 执行;用户回答后覆盖。',
249
+ },
250
+ routing: routeStyle(brief, registry),
251
+ }
252
+ }
253
+
254
+ function isMain() {
255
+ return process.argv[1] && new URL(import.meta.url).pathname === new URL(`file://${process.argv[1]}`).pathname
256
+ }
257
+
258
+ if (isMain()) {
259
+ const args = process.argv.slice(2)
260
+ const json = args.includes('--json')
261
+ const brief = args.filter((a) => a !== '--json').join(' ').trim()
262
+ if (!brief) {
263
+ console.error('用法: node scripts/plan-questions.mjs "<需求>" [--json]')
264
+ process.exit(1)
265
+ }
266
+ const plan = planQuestions(brief)
267
+ if (json) {
268
+ console.log(JSON.stringify(plan, null, 2))
269
+ } else {
270
+ console.log(`需求: ${plan.brief}`)
271
+ for (const q of plan.questions) {
272
+ console.log(`\n[${q.header}] ${q.question}`)
273
+ for (const option of q.options) console.log(` - ${option.label}:${option.description}`)
274
+ }
275
+ console.log(`\n默认: ${plan.assumptions.aspect} / ${plan.assumptions.delivery} / ${plan.assumptions.style} / ${plan.assumptions.audience ?? '-'} / ${plan.assumptions.durationSec}s`)
276
+ }
277
+ }
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 显式风格路由:按 registry 打分,给出 top 3、推荐与置信度。
4
+ *
5
+ * node scripts/route-style.mjs "导数 切线"
6
+ * node scripts/route-style.mjs "矩阵 特征值" --json
7
+ * node scripts/route-style.mjs "用 3b1b 风格讲单位圆"
8
+ *
9
+ * 规则:用户点名风格 > 关键词/uses 打分 > 默认风格;分数并列或为 0 时要求确认。
10
+ */
11
+ import { readFileSync } from 'node:fs'
12
+ import { dirname, join, resolve } from 'node:path'
13
+ import { fileURLToPath } from 'node:url'
14
+
15
+ const HERE = dirname(fileURLToPath(import.meta.url))
16
+ const DEFAULT_REGISTRY = join(HERE, '..', 'styles', 'registry.json')
17
+
18
+ export function loadRegistry(path = DEFAULT_REGISTRY) {
19
+ return JSON.parse(readFileSync(path, 'utf8'))
20
+ }
21
+
22
+ function scoreOne(style, query) {
23
+ const q = query.toLowerCase()
24
+ const reasons = []
25
+ let score = 0
26
+
27
+ if (q === style.slug.toLowerCase() || q === style.name_cn.toLowerCase()) {
28
+ score += 12
29
+ reasons.push('点名风格')
30
+ } else if (q.includes(style.slug.toLowerCase()) || q.includes(style.name_cn.toLowerCase())) {
31
+ score += 8
32
+ reasons.push('点名风格')
33
+ }
34
+
35
+ const keywords = (style.keywords ?? []).filter((k) => q.includes(k.toLowerCase()))
36
+ if (keywords.length) {
37
+ score += keywords.length * 3
38
+ reasons.push(`关键词: ${keywords.join(', ')}`)
39
+ }
40
+
41
+ const uses = [...(style.uses ?? []), ...(style.uses_cn ?? [])]
42
+ .filter((u) => q.includes(u.toLowerCase()))
43
+ if (uses.length) {
44
+ score += uses.length * 2
45
+ reasons.push(`用途: ${uses.join(', ')}`)
46
+ }
47
+
48
+ const avoided = (style.avoid ?? []).filter((a) => q.includes(a.toLowerCase()))
49
+ if (avoided.length) {
50
+ score -= avoided.length * 2
51
+ reasons.push(`冲突: ${avoided.join(', ')}`)
52
+ }
53
+
54
+ return { slug: style.slug, score: Math.max(score, 0), reasons }
55
+ }
56
+
57
+ export function routeStyle(query, registry) {
58
+ const q = String(query ?? '').trim()
59
+ const styles = registry.styles ?? []
60
+ const fallback = registry.default ?? styles[0]?.slug ?? null
61
+ if (!q) {
62
+ return { query: q, ranked: [], recommendation: fallback, confidence: 'low', needsConfirmation: true, reason: '空查询,使用默认风格' }
63
+ }
64
+
65
+ const ranked = styles
66
+ .map((style) => scoreOne(style, q))
67
+ .sort((a, b) => b.score - a.score || a.slug.localeCompare(b.slug))
68
+
69
+ const top = ranked[0]
70
+ if (!top || top.score === 0) {
71
+ return { query: q, ranked: ranked.slice(0, 3), recommendation: fallback, confidence: 'low', needsConfirmation: true, reason: '没有命中关键词,使用默认风格' }
72
+ }
73
+
74
+ const tie = ranked[1] && ranked[1].score === top.score
75
+ const lead = top.score - (ranked[1]?.score ?? 0)
76
+ const confidence = tie ? 'medium' : top.score >= 6 && lead >= 3 ? 'high' : top.score >= 3 ? 'medium' : 'low'
77
+ return {
78
+ query: q,
79
+ ranked: ranked.slice(0, 3),
80
+ recommendation: top.slug,
81
+ confidence,
82
+ needsConfirmation: tie || confidence !== 'high',
83
+ reason: tie ? '前两名同分,需要确认' : top.reasons.join(';'),
84
+ }
85
+ }
86
+
87
+ function isMain() {
88
+ return process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
89
+ }
90
+
91
+ if (isMain()) {
92
+ const args = process.argv.slice(2)
93
+ const json = args.includes('--json')
94
+ const rest = args.filter((a) => a !== '--json')
95
+ const query = rest.join(' ').trim()
96
+ if (!query) {
97
+ console.error('用法: node scripts/route-style.mjs "<题材>" [--json]')
98
+ process.exit(1)
99
+ }
100
+ const result = routeStyle(query, loadRegistry())
101
+ if (json) {
102
+ console.log(JSON.stringify(result, null, 2))
103
+ } else {
104
+ console.log(`推荐: ${result.recommendation} (${result.confidence})`)
105
+ console.log(`原因: ${result.reason}`)
106
+ console.log('候选:')
107
+ for (const [i, item] of result.ranked.entries()) {
108
+ console.log(` ${i + 1}. ${item.slug} ${item.score} ${item.reasons.join(';')}`)
109
+ }
110
+ if (result.needsConfirmation) console.log('需要确认:分数不唯一或置信度低,先问用户再写 DESIGN.md。')
111
+ }
112
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * 冒烟测试:不依赖网络与 Python,验证包结构与核心模块可加载。
3
+ * 用法:node scripts/smoke.mjs
4
+ */
5
+ import { existsSync, readFileSync } from 'node:fs'
6
+ import { dirname, join } from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+
9
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..')
10
+ const required = [
11
+ 'package.json',
12
+ 'cordis.patch.yml',
13
+ 'lib/index.js',
14
+ 'lib/ops.js',
15
+ 'lib/env.js',
16
+ 'lib/doctor.js',
17
+ 'lib/fresh.js',
18
+ 'skills/animation-router/SKILL.md',
19
+ 'skills/animation-router/references/confirmation.md',
20
+ 'skills/design-spec/SKILL.md',
21
+ 'skills/qa-rubric/SKILL.md',
22
+ 'styles/registry.json',
23
+ 'styles/swiss-motion/STYLE.md',
24
+ 'styles/swiss-motion/tokens.json',
25
+ 'styles/swiss-motion/theme.py',
26
+ 'styles/swiss-motion/preview.jpg',
27
+ 'styles/dataviz/STYLE.md',
28
+ 'styles/dataviz/tokens.json',
29
+ 'styles/dataviz/theme.py',
30
+ 'styles/dataviz/preview.jpg',
31
+ 'styles/blueprint/STYLE.md',
32
+ 'styles/blueprint/tokens.json',
33
+ 'styles/blueprint/theme.py',
34
+ 'styles/blueprint/preview.jpg',
35
+ 'styles/whiteboard/STYLE.md',
36
+ 'styles/whiteboard/tokens.json',
37
+ 'styles/whiteboard/theme.py',
38
+ 'styles/whiteboard/preview.jpg',
39
+ 'styles/3b1b/STYLE.md',
40
+ 'styles/3b1b/tokens.json',
41
+ 'styles/3b1b/theme.py',
42
+ 'styles/3b1b/preview.jpg',
43
+ 'docs/ai-native-motion.md',
44
+ 'docs/dev-loop.md',
45
+ 'docs/performance-budget.md',
46
+ 'README.md',
47
+ 'LICENSE',
48
+ 'NOTICE.md',
49
+ ]
50
+
51
+ let failed = 0
52
+ for (const rel of required) {
53
+ const ok = existsSync(join(root, rel))
54
+ if (!ok) failed++
55
+ console.log(`${ok ? 'ok ' : 'MISS'} ${rel}`)
56
+ }
57
+
58
+ const mod = await import(join(root, 'lib/index.js'))
59
+ console.log(`${mod.name === 'motion' ? 'ok ' : 'FAIL'} exports name=${mod.name}`)
60
+
61
+ const ops = await import(join(root, 'lib/ops.js'))
62
+ const framesOk = typeof ops.inspectFrames === 'function' && typeof ops.parseInspectOutput === 'function'
63
+ console.log(`${framesOk ? 'ok ' : 'FAIL'} frames-first: motion_frames helpers exported`)
64
+ if (!framesOk) failed++
65
+ console.log(`${typeof mod.apply === 'function' ? 'ok ' : 'FAIL'} exports apply()`)
66
+ if (mod.name !== 'motion' || typeof mod.apply !== 'function') failed++
67
+
68
+ for (const slug of ['swiss-motion', 'dataviz', 'blueprint', 'whiteboard', '3b1b']) {
69
+ const tokens = JSON.parse(readFileSync(join(root, 'styles', slug, 'tokens.json'), 'utf8'))
70
+ const ok = tokens.name === slug && tokens.version === '1.0.0'
71
+ if (!ok) failed++
72
+ console.log(`${ok ? 'ok ' : 'FAIL'} style tokens: ${tokens.name}@${tokens.version}`)
73
+ }
74
+
75
+ const { doctor } = await import(join(root, 'lib/doctor.js'))
76
+ const report = await doctor({})
77
+ console.log(`ok doctor: ${report.rows.length} checks, math=${report.ready.math}, delivery=${report.ready.delivery}`)
78
+
79
+ if (failed > 0) {
80
+ console.error(`\n${failed} check(s) failed`)
81
+ process.exit(1)
82
+ }
83
+ console.log('\nsmoke passed')
@@ -0,0 +1,64 @@
1
+ import assert from 'node:assert/strict'
2
+ import { execFileSync } from 'node:child_process'
3
+ import { mkdtempSync, rmSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+
7
+ const SCRIPT = new URL('./approvals.mjs', import.meta.url).pathname
8
+ const project = mkdtempSync(join(tmpdir(), 'dsh-approvals-'))
9
+ let failed = 0
10
+
11
+ function run(args) {
12
+ try {
13
+ const out = execFileSync(process.execPath, [SCRIPT, ...args], { encoding: 'utf8', stdio: 'pipe' })
14
+ return { code: 0, out }
15
+ } catch (error) {
16
+ return { code: error.status ?? 1, out: `${error.stdout ?? ''}${error.stderr ?? ''}` }
17
+ }
18
+ }
19
+
20
+ function test(name, fn) {
21
+ try {
22
+ fn()
23
+ console.log(`ok ${name}`)
24
+ } catch (error) {
25
+ failed++
26
+ console.log(`FAIL ${name}`)
27
+ console.error(` ${String(error.message).split('\n')[0]}`)
28
+ }
29
+ }
30
+
31
+ run(['init', project, '--mode', 'light'])
32
+
33
+ test('刚 init 时 check 不通过', () => {
34
+ assert.equal(run(['check', project]).code, 1)
35
+ })
36
+
37
+ test('不能跳过前置阶段', () => {
38
+ assert.equal(run(['set', project, 'script_storyboard', 'approved']).code, 1)
39
+ })
40
+
41
+ test('按顺序全部确认后 check 通过', () => {
42
+ assert.equal(run(['set', project, 'requirements', 'approved', '--note', '需求']).code, 0)
43
+ assert.equal(run(['set', project, 'script_storyboard', 'approved', '--note', '脚本+分镜']).code, 0)
44
+ assert.equal(run(['set', project, 'look', 'approved', '--note', '3 帧']).code, 0)
45
+ assert.equal(run(['set', project, 'start', 'approved', '--note', '可以开始']).code, 0)
46
+ assert.equal(run(['check', project]).code, 0)
47
+ })
48
+
49
+ test('standard 模式包含独立脚本/分镜/交付', () => {
50
+ const dir = mkdtempSync(join(tmpdir(), 'dsh-approvals-std-'))
51
+ assert.equal(run(['init', dir, '--mode', 'standard']).code, 0)
52
+ assert.match(run(['show', dir]).out, /脚本/)
53
+ assert.match(run(['show', dir]).out, /分镜/)
54
+ assert.match(run(['show', dir]).out, /交付清单/)
55
+ rmSync(dir, { recursive: true, force: true })
56
+ })
57
+
58
+ rmSync(project, { recursive: true, force: true })
59
+
60
+ if (failed > 0) {
61
+ console.error(`\n${failed} test(s) failed`)
62
+ process.exit(1)
63
+ }
64
+ console.log('\ntest-approvals passed')