mocode-pet-app 1.6.0 → 1.7.0
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.
- package/assets/pets/03-robo-fox.motion.css +63 -0
- package/assets/pets/04-robo-panda.motion.css +61 -0
- package/assets/pets/05-robo-owl.motion.css +88 -0
- package/assets/pets/06-robo-bunny.motion.css +84 -0
- package/assets/pets/07-robo-frog.motion.css +65 -0
- package/assets/pets/08-robo-bear.motion.css +65 -0
- package/assets/pets/09-robo-penguin.motion.css +94 -0
- package/assets/pets/10-robo-dino.motion.css +91 -0
- package/assets/pets/12-ghost-byte.motion.css +73 -0
- package/assets/pets/13-cactus-bot.motion.css +90 -0
- package/assets/pets/14-crystal-bot.motion.css +63 -0
- package/assets/pets/15-satellite-bot.motion.css +72 -0
- package/assets/pets/16-jellyfish-bot.motion.css +71 -0
- package/assets/pets/17-mushroom-bot.motion.css +65 -0
- package/assets/pets/18-star-bot.motion.css +72 -0
- package/assets/pets/manifest.json +139 -42
- package/dist/assets/pets/03-robo-fox.motion.css +63 -0
- package/dist/assets/pets/04-robo-panda.motion.css +61 -0
- package/dist/assets/pets/05-robo-owl.motion.css +88 -0
- package/dist/assets/pets/06-robo-bunny.motion.css +84 -0
- package/dist/assets/pets/07-robo-frog.motion.css +65 -0
- package/dist/assets/pets/08-robo-bear.motion.css +65 -0
- package/dist/assets/pets/09-robo-penguin.motion.css +94 -0
- package/dist/assets/pets/10-robo-dino.motion.css +91 -0
- package/dist/assets/pets/12-ghost-byte.motion.css +73 -0
- package/dist/assets/pets/13-cactus-bot.motion.css +90 -0
- package/dist/assets/pets/14-crystal-bot.motion.css +63 -0
- package/dist/assets/pets/15-satellite-bot.motion.css +72 -0
- package/dist/assets/pets/16-jellyfish-bot.motion.css +71 -0
- package/dist/assets/pets/17-mushroom-bot.motion.css +65 -0
- package/dist/assets/pets/18-star-bot.motion.css +72 -0
- package/dist/assets/pets/manifest.json +139 -42
- package/dist/e2e-mood-check.js +27 -5
- package/dist/main.js +4 -0
- package/dist/quips.js +14 -0
- package/dist/quips.test.js +43 -2
- package/dist/renderer/dom-mood.js +4 -2
- package/dist/renderer/preload.js +11 -0
- package/dist/renderer/renderer.js +6 -2
- package/dist/skins.js +20 -2
- package/dist/skins.test.js +40 -1
- package/package.json +1 -1
package/dist/e2e-mood-check.js
CHANGED
|
@@ -3,9 +3,26 @@
|
|
|
3
3
|
// 模拟 design.md 里描述的完整事件序列,断言 Mood 链路与皮肤个性化行为符合预期。
|
|
4
4
|
// 本脚本只读验证,不修改被验证的任何模块。
|
|
5
5
|
// 运行(在 f:\mocode 根目录):npx tsx packages/pet-app/src/e2e-mood-check.ts
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
//
|
|
7
|
+
// 说明:skins.ts 的 skinsDir() 按"该模块自身文件所在目录 + assets/pets"解析 manifest.json 路径
|
|
8
|
+
// (设计意图见 skins.ts 注释:生产环境下 dist/skins.js 与 dist/assets/ 同级)。用 tsx 直接运行本脚本时,
|
|
9
|
+
// 模块的 import.meta.url 指向 src/skins.ts,同级并无 assets/ 目录(真实素材放在 packages/pet-app/assets/,
|
|
10
|
+
// 由构建脚本 copy-static.mjs 复制到 dist/assets/)。为了不修改 skins.ts 就能让 listSkinEntries() 在此
|
|
11
|
+
// 场景下"真实读取"到 manifest.json,这里在调用前临时把 ../assets 镜像到 ./assets(与构建产物的目录关系
|
|
12
|
+
// 完全一致),验证结束后在 finally 里删除,不在仓库里留下任何新增/改动的痕迹。
|
|
13
|
+
import { cpSync, existsSync, rmSync } from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const tempAssetsDir = path.join(__dirname, 'assets');
|
|
18
|
+
const realAssetsDir = path.join(__dirname, '..', 'assets');
|
|
19
|
+
const createdTempAssets = !existsSync(tempAssetsDir);
|
|
20
|
+
if (createdTempAssets) {
|
|
21
|
+
cpSync(realAssetsDir, tempAssetsDir, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
const { createMoodTracker } = await import('./mood-tracker.js');
|
|
24
|
+
const { listSkinEntries } = await import('./skins.js');
|
|
25
|
+
const { UNIVERSAL_QUIPS, pickQuip } = await import('./quips.js');
|
|
9
26
|
let passed = 0;
|
|
10
27
|
const assert = (cond, msg) => {
|
|
11
28
|
if (!cond) {
|
|
@@ -15,7 +32,7 @@ const assert = (cond, msg) => {
|
|
|
15
32
|
passed++;
|
|
16
33
|
console.log('✓ ' + msg);
|
|
17
34
|
};
|
|
18
|
-
|
|
35
|
+
try {
|
|
19
36
|
// ── 场景1:agent 长时间停留在 thinking(同理 tool_call)触发 tired ─────────
|
|
20
37
|
// 每 5000ms 上报一次 thinking,ts: 0..60000。默认 tiredMs=60000。
|
|
21
38
|
{
|
|
@@ -125,4 +142,9 @@ const assert = (cond, msg) => {
|
|
|
125
142
|
}
|
|
126
143
|
}
|
|
127
144
|
console.log(`\nOK: ${passed} passed, 0 failed`);
|
|
128
|
-
}
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
if (createdTempAssets) {
|
|
148
|
+
rmSync(tempAssetsDir, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -196,6 +196,10 @@ ipcMain.handle('pet:get-skin', () => ({
|
|
|
196
196
|
function currentSkinQuips() {
|
|
197
197
|
return listSkinEntries().find((e) => e.id === currentSkinId)?.quips;
|
|
198
198
|
}
|
|
199
|
+
/** 当前皮肤的状态专属文案池(manifest.json 的 stateQuips 字段),供 broadcastToRenderer 的 pickStateQuip 选取。 */
|
|
200
|
+
function currentSkinStateQuips() {
|
|
201
|
+
return listSkinEntries().find((e) => e.id === currentSkinId)?.stateQuips;
|
|
202
|
+
}
|
|
199
203
|
/** 把 mood-tracker 求值结果推给渲染进程(IPC 频道 pet:mood)。result 为 null 表示本次求值无需推送。
|
|
200
204
|
* 写法与 broadcastToRenderer 一致的 null/isDestroyed 检查 + try/catch 静默降级,不影响 Server 主流程。 */
|
|
201
205
|
function pushMoodEvaluation(result) {
|
package/dist/quips.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// 通用文案池:各 mood 对应的中文吐槽短句,以及随机选取逻辑。
|
|
2
2
|
// 皮肤可提供自定义文案(skinQuips)覆盖某个 mood 的候选池;未覆盖或为空时回退到通用文案池。
|
|
3
|
+
// 另提供 pickStateQuip:PetState 维度的文案(独立于 mood,见 protocol.ts)——
|
|
4
|
+
// 让皮肤对"任务完成 / 用户中断 / 报错"等瞬时状态也能挂个性化吐槽。
|
|
3
5
|
/** 每种 mood 的通用吐槽短句候选池,语气轻松幽默(赛博终端风机器人设定)。 */
|
|
4
6
|
export const UNIVERSAL_QUIPS = {
|
|
5
7
|
frustrated: [
|
|
@@ -43,3 +45,15 @@ export function pickQuip(mood, skinQuips, pool = UNIVERSAL_QUIPS) {
|
|
|
43
45
|
const list = skinList && skinList.length > 0 ? skinList : pool[mood];
|
|
44
46
|
return list[Math.floor(Math.random() * list.length)];
|
|
45
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* 根据 PetState 选一条吐槽文案(独立于 mood 系统,见 quips.ts 顶部注释)。
|
|
50
|
+
* 命中条件:stateQuips 里该 state 有非空候选列表 → 随机选一条;否则返回 null。
|
|
51
|
+
* 返回 null 表示"该 state 在该皮肤下没有专属文案",调用方据此跳过推送(不影响主流程)。
|
|
52
|
+
* 纯函数,唯一的非确定性来源是 Math.random()。
|
|
53
|
+
*/
|
|
54
|
+
export function pickStateQuip(state, stateQuips) {
|
|
55
|
+
const list = stateQuips?.[state];
|
|
56
|
+
if (!list || list.length === 0)
|
|
57
|
+
return null;
|
|
58
|
+
return list[Math.floor(Math.random() * list.length)];
|
|
59
|
+
}
|
package/dist/quips.test.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
// 单测:pickQuip 的回退逻辑(皮肤池覆盖 / 未覆盖 / 空数组 / 未传皮肤池)
|
|
1
|
+
// 单测:pickQuip 的回退逻辑(皮肤池覆盖 / 未覆盖 / 空数组 / 未传皮肤池)
|
|
2
2
|
// 覆盖 5 种 MoodKind,并验证多次随机调用不会跑出候选池范围。
|
|
3
|
+
// + pickStateQuip 的命中 / 跳过逻辑(独立于 mood 系统,见 quips.ts 顶部注释)。
|
|
3
4
|
// 运行:npx tsx packages/pet-app/src/quips.test.ts
|
|
4
|
-
import { pickQuip, UNIVERSAL_QUIPS } from './quips.js';
|
|
5
|
+
import { pickQuip, pickStateQuip, UNIVERSAL_QUIPS } from './quips.js';
|
|
5
6
|
let passed = 0;
|
|
6
7
|
const assert = (cond, msg) => {
|
|
7
8
|
if (!cond) {
|
|
@@ -54,4 +55,44 @@ for (let i = 0; i < 20; i++) {
|
|
|
54
55
|
const r = pickQuip('tired');
|
|
55
56
|
assert(UNIVERSAL_QUIPS.tired.includes(r), `第 ${i + 1} 次调用 pickQuip('tired')→ 返回值 ∈ UNIVERSAL_QUIPS.tired(实际:"${r}")`);
|
|
56
57
|
}
|
|
58
|
+
// ── 7) pickStateQuip:皮肤池覆盖该 state(单条)→ 必然返回那条 ───────────────
|
|
59
|
+
{
|
|
60
|
+
const stateQuips = { done: ['Twinkle! 任务完成 ✨'] };
|
|
61
|
+
const r = pickStateQuip('done', stateQuips);
|
|
62
|
+
assert(r === 'Twinkle! 任务完成 ✨', `pickStateQuip 皮肤池覆盖 done(单条)→ 返回皮肤池文案(实际:"${r}")`);
|
|
63
|
+
}
|
|
64
|
+
// ── 8) pickStateQuip:皮肤池覆盖该 state(多条)→ 20 次随机都在候选池内 ───────
|
|
65
|
+
{
|
|
66
|
+
const list = ['Twinkle! 任务完成 ✨', '✦ DONE ✦', '任务收工'];
|
|
67
|
+
const stateQuips = { done: list };
|
|
68
|
+
for (let i = 0; i < 20; i++) {
|
|
69
|
+
const r = pickStateQuip('done', stateQuips);
|
|
70
|
+
assert(list.includes(r ?? ''), `pickStateQuip done(多条)→ 返回值 ∈ 皮肤池(实际:"${r}")`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// ── 9) pickStateQuip:皮肤池未配置该 state → 返回 null ─────────────────────
|
|
74
|
+
{
|
|
75
|
+
const stateQuips = { aborted: ['再见'] };
|
|
76
|
+
const r = pickStateQuip('done', stateQuips);
|
|
77
|
+
assert(r === null, `pickStateQuip 皮肤池未配置 done → 返回 null(实际:${JSON.stringify(r)})`);
|
|
78
|
+
}
|
|
79
|
+
// ── 10) pickStateQuip:皮肤池该 state 是空数组 → 返回 null(视为未配置) ───
|
|
80
|
+
{
|
|
81
|
+
const stateQuips = { done: [] };
|
|
82
|
+
const r = pickStateQuip('done', stateQuips);
|
|
83
|
+
assert(r === null, `pickStateQuip done 为空数组 → 返回 null(实际:${JSON.stringify(r)})`);
|
|
84
|
+
}
|
|
85
|
+
// ── 11) pickStateQuip:未传 stateQuips → 返回 null ────────────────────────
|
|
86
|
+
{
|
|
87
|
+
const r = pickStateQuip('error');
|
|
88
|
+
assert(r === null, `pickStateQuip 未传 stateQuips → 返回 null(实际:${JSON.stringify(r)})`);
|
|
89
|
+
}
|
|
90
|
+
// ── 12) pickStateQuip:8 种 PetState 全部可作为 key 命中,未命中返回 null ────
|
|
91
|
+
{
|
|
92
|
+
const ALL_STATES = ['idle', 'thinking', 'speaking', 'tool_call', 'done', 'aborted', 'error', 'waiting_human'];
|
|
93
|
+
for (const state of ALL_STATES) {
|
|
94
|
+
const r = pickStateQuip(state);
|
|
95
|
+
assert(r === null, `pickStateQuip('${state}') 在空配置下 → 返回 null(实际:${JSON.stringify(r)})`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
57
98
|
console.log(`\nOK: ${passed} passed, 0 failed`);
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
// 不 import 'electron',不引用真实的全局 window/document,只操作调用方传入的、符合
|
|
3
3
|
// "最小 DOM 元素形状"接口(ElementLike/LinkElementLike)的对象,使这部分逻辑可以脱离真实浏览器
|
|
4
4
|
// 环境(本项目未安装 jsdom)被单元测试覆盖——与任务 4 里 main.ts → mood-tracker.ts 的抽取方式同构。
|
|
5
|
-
/**
|
|
6
|
-
|
|
5
|
+
/** 气泡文案自动淡出隐藏前的默认展示时长(design.md "情绪 → 演出的驱动"默认假设);
|
|
6
|
+
* 运行期可由 MOCODE_PET_QUIP_VISIBLE_MS 环境变量(经 preload.ts → window.petBridge.quipVisibleMs)覆盖,
|
|
7
|
+
* 不用重新打包就能调"宠物说一句话停多久"。 */
|
|
8
|
+
export const QUIP_VISIBLE_MS = 6000;
|
|
7
9
|
export const ALL_MOOD_CLASSES = [
|
|
8
10
|
'mood-tired',
|
|
9
11
|
'mood-bored',
|
package/dist/renderer/preload.js
CHANGED
|
@@ -3,6 +3,15 @@
|
|
|
3
3
|
// 不给渲染进程任何 Node/Electron API 访问权限,只转发一个只读回调注册接口)。
|
|
4
4
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
5
|
const electron_1 = require("electron");
|
|
6
|
+
/** 与 mood.ts 同样的环境变量读法:取合法正数,非法/缺失回退默认值。 */
|
|
7
|
+
function envNumber(name, defaultValue) {
|
|
8
|
+
const v = process.env[name];
|
|
9
|
+
const n = v ? Number(v) : NaN;
|
|
10
|
+
return Number.isFinite(n) && n > 0 ? n : defaultValue;
|
|
11
|
+
}
|
|
12
|
+
/** 气泡文案展示时长(ms),环境变量 MOCODE_PET_QUIP_VISIBLE_MS 覆盖,默认 6000(2x 老值 3000)。
|
|
13
|
+
* 渲染进程 renderer.ts 在模块顶层读取该值,后续 showQuip 调用都使用它——启动后变更需重启 pet-app 生效。 */
|
|
14
|
+
const QUIP_VISIBLE_MS = envNumber('MOCODE_PET_QUIP_VISIBLE_MS', 6000);
|
|
6
15
|
electron_1.contextBridge.exposeInMainWorld('petBridge', {
|
|
7
16
|
onState: (callback) => {
|
|
8
17
|
electron_1.ipcRenderer.on('pet:state', (_event, payload) => {
|
|
@@ -18,6 +27,8 @@ electron_1.contextBridge.exposeInMainWorld('petBridge', {
|
|
|
18
27
|
},
|
|
19
28
|
/** 启动时主动拉取当前皮肤(避免 did-finish-load 推送与监听器注册之间的时序竞争)。 */
|
|
20
29
|
getInitialSkin: () => electron_1.ipcRenderer.invoke('pet:get-skin'),
|
|
30
|
+
/** 气泡文案展示时长(ms),renderer.ts 在模块顶层读取一次后用于所有 showQuip 调用。 */
|
|
31
|
+
quipVisibleMs: QUIP_VISIBLE_MS,
|
|
21
32
|
/** 主进程推来的 mood 求值结果(见 main.ts pushMoodEvaluation),mood 为 null 表示当前无需展示情绪演出。 */
|
|
22
33
|
onMood: (callback) => {
|
|
23
34
|
electron_1.ipcRenderer.on('pet:mood', (_event, payload) => {
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// 渲染进程:纯展示逻辑,不含业务判断。接收 preload 暴露的 petBridge.onState 回调,
|
|
2
2
|
// 按 PetState 切换外层容器的 CSS class,驱动 style.css 里定义的 @keyframes 动画。
|
|
3
3
|
// mascot.svg 本身的呼吸灯/眨眼动画(inline <animate>)始终运行,不受这里的 class 切换影响。
|
|
4
|
-
import { applyMood, applySkinMotion, createBubbleController
|
|
4
|
+
import { applyMood, applySkinMotion, createBubbleController } from './dom-mood.js';
|
|
5
|
+
/** 气泡文案展示时长,从 preload 经 MOCODE_PET_QUIP_VISIBLE_MS 环境变量注入,默认 6000(见 dom-mood.ts QUIP_VISIBLE_MS)。 */
|
|
6
|
+
const QUIP_VISIBLE_MS = window.petBridge.quipVisibleMs;
|
|
5
7
|
const ALL_STATE_CLASSES = [
|
|
6
8
|
'pet-idle',
|
|
7
9
|
'pet-thinking',
|
|
@@ -78,7 +80,9 @@ window.addEventListener('DOMContentLoaded', async () => {
|
|
|
78
80
|
window.petBridge.onSkin(({ assetPath, motionFile }) => swapSkin(petContainer, assetPath, motionFile));
|
|
79
81
|
window.petBridge.onMood((mood, quip) => {
|
|
80
82
|
applyMood(stage, mood);
|
|
81
|
-
|
|
83
|
+
// 状态文案(由 main.ts broadcastToRenderer 选 stateQuips 推送过来)和 mood 文案共用同一条 IPC;
|
|
84
|
+
// 状态文案的 mood 字段为 null,只要 quip 存在就展示;mood 文案按之前的语义(mood 命中)也展示。
|
|
85
|
+
if (quip)
|
|
82
86
|
bubbleController.showQuip(bubbleEl, quip, QUIP_VISIBLE_MS);
|
|
83
87
|
});
|
|
84
88
|
// 拖拽放置:窗口默认鼠标穿透(见 main.ts setIgnoreMouseEvents(true,{forward:true})),
|
package/dist/skins.js
CHANGED
|
@@ -10,11 +10,14 @@ let cached = null;
|
|
|
10
10
|
export function skinsDir() {
|
|
11
11
|
return path.join(__dirname, 'assets', 'pets');
|
|
12
12
|
}
|
|
13
|
-
/** 从 manifest 里单个 pet 条目对象中,宽容解析出 motionFile/quips
|
|
13
|
+
/** 从 manifest 里单个 pet 条目对象中,宽容解析出 motionFile/quips/stateQuips 三个可选字段。
|
|
14
14
|
* 纯函数,不涉及文件 I/O,便于单元测试覆盖各种非法输入的容错行为。
|
|
15
15
|
* - motionFile:必须是 string,否则不设置该字段。
|
|
16
16
|
* - quips:必须是非 null 对象;逐个 key 校验 value 是否为 string[],非法 key 被跳过;
|
|
17
|
-
* 若最终没有任何合法 key,则不设置 quips 字段。
|
|
17
|
+
* 若最终没有任何合法 key,则不设置 quips 字段。
|
|
18
|
+
* - stateQuips:与 quips 同结构(只是 key 集合是 PetState 而非 MoodKind);
|
|
19
|
+
* 所有 key 都会被原样保留(parseClientMessage 已对 PetState 做合法性校验,这里不做二次校验
|
|
20
|
+
* 以避免重复维护合法状态列表),但仍逐个校验 value 必须是 string[]。 */
|
|
18
21
|
export function parseSkinEntryExtras(pp) {
|
|
19
22
|
const result = {};
|
|
20
23
|
if (typeof pp.motionFile === 'string') {
|
|
@@ -33,6 +36,19 @@ export function parseSkinEntryExtras(pp) {
|
|
|
33
36
|
result.quips = quips;
|
|
34
37
|
}
|
|
35
38
|
}
|
|
39
|
+
if (typeof pp.stateQuips === 'object' && pp.stateQuips !== null) {
|
|
40
|
+
const rawStateQuips = pp.stateQuips;
|
|
41
|
+
const stateQuips = {};
|
|
42
|
+
for (const key of Object.keys(rawStateQuips)) {
|
|
43
|
+
const value = rawStateQuips[key];
|
|
44
|
+
if (Array.isArray(value) && value.every((v) => typeof v === 'string')) {
|
|
45
|
+
stateQuips[key] = value;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (Object.keys(stateQuips).length > 0) {
|
|
49
|
+
result.stateQuips = stateQuips;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
36
52
|
return result;
|
|
37
53
|
}
|
|
38
54
|
/** 读取 manifest.json 里的候选皮肤列表(带内存缓存,manifest 在运行期不会变化)。 */
|
|
@@ -64,6 +80,8 @@ export function listSkinEntries() {
|
|
|
64
80
|
entry.motionFile = extras.motionFile;
|
|
65
81
|
if (extras.quips !== undefined)
|
|
66
82
|
entry.quips = extras.quips;
|
|
83
|
+
if (extras.stateQuips !== undefined)
|
|
84
|
+
entry.stateQuips = extras.stateQuips;
|
|
67
85
|
entries.push(entry);
|
|
68
86
|
}
|
|
69
87
|
}
|
package/dist/skins.test.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// 单测 parseSkinEntryExtras:对 manifest 单个 pet 条目里 motionFile/quips 字段的宽容解析。
|
|
1
|
+
// 单测 parseSkinEntryExtras:对 manifest 单个 pet 条目里 motionFile/quips/stateQuips 字段的宽容解析。
|
|
2
2
|
// 纯函数、无文件 I/O,覆盖各种合法/非法输入的容错行为。
|
|
3
3
|
// 运行:npx tsx packages/pet-app/src/skins.test.ts
|
|
4
4
|
import { parseSkinEntryExtras } from './skins.js';
|
|
@@ -70,4 +70,43 @@ const assert = (cond, msg) => {
|
|
|
70
70
|
assert(r.quips === undefined, '其余字段解析不受影响时,quips 仍为 undefined');
|
|
71
71
|
assert(Object.keys(r).length === 0, '两字段皆缺失时返回空对象 {}');
|
|
72
72
|
}
|
|
73
|
+
// ── stateQuips 字段解析覆盖 ────────────────────────────────────────────────
|
|
74
|
+
// 10) stateQuips 是合法对象 → 保留
|
|
75
|
+
{
|
|
76
|
+
const r = parseSkinEntryExtras({
|
|
77
|
+
stateQuips: { done: ['Twinkle! 任务完成 ✨'], aborted: ['再见'] },
|
|
78
|
+
});
|
|
79
|
+
assert(JSON.stringify(r.stateQuips) === JSON.stringify({ done: ['Twinkle! 任务完成 ✨'], aborted: ['再见'] }), 'stateQuips 为合法对象时按原样保留');
|
|
80
|
+
}
|
|
81
|
+
// 11) stateQuips 不是对象(字符串/数字/数组/null)→ 不设置
|
|
82
|
+
{
|
|
83
|
+
assert(parseSkinEntryExtras({ stateQuips: 'foo' }).stateQuips === undefined, 'stateQuips 为字符串时不设置该字段');
|
|
84
|
+
assert(parseSkinEntryExtras({ stateQuips: 42 }).stateQuips === undefined, 'stateQuips 为数字时不设置该字段');
|
|
85
|
+
assert(parseSkinEntryExtras({ stateQuips: ['done'] }).stateQuips === undefined, 'stateQuips 为数组(无对象结构)时不设置该字段');
|
|
86
|
+
assert(parseSkinEntryExtras({ stateQuips: null }).stateQuips === undefined, 'stateQuips 为 null 时不设置该字段');
|
|
87
|
+
}
|
|
88
|
+
// 12) stateQuips 数组元素含非字符串 → 该 key 跳过,合法 key 保留
|
|
89
|
+
{
|
|
90
|
+
const r = parseSkinEntryExtras({
|
|
91
|
+
stateQuips: { done: ['ok', 123], aborted: ['ok'] },
|
|
92
|
+
});
|
|
93
|
+
assert(r.stateQuips !== undefined, 'stateQuips 存在合法 key 时应设置该字段');
|
|
94
|
+
assert(r.stateQuips.done === undefined, 'stateQuips 非法 key(done)被跳过');
|
|
95
|
+
assert(JSON.stringify(r.stateQuips.aborted) === JSON.stringify(['ok']), 'stateQuips 合法 key(aborted)被保留');
|
|
96
|
+
}
|
|
97
|
+
// 13) stateQuips 是空对象 → 不设置
|
|
98
|
+
{
|
|
99
|
+
assert(parseSkinEntryExtras({ stateQuips: {} }).stateQuips === undefined, 'stateQuips 为空对象(无 key)时不设置该字段');
|
|
100
|
+
}
|
|
101
|
+
// 14) 三字段同时存在 → 互不影响
|
|
102
|
+
{
|
|
103
|
+
const r = parseSkinEntryExtras({
|
|
104
|
+
motionFile: '11.motion.css',
|
|
105
|
+
quips: { tired: ['困'] },
|
|
106
|
+
stateQuips: { done: ['完成'] },
|
|
107
|
+
});
|
|
108
|
+
assert(r.motionFile === '11.motion.css', '三字段同时存在时 motionFile 保留');
|
|
109
|
+
assert(JSON.stringify(r.quips) === JSON.stringify({ tired: ['困'] }), '三字段同时存在时 quips 保留');
|
|
110
|
+
assert(JSON.stringify(r.stateQuips) === JSON.stringify({ done: ['完成'] }), '三字段同时存在时 stateQuips 保留');
|
|
111
|
+
}
|
|
73
112
|
console.log(`\nOK: ${passed} passed, 0 failed`);
|