mocode-pet-app 1.4.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/01-robo-cat.motion.css +42 -0
- package/assets/pets/01-robo-cat.svg +2 -2
- package/assets/pets/02-robo-dog.motion.css +42 -0
- package/assets/pets/02-robo-dog.svg +1 -1
- package/assets/pets/03-robo-fox.motion.css +63 -0
- package/assets/pets/03-robo-fox.svg +1 -1
- package/assets/pets/04-robo-panda.motion.css +61 -0
- package/assets/pets/04-robo-panda.svg +1 -1
- package/assets/pets/05-robo-owl.motion.css +88 -0
- package/assets/pets/05-robo-owl.svg +3 -3
- package/assets/pets/06-robo-bunny.motion.css +84 -0
- package/assets/pets/06-robo-bunny.svg +2 -2
- package/assets/pets/07-robo-frog.motion.css +65 -0
- package/assets/pets/07-robo-frog.svg +3 -3
- package/assets/pets/08-robo-bear.motion.css +65 -0
- package/assets/pets/09-robo-penguin.motion.css +94 -0
- package/assets/pets/09-robo-penguin.svg +3 -3
- package/assets/pets/10-robo-dino.motion.css +91 -0
- package/assets/pets/10-robo-dino.svg +3 -3
- package/assets/pets/11-slime-blob.motion.css +44 -0
- package/assets/pets/11-slime-blob.svg +3 -3
- package/assets/pets/12-ghost-byte.motion.css +73 -0
- package/assets/pets/12-ghost-byte.svg +3 -3
- package/assets/pets/13-cactus-bot.motion.css +90 -0
- package/assets/pets/13-cactus-bot.svg +2 -2
- package/assets/pets/14-crystal-bot.motion.css +63 -0
- package/assets/pets/15-satellite-bot.motion.css +72 -0
- package/assets/pets/15-satellite-bot.svg +1 -1
- package/assets/pets/16-jellyfish-bot.motion.css +71 -0
- package/assets/pets/16-jellyfish-bot.svg +3 -3
- package/assets/pets/17-mushroom-bot.motion.css +65 -0
- package/assets/pets/18-star-bot.motion.css +72 -0
- package/assets/pets/18-star-bot.svg +3 -3
- package/assets/pets/manifest.json +139 -25
- package/dist/assets/pets/01-robo-cat.motion.css +42 -0
- package/dist/assets/pets/01-robo-cat.svg +2 -2
- package/dist/assets/pets/02-robo-dog.motion.css +42 -0
- package/dist/assets/pets/02-robo-dog.svg +1 -1
- package/dist/assets/pets/03-robo-fox.motion.css +63 -0
- package/dist/assets/pets/03-robo-fox.svg +1 -1
- package/dist/assets/pets/04-robo-panda.motion.css +61 -0
- package/dist/assets/pets/04-robo-panda.svg +1 -1
- package/dist/assets/pets/05-robo-owl.motion.css +88 -0
- package/dist/assets/pets/05-robo-owl.svg +3 -3
- package/dist/assets/pets/06-robo-bunny.motion.css +84 -0
- package/dist/assets/pets/06-robo-bunny.svg +2 -2
- package/dist/assets/pets/07-robo-frog.motion.css +65 -0
- package/dist/assets/pets/07-robo-frog.svg +3 -3
- 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/09-robo-penguin.svg +3 -3
- package/dist/assets/pets/10-robo-dino.motion.css +91 -0
- package/dist/assets/pets/10-robo-dino.svg +3 -3
- package/dist/assets/pets/11-slime-blob.motion.css +44 -0
- package/dist/assets/pets/11-slime-blob.svg +3 -3
- package/dist/assets/pets/12-ghost-byte.motion.css +73 -0
- package/dist/assets/pets/12-ghost-byte.svg +3 -3
- package/dist/assets/pets/13-cactus-bot.motion.css +90 -0
- package/dist/assets/pets/13-cactus-bot.svg +2 -2
- 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/15-satellite-bot.svg +1 -1
- package/dist/assets/pets/16-jellyfish-bot.motion.css +71 -0
- package/dist/assets/pets/16-jellyfish-bot.svg +3 -3
- 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/18-star-bot.svg +3 -3
- package/dist/assets/pets/manifest.json +139 -25
- package/dist/assets/tray-icon.png +0 -0
- package/dist/e2e-mood-check.js +150 -0
- package/dist/main.js +53 -2
- package/dist/mood-current-state.test.js +55 -0
- package/dist/mood-determinism.pbt.js +66 -0
- package/dist/mood-tracker-timing.test.js +70 -0
- package/dist/mood-tracker.js +41 -0
- package/dist/mood-tracker.test.js +56 -0
- package/dist/mood.js +102 -0
- package/dist/mood.pbt.js +52 -0
- package/dist/quips.js +59 -0
- package/dist/quips.test.js +98 -0
- package/dist/renderer/dom-mood.js +55 -0
- package/dist/renderer/dom-mood.test.js +105 -0
- package/dist/renderer/index.html +2 -0
- package/dist/renderer/preload.js +20 -2
- package/dist/renderer/renderer.js +29 -9
- package/dist/renderer/style.css +89 -0
- package/dist/skins.js +50 -1
- package/dist/skins.test.js +112 -0
- package/package.json +1 -1
|
@@ -1,25 +1,139 @@
|
|
|
1
|
-
{
|
|
2
|
-
"_description": "候选桌宠素材索引,供未来 desktop-pet '选皮' 功能读取。当前 packages/pet-app 实现仅使用 assets/mascot.svg(单一形象+CSS状态动画),这些素材是为后续换皮功能预留的候选库。",
|
|
3
|
-
"viewBox": "0 0 256 256",
|
|
4
|
-
"style": "赛博终端风,深色机身(#1b263b/#0d1b2a)+ 屏幕脸(黑底彩色像素眼,呼吸/眨眼动画),与 assets/mascot.svg 视觉语言一致",
|
|
5
|
-
"pets": [
|
|
6
|
-
{ "id": "robo-cat", "file": "01-robo-cat.svg", "name": "机械猫", "accent": "#2afadf"
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
{ "id": "robo-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
{ "id": "
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
1
|
+
{
|
|
2
|
+
"_description": "候选桌宠素材索引,供未来 desktop-pet '选皮' 功能读取。当前 packages/pet-app 实现仅使用 assets/mascot.svg(单一形象+CSS状态动画),这些素材是为后续换皮功能预留的候选库。",
|
|
3
|
+
"viewBox": "0 0 256 256",
|
|
4
|
+
"style": "赛博终端风,深色机身(#1b263b/#0d1b2a)+ 屏幕脸(黑底彩色像素眼,呼吸/眨眼动画),与 assets/mascot.svg 视觉语言一致",
|
|
5
|
+
"pets": [
|
|
6
|
+
{ "id": "robo-cat", "file": "01-robo-cat.svg", "name": "机械猫", "accent": "#2afadf",
|
|
7
|
+
"motionFile": "01-robo-cat.motion.css",
|
|
8
|
+
"quips": {
|
|
9
|
+
"tired": ["猫猫要趴一下…", "喵…有点累了,尾巴都垂下来了"],
|
|
10
|
+
"bored": ["理理毛,顺便等你", "尾巴甩甩,好无聊喵"],
|
|
11
|
+
"urging": ["喵?还没好吗", "尾巴都等急了喵"]
|
|
12
|
+
} },
|
|
13
|
+
{ "id": "robo-dog", "file": "02-robo-dog.svg", "name": "机械犬", "accent": "#ffb74d",
|
|
14
|
+
"motionFile": "02-robo-dog.motion.css",
|
|
15
|
+
"quips": {
|
|
16
|
+
"bored": ["打了个哈欠,好无聊呀", "尾巴都不知道摇给谁看了"],
|
|
17
|
+
"urging": ["快点快点,舌头都伸出来啦", "汪!还在等你呢"],
|
|
18
|
+
"flustered": ["汪汪汪,有点忙不过来了", "尾巴摇得比脑子转得快"]
|
|
19
|
+
} },
|
|
20
|
+
{ "id": "robo-fox", "file": "03-robo-fox.svg", "name": "机械狐", "accent": "#ff7043",
|
|
21
|
+
"motionFile": "03-robo-fox.motion.css",
|
|
22
|
+
"quips": {
|
|
23
|
+
"thinking": ["嗅嗅…好像有什么线索喵…不对,狐狐嗅嗅"],
|
|
24
|
+
"bored": ["尾巴都扫累了,还是没动静"],
|
|
25
|
+
"urging": ["喂喂,狐狸都急得直摇尾巴了"]
|
|
26
|
+
} },
|
|
27
|
+
{ "id": "robo-panda", "file": "04-robo-panda.svg", "name": "机械熊猫", "accent": "#2afadf",
|
|
28
|
+
"motionFile": "04-robo-panda.motion.css",
|
|
29
|
+
"quips": {
|
|
30
|
+
"tired": ["呜…竹子嚼完了,想眯一会儿"],
|
|
31
|
+
"bored": ["翻个身…啊,翻不动"],
|
|
32
|
+
"urging": ["嗯…?完了吗?让我再翻个身想想"]
|
|
33
|
+
} },
|
|
34
|
+
{ "id": "robo-owl", "file": "05-robo-owl.svg", "name": "机械猫头鹰", "accent": "#7c4dff",
|
|
35
|
+
"motionFile": "05-robo-owl.motion.css",
|
|
36
|
+
"quips": {
|
|
37
|
+
"bored": ["咕咕…360° 巡视中", "夜里都飞完了,白天可困"],
|
|
38
|
+
"urging": ["扇翅膀提示你,别再让我咕咕了"]
|
|
39
|
+
} },
|
|
40
|
+
{ "id": "robo-bunny", "file": "06-robo-bunny.svg", "name": "机械兔", "accent": "#ff6ec7",
|
|
41
|
+
"motionFile": "06-robo-bunny.motion.css",
|
|
42
|
+
"quips": {
|
|
43
|
+
"tired": ["耳朵都耷拉下来了…"],
|
|
44
|
+
"bored": ["蹦跶蹦跶!…啊,又没意思了"],
|
|
45
|
+
"urging": ["蹦蹦蹦!好了没呀"]
|
|
46
|
+
} },
|
|
47
|
+
{ "id": "robo-frog", "file": "07-robo-frog.svg", "name": "机械蛙", "accent": "#00e676",
|
|
48
|
+
"motionFile": "07-robo-frog.motion.css",
|
|
49
|
+
"quips": {
|
|
50
|
+
"thinking": ["呱…再想想"],
|
|
51
|
+
"bored": ["蹲得腿都麻了呱…"],
|
|
52
|
+
"urging": ["呱呱呱!快跳到下一题了呱"]
|
|
53
|
+
} },
|
|
54
|
+
{ "id": "robo-bear", "file": "08-robo-bear.svg", "name": "机械熊", "accent": "#ffa726",
|
|
55
|
+
"motionFile": "08-robo-bear.motion.css",
|
|
56
|
+
"quips": {
|
|
57
|
+
"tired": ["冬眠时间快到了…zzZ"],
|
|
58
|
+
"bored": ["蜂蜜都被我吃完了,无聊"],
|
|
59
|
+
"urging": ["熊!都!要!等!不!住!了!"]
|
|
60
|
+
} },
|
|
61
|
+
{ "id": "robo-penguin", "file": "09-robo-penguin.svg", "name": "机械企鹅", "accent": "#42a5f5",
|
|
62
|
+
"motionFile": "09-robo-penguin.motion.css",
|
|
63
|
+
"quips": {
|
|
64
|
+
"bored": ["左右摇,左右摇…站久了脚好冷"],
|
|
65
|
+
"urging": ["嘎嘎!鳍都拍红了!"],
|
|
66
|
+
"tired": ["南极了,太冷,想躺平"]
|
|
67
|
+
} },
|
|
68
|
+
{ "id": "robo-dino", "file": "10-robo-dino.svg", "name": "机械龙", "accent": "#00c853",
|
|
69
|
+
"motionFile": "10-robo-dino.motion.css",
|
|
70
|
+
"quips": {
|
|
71
|
+
"frustrated": ["嗷呜!侏罗纪的怒火在燃烧!"],
|
|
72
|
+
"urging": ["跺脚!跺脚!小短腿都跺出火星了!"]
|
|
73
|
+
},
|
|
74
|
+
"stateQuips": {
|
|
75
|
+
"done": ["哼,这次算你过关,本龙勉强点头"],
|
|
76
|
+
"aborted": ["嗷!本龙还没出手呢!"],
|
|
77
|
+
"error": ["本龙摔了一跤,爬起来继续"]
|
|
78
|
+
} },
|
|
79
|
+
{ "id": "slime-blob", "file": "11-slime-blob.svg", "name": "史莱姆", "accent": "#00acc1",
|
|
80
|
+
"motionFile": "11-slime-blob.motion.css",
|
|
81
|
+
"quips": {
|
|
82
|
+
"tired": ["软软地瘫下去了…", "身体有点撑不住形状了"],
|
|
83
|
+
"urging": ["晃一晃,还在等你哦", "抖一抖,催你一下"]
|
|
84
|
+
} },
|
|
85
|
+
{ "id": "ghost-byte", "file": "12-ghost-byte.svg", "name": "字节幽灵", "accent": "#4dd0e1",
|
|
86
|
+
"motionFile": "12-ghost-byte.motion.css",
|
|
87
|
+
"quips": {
|
|
88
|
+
"tired": ["电量耗尽了…要散了…"],
|
|
89
|
+
"bored": ["飘来飘去,连 bug 都飘光了"],
|
|
90
|
+
"urging": ["呜——呜——快看我一眼"]
|
|
91
|
+
} },
|
|
92
|
+
{ "id": "cactus-bot", "file": "13-cactus-bot.svg", "name": "仙人掌兽", "accent": "#66bb6a",
|
|
93
|
+
"motionFile": "13-cactus-bot.motion.css",
|
|
94
|
+
"quips": {
|
|
95
|
+
"bored": ["晒太阳晒到刺都软了"],
|
|
96
|
+
"tired": ["缺水…缺人理…"],
|
|
97
|
+
"urging": ["刺都竖起来了,快点快点"]
|
|
98
|
+
} },
|
|
99
|
+
{ "id": "crystal-bot", "file": "14-crystal-bot.svg", "name": "水晶精灵", "accent": "#7c4dff",
|
|
100
|
+
"motionFile": "14-crystal-bot.motion.css",
|
|
101
|
+
"quips": {
|
|
102
|
+
"bored": ["折射中…请勿直视"],
|
|
103
|
+
"tired": ["光谱变暗了…能量要充电"],
|
|
104
|
+
"urging": ["折射出警示光!快!快!"]
|
|
105
|
+
} },
|
|
106
|
+
{ "id": "satellite-bot", "file": "15-satellite-bot.svg", "name": "卫星兽", "accent": "#42a5f5",
|
|
107
|
+
"motionFile": "15-satellite-bot.motion.css",
|
|
108
|
+
"quips": {
|
|
109
|
+
"bored": ["轨道上只剩我一只,寂寞"],
|
|
110
|
+
"tired": ["太阳能板收起来了,需要补光"],
|
|
111
|
+
"urging": ["哔哔哔!信号强到溢出!"]
|
|
112
|
+
} },
|
|
113
|
+
{ "id": "jellyfish-bot", "file": "16-jellyfish-bot.svg", "name": "水母兽", "accent": "#ba68c8",
|
|
114
|
+
"motionFile": "16-jellyfish-bot.motion.css",
|
|
115
|
+
"quips": {
|
|
116
|
+
"tired": ["随波逐流,飘到没力气"],
|
|
117
|
+
"bored": ["深海里一只水母,在发呆"],
|
|
118
|
+
"urging": ["刺一下!刺一下!…啊我没有刺"]
|
|
119
|
+
} },
|
|
120
|
+
{ "id": "mushroom-bot", "file": "17-mushroom-bot.svg", "name": "菌菇兽", "accent": "#c62828",
|
|
121
|
+
"motionFile": "17-mushroom-bot.motion.css",
|
|
122
|
+
"quips": {
|
|
123
|
+
"tired": ["菌盖都瘪了,要孢子休了"],
|
|
124
|
+
"bored": ["在森林角落,洒了一把无聊的孢子"],
|
|
125
|
+
"urging": ["蹦!蹦!蹦!Q弹到爆!"]
|
|
126
|
+
} },
|
|
127
|
+
{ "id": "star-bot", "file": "18-star-bot.svg", "name": "星星兽", "accent": "#ff8f00",
|
|
128
|
+
"motionFile": "18-star-bot.motion.css",
|
|
129
|
+
"quips": {
|
|
130
|
+
"frustrated": ["星芒都烧起来了!急!急!"],
|
|
131
|
+
"urging": ["一闪一闪亮晶晶,催你快回应"]
|
|
132
|
+
},
|
|
133
|
+
"stateQuips": {
|
|
134
|
+
"done": ["Twinkle! 任务完成 ✨"],
|
|
135
|
+
"aborted": ["流星中途熄灭了…"],
|
|
136
|
+
"error": ["星星也黯淡了一瞬,别慌,会再亮起来的"]
|
|
137
|
+
} }
|
|
138
|
+
]
|
|
139
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// 任务 8:端到端逻辑验证(脚本化,不依赖真实 Electron/图形环境/真实 mocode CLI 进程)。
|
|
2
|
+
// 直接调用已完成的纯逻辑模块 mood.ts / mood-tracker.ts / quips.ts / skins.ts,
|
|
3
|
+
// 模拟 design.md 里描述的完整事件序列,断言 Mood 链路与皮肤个性化行为符合预期。
|
|
4
|
+
// 本脚本只读验证,不修改被验证的任何模块。
|
|
5
|
+
// 运行(在 f:\mocode 根目录):npx tsx packages/pet-app/src/e2e-mood-check.ts
|
|
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');
|
|
26
|
+
let passed = 0;
|
|
27
|
+
const assert = (cond, msg) => {
|
|
28
|
+
if (!cond) {
|
|
29
|
+
console.error('✗ ' + msg);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
passed++;
|
|
33
|
+
console.log('✓ ' + msg);
|
|
34
|
+
};
|
|
35
|
+
try {
|
|
36
|
+
// ── 场景1:agent 长时间停留在 thinking(同理 tool_call)触发 tired ─────────
|
|
37
|
+
// 每 5000ms 上报一次 thinking,ts: 0..60000。默认 tiredMs=60000。
|
|
38
|
+
{
|
|
39
|
+
const tracker = createMoodTracker();
|
|
40
|
+
let reachedTired = false;
|
|
41
|
+
for (let ts = 0; ts <= 60000; ts += 5000) {
|
|
42
|
+
tracker.recordState('thinking', ts);
|
|
43
|
+
const res = tracker.evaluate(ts);
|
|
44
|
+
if (ts < 60000) {
|
|
45
|
+
assert(res === null, `场景1: ts=${ts} (<60000) 时不应推送 tired(evaluate 应返回 null,实际 ${JSON.stringify(res)})`);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
assert(res !== null && res.mood === 'tired', `场景1: ts=60000(达到 tiredMs 阈值)时 mood 应变为 tired(实际 ${JSON.stringify(res)})`);
|
|
49
|
+
reachedTired = true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
assert(reachedTired, '场景1: 已跑到 ts=60000 并验证 tired 触发');
|
|
53
|
+
}
|
|
54
|
+
// ── 场景2:10 秒内 5 次 tool_call 触发 flustered ─────────────────────────
|
|
55
|
+
// 默认 toolWindowMs=10000, toolCount=5。
|
|
56
|
+
{
|
|
57
|
+
const tracker = createMoodTracker();
|
|
58
|
+
for (const ts of [0, 2000, 4000, 6000, 8000]) {
|
|
59
|
+
tracker.recordState('tool_call', ts);
|
|
60
|
+
}
|
|
61
|
+
const res = tracker.evaluate(8000);
|
|
62
|
+
assert(res !== null && res.mood === 'flustered', `场景2: 10s 内 5 次 tool_call → mood 应为 flustered(实际 ${JSON.stringify(res)})`);
|
|
63
|
+
}
|
|
64
|
+
// ── 场景3:60 秒内 2 次 error 触发 frustrated ────────────────────────────
|
|
65
|
+
// 默认 errorWindowMs=60000, errorCount=2。
|
|
66
|
+
{
|
|
67
|
+
const tracker = createMoodTracker();
|
|
68
|
+
tracker.recordState('error', 0);
|
|
69
|
+
tracker.recordState('error', 30000);
|
|
70
|
+
const res = tracker.evaluate(30000);
|
|
71
|
+
assert(res !== null && res.mood === 'frustrated', `场景3: 60s 窗口内 2 次 error → mood 应为 frustrated(实际 ${JSON.stringify(res)})`);
|
|
72
|
+
}
|
|
73
|
+
// ── 场景4:waiting_human 超时触发 urging ─────────────────────────────────
|
|
74
|
+
// 默认 urgingMs=30000。
|
|
75
|
+
{
|
|
76
|
+
const tracker = createMoodTracker();
|
|
77
|
+
tracker.recordState('waiting_human', 0);
|
|
78
|
+
const res = tracker.evaluate(30000);
|
|
79
|
+
assert(res !== null && res.mood === 'urging', `场景4: waiting_human 持续 30000ms → mood 应为 urging(实际 ${JSON.stringify(res)})`);
|
|
80
|
+
}
|
|
81
|
+
// ── 场景5:idle 超时触发 bored ────────────────────────────────────────────
|
|
82
|
+
// 默认 boredMs=300000。
|
|
83
|
+
{
|
|
84
|
+
const tracker = createMoodTracker();
|
|
85
|
+
tracker.recordState('idle', 0);
|
|
86
|
+
const res = tracker.evaluate(300000);
|
|
87
|
+
assert(res !== null && res.mood === 'bored', `场景5: idle 持续 300000ms → mood 应为 bored(实际 ${JSON.stringify(res)})`);
|
|
88
|
+
}
|
|
89
|
+
// ── 场景6:多条件同时满足,只呈现最高优先级 mood ──────────────────────────
|
|
90
|
+
// 选用组合:frustrated(errorCount=2,errorWindowMs=60000) vs tired(tiredMs=60000)。
|
|
91
|
+
// 关键设计:tired/bored/urging 只看"末尾同状态连续段"的起始时间(currentStateSince),
|
|
92
|
+
// 而 frustrated/flustered 看的是"整个事件历史里落在滑动窗口内"的计数,两者不互斥。
|
|
93
|
+
// 序列:error@0, error@0, thinking@0 —— 末尾状态是 thinking,起始时间就是它自己的 ts=0;
|
|
94
|
+
// 同时前面两条 error 事件的 ts=0 仍落在以 now=60000、宽度 60000 的窗口 [0,60000] 内。
|
|
95
|
+
// 于是在 now=60000 时:tired 条件成立(60000-0>=60000)且 frustrated 条件成立(窗口内 2 条 error)。
|
|
96
|
+
// MOOD_PRIORITY = [frustrated, flustered, urging, tired, bored],frustrated 排在 tired 之前,
|
|
97
|
+
// 因此应最终呈现 frustrated 而不是 tired。
|
|
98
|
+
{
|
|
99
|
+
const tracker = createMoodTracker();
|
|
100
|
+
tracker.recordState('error', 0);
|
|
101
|
+
tracker.recordState('error', 0);
|
|
102
|
+
tracker.recordState('thinking', 0);
|
|
103
|
+
const res = tracker.evaluate(60000);
|
|
104
|
+
assert(res !== null && res.mood === 'frustrated', `场景6: frustrated(2次error@60000ms窗口内) 与 tired(thinking持续60000ms) 同时满足 → 应呈现优先级更高的 frustrated,不是 tired(实际 ${JSON.stringify(res)})`);
|
|
105
|
+
}
|
|
106
|
+
// ── 场景7:断开活跃连接重新连接后,mood 判定不受上一个会话历史事件影响 ────
|
|
107
|
+
{
|
|
108
|
+
const tracker = createMoodTracker();
|
|
109
|
+
tracker.recordState('thinking', 0);
|
|
110
|
+
const tiredRes = tracker.evaluate(60000);
|
|
111
|
+
assert(tiredRes !== null && tiredRes.mood === 'tired', `场景7: 会话A 先触发 tired 作为前置条件(实际 ${JSON.stringify(tiredRes)})`);
|
|
112
|
+
tracker.reset();
|
|
113
|
+
// 用同一个 now=60000 再 evaluate 一次:若 events 未被真正清空,tired 条件依旧成立且
|
|
114
|
+
// mood 与上次推送的 'tired' 相同 → evaluate 会因"未变化"返回 JS null(不会是 {mood:null})。
|
|
115
|
+
// 只有 events 真被清空,deriveMood([], 60000) 才会算出 null,与上次推送的 'tired' 不同,
|
|
116
|
+
// 从而触发一次"变化推送"返回 {mood:null}。用这个差异来验证 reset 确实清空了事件缓冲。
|
|
117
|
+
const afterReset = tracker.evaluate(60000);
|
|
118
|
+
assert(afterReset !== null && afterReset.mood === null, `场景7: reset() 后立即 evaluate 应返回 {mood:null}(证明 events 已清空,不是简单的"未变化"判定;实际 ${JSON.stringify(afterReset)})`);
|
|
119
|
+
// 新会话从零开始:只记一条 idle 事件,时间差远小于 boredMs(300000),不应触发任何 mood,
|
|
120
|
+
// 也不应因为旧会话的 tired 历史残留而误判。
|
|
121
|
+
tracker.recordState('idle', 60000);
|
|
122
|
+
const newSessionRes = tracker.evaluate(61000);
|
|
123
|
+
assert(newSessionRes === null, `场景7: 新会话记一条 idle、时间差(1000ms)远小于 boredMs → mood 应仍是 null(evaluate 返回 null,无残留旧 mood;实际 ${JSON.stringify(newSessionRes)})`);
|
|
124
|
+
}
|
|
125
|
+
// ── 场景8:定制皮肤展示专属文案,未定制皮肤正确回退通用池 ──────────────────
|
|
126
|
+
{
|
|
127
|
+
const entries = listSkinEntries();
|
|
128
|
+
const cat = entries.find((e) => e.id === 'robo-cat');
|
|
129
|
+
assert(!!cat, '场景8: manifest.json 中应存在 robo-cat 条目');
|
|
130
|
+
assert(!!cat?.quips && Array.isArray(cat.quips.tired) && Array.isArray(cat.quips.bored) && Array.isArray(cat.quips.urging), `场景8: robo-cat.quips 应包含 tired/bored/urging 数组字段(实际 ${JSON.stringify(cat?.quips)})`);
|
|
131
|
+
const catTired = cat.quips.tired;
|
|
132
|
+
for (let i = 0; i < 20; i++) {
|
|
133
|
+
const q = pickQuip('tired', cat.quips);
|
|
134
|
+
assert(catTired.includes(q), `场景8: robo-cat 的 tired 文案应从专属池选取(第 ${i + 1} 次得到 "${q}",专属池 ${JSON.stringify(catTired)})`);
|
|
135
|
+
}
|
|
136
|
+
const fox = entries.find((e) => e.id === 'robo-fox');
|
|
137
|
+
assert(!!fox, '场景8: manifest.json 中应存在 robo-fox 条目');
|
|
138
|
+
assert(fox?.quips === undefined, `场景8: robo-fox 未定制 quips,应解析为 undefined(实际 ${JSON.stringify(fox?.quips)})`);
|
|
139
|
+
for (let i = 0; i < 20; i++) {
|
|
140
|
+
const q = pickQuip('tired', fox.quips);
|
|
141
|
+
assert(UNIVERSAL_QUIPS.tired.includes(q), `场景8: robo-fox 未定制皮肤应回退通用池(第 ${i + 1} 次得到 "${q}",通用池 ${JSON.stringify(UNIVERSAL_QUIPS.tired)})`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
console.log(`\nOK: ${passed} passed, 0 failed`);
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
if (createdTempAssets) {
|
|
148
|
+
rmSync(tempAssetsDir, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -8,6 +8,7 @@ import path from 'node:path';
|
|
|
8
8
|
import { parseClientMessage, } from './protocol.js';
|
|
9
9
|
import { loadConfig, saveConfig } from './config.js';
|
|
10
10
|
import { listSkinEntries, resolveSkinPath } from './skins.js';
|
|
11
|
+
import { createMoodTracker } from './mood-tracker.js';
|
|
11
12
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
13
|
/** 默认端口;MOCODE_PET_PORT 环境变量覆盖(与 mocode 主包 src/pet/bridge.ts 保持一致的默认假设)。 */
|
|
13
14
|
const DEFAULT_PORT = 47821;
|
|
@@ -21,6 +22,8 @@ const HEARTBEAT_CHECK_INTERVAL_MS = 15000;
|
|
|
21
22
|
const HEARTBEAT_TIMEOUT_MS = 10000;
|
|
22
23
|
/** 状态展示超时后自动回落(design.md 默认假设:done/aborted/error 短暂展示 1500ms)。 */
|
|
23
24
|
const TRANSIENT_STATE_TIMEOUT_MS = 1500;
|
|
25
|
+
/** Mood ticker 检查间隔(design.md 默认假设:2000ms,状态变化时另外立即求值一次,不必等 ticker)。 */
|
|
26
|
+
const MOOD_CHECK_INTERVAL_MS = 2000;
|
|
24
27
|
/** 全局连接表 + 唯一活跃连接引用("最新连接覆盖"算法的状态载体,见 design.md Low-Level)。 */
|
|
25
28
|
const connections = new Map();
|
|
26
29
|
let activeSocket = null;
|
|
@@ -29,6 +32,8 @@ let tray = null;
|
|
|
29
32
|
let transientTimer = null;
|
|
30
33
|
/** 当前生效的皮肤 id('default' = assets/mascot.svg);启动时从持久化配置读取,运行期随 set_skin/托盘菜单变化。 */
|
|
31
34
|
let currentSkinId = 'default';
|
|
35
|
+
/** Mood 判定与文案调度(纯逻辑,见 mood-tracker.ts);随活跃连接建立/断开重置,详见 onConnection/onDisconnect。 */
|
|
36
|
+
const moodTracker = createMoodTracker();
|
|
32
37
|
/** 把状态推给渲染进程(IPC)。渲染进程窗口未就绪时静默丢弃(下次状态到达会重推)。 */
|
|
33
38
|
function broadcastToRenderer(state, meta) {
|
|
34
39
|
if (!mainWindow || mainWindow.isDestroyed())
|
|
@@ -72,6 +77,7 @@ function onConnection(socket) {
|
|
|
72
77
|
};
|
|
73
78
|
connections.set(socket, record);
|
|
74
79
|
activeSocket = socket;
|
|
80
|
+
moodTracker.reset(); // 新会话从空事件历史开始,不受上一个活跃连接遗留事件影响(design.md 事件缓冲生命周期)
|
|
75
81
|
send(socket, { type: 'welcome', isActive: true, ts: Date.now() });
|
|
76
82
|
broadcastToRenderer('idle'); // 新连接刚建立还没收到它的第一条 state,先归 idle 兜底
|
|
77
83
|
socket.on('message', (data) => onMessage(socket, String(data)));
|
|
@@ -88,6 +94,7 @@ function onDisconnect(socket) {
|
|
|
88
94
|
if (wasActive) {
|
|
89
95
|
activeSocket = null; // 强制清空,不回退到任何仍 open 的连接
|
|
90
96
|
broadcastToRenderer('idle');
|
|
97
|
+
moodTracker.reset(); // 避免下一个连接复用旧历史(design.md 事件缓冲生命周期)
|
|
91
98
|
}
|
|
92
99
|
// wasActive = false:该连接原本就被忽略,断开对当前状态源无影响
|
|
93
100
|
}
|
|
@@ -119,6 +126,8 @@ function onMessage(socket, raw) {
|
|
|
119
126
|
if (socket !== activeSocket)
|
|
120
127
|
return; // 非活跃连接的状态消息静默丢弃
|
|
121
128
|
broadcastToRenderer(msg.state, msg.meta);
|
|
129
|
+
moodTracker.recordState(msg.state);
|
|
130
|
+
pushMoodEvaluation(moodTracker.evaluate(Date.now(), currentSkinQuips()));
|
|
122
131
|
return;
|
|
123
132
|
case 'shutdown':
|
|
124
133
|
// 关闭桌宠(方案C的 CLI 入口):不要求发送方是活跃连接,任何已连接的 mocode 进程均可请求关闭。
|
|
@@ -156,6 +165,11 @@ function currentSkinAssetPath() {
|
|
|
156
165
|
const abs = resolveSkinPath(currentSkinId);
|
|
157
166
|
return abs ? `../assets/pets/${path.basename(abs)}` : '../assets/mascot.svg';
|
|
158
167
|
}
|
|
168
|
+
/** 当前皮肤的个性化动作覆盖 CSS 文件名(manifest.json 的 motionFile 字段);
|
|
169
|
+
* 'default' 或未定制该字段时返回 undefined,渲染进程据此清空 `#pet-skin-motion` 的 href(回退通用样式)。 */
|
|
170
|
+
function currentSkinMotionFile() {
|
|
171
|
+
return listSkinEntries().find((e) => e.id === currentSkinId)?.motionFile;
|
|
172
|
+
}
|
|
159
173
|
/** 把当前皮肤推给渲染进程(运行期切换用,如托盘菜单点击 / CLI set_skin 消息)。
|
|
160
174
|
* 注:启动时的初始皮肤不走这条路径——渲染进程通过 'pet:get-skin' invoke 主动拉取,
|
|
161
175
|
* 避免 did-finish-load 与渲染进程异步注册监听器之间的时序竞争(IPC 消息不会缓冲,
|
|
@@ -164,14 +178,50 @@ function pushSkinToRenderer() {
|
|
|
164
178
|
if (!mainWindow || mainWindow.isDestroyed())
|
|
165
179
|
return;
|
|
166
180
|
try {
|
|
167
|
-
mainWindow.webContents.send('pet:skin', {
|
|
181
|
+
mainWindow.webContents.send('pet:skin', {
|
|
182
|
+
assetPath: currentSkinAssetPath(),
|
|
183
|
+
motionFile: currentSkinMotionFile(),
|
|
184
|
+
});
|
|
168
185
|
}
|
|
169
186
|
catch {
|
|
170
187
|
// 静默:渲染进程未就绪时忽略,下次运行期切换会重新推送
|
|
171
188
|
}
|
|
172
189
|
}
|
|
173
190
|
/** 渲染进程启动时主动拉取当前皮肤(同步于其自身初始化时机,不依赖 did-finish-load 的时序假设)。 */
|
|
174
|
-
ipcMain.handle('pet:get-skin', () => ({
|
|
191
|
+
ipcMain.handle('pet:get-skin', () => ({
|
|
192
|
+
assetPath: currentSkinAssetPath(),
|
|
193
|
+
motionFile: currentSkinMotionFile(),
|
|
194
|
+
}));
|
|
195
|
+
/** 当前皮肤的专属文案池(manifest.json 的 quips 字段),供 mood-tracker 的 pickQuip 优先选取。 */
|
|
196
|
+
function currentSkinQuips() {
|
|
197
|
+
return listSkinEntries().find((e) => e.id === currentSkinId)?.quips;
|
|
198
|
+
}
|
|
199
|
+
/** 当前皮肤的状态专属文案池(manifest.json 的 stateQuips 字段),供 broadcastToRenderer 的 pickStateQuip 选取。 */
|
|
200
|
+
function currentSkinStateQuips() {
|
|
201
|
+
return listSkinEntries().find((e) => e.id === currentSkinId)?.stateQuips;
|
|
202
|
+
}
|
|
203
|
+
/** 把 mood-tracker 求值结果推给渲染进程(IPC 频道 pet:mood)。result 为 null 表示本次求值无需推送。
|
|
204
|
+
* 写法与 broadcastToRenderer 一致的 null/isDestroyed 检查 + try/catch 静默降级,不影响 Server 主流程。 */
|
|
205
|
+
function pushMoodEvaluation(result) {
|
|
206
|
+
if (!result)
|
|
207
|
+
return;
|
|
208
|
+
if (!mainWindow || mainWindow.isDestroyed())
|
|
209
|
+
return;
|
|
210
|
+
try {
|
|
211
|
+
mainWindow.webContents.send('pet:mood', result);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
// 静默:渲染进程尚未加载完毕或已崩溃,不影响 Server 侧状态机
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/** Mood ticker:每 MOOD_CHECK_INTERVAL_MS 定时求值一次(状态变化时另有立即求值,见 onMessage 的 'state' 分支)。 */
|
|
218
|
+
function startMoodTicker() {
|
|
219
|
+
const timer = setInterval(() => {
|
|
220
|
+
pushMoodEvaluation(moodTracker.evaluate(Date.now(), currentSkinQuips()));
|
|
221
|
+
}, MOOD_CHECK_INTERVAL_MS);
|
|
222
|
+
timer.unref?.();
|
|
223
|
+
return timer;
|
|
224
|
+
}
|
|
175
225
|
function send(socket, msg) {
|
|
176
226
|
try {
|
|
177
227
|
socket.send(JSON.stringify(msg));
|
|
@@ -420,6 +470,7 @@ app.whenReady().then(async () => {
|
|
|
420
470
|
mainWindow = createPetWindow();
|
|
421
471
|
tray = createTray();
|
|
422
472
|
rebuildTrayMenu();
|
|
473
|
+
startMoodTicker();
|
|
423
474
|
});
|
|
424
475
|
// 不再监听 window-all-closed → app.quit():悬浮窗本身不可关闭(frame:false 且无关闭按钮),
|
|
425
476
|
// 该事件设计上永不触发。退出统一走两个显式入口:托盘菜单"退出桌宠" 或 CLI 侧 shutdown 消息
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// currentStateSince 边界场景单测(mood.ts 内部辅助函数,已临时加 export 以便测试)。
|
|
2
|
+
// 覆盖:空数组、单条事件、末尾多条同状态、末尾单条状态与前一条不同、
|
|
3
|
+
// 中间有切换但末尾又切回同状态(不应误判 since)。
|
|
4
|
+
// 运行:npx tsx packages/pet-app/src/mood-current-state.test.ts
|
|
5
|
+
import { currentStateSince } from './mood.js';
|
|
6
|
+
let passed = 0;
|
|
7
|
+
const assert = (cond, msg) => {
|
|
8
|
+
if (!cond) {
|
|
9
|
+
console.error('✗ ' + msg);
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
passed++;
|
|
13
|
+
console.log('✓ ' + msg);
|
|
14
|
+
};
|
|
15
|
+
// 1) 空数组 → null
|
|
16
|
+
{
|
|
17
|
+
const r = currentStateSince([]);
|
|
18
|
+
assert(r === null, '空数组 → 返回 null');
|
|
19
|
+
}
|
|
20
|
+
// 2) 单条事件 → { state, since } 就是该条自身
|
|
21
|
+
{
|
|
22
|
+
const events = [{ state: 'idle', ts: 100 }];
|
|
23
|
+
const r = currentStateSince(events);
|
|
24
|
+
assert(r !== null && r.state === 'idle' && r.since === 100, "单条事件 [{state:'idle',ts:100}] → {state:'idle', since:100}");
|
|
25
|
+
}
|
|
26
|
+
// 3) 末尾多条同状态 → since 是这段连续区间里最早那条的 ts
|
|
27
|
+
{
|
|
28
|
+
const events = [
|
|
29
|
+
{ state: 'thinking', ts: 100 },
|
|
30
|
+
{ state: 'thinking', ts: 200 },
|
|
31
|
+
{ state: 'thinking', ts: 300 },
|
|
32
|
+
];
|
|
33
|
+
const r = currentStateSince(events);
|
|
34
|
+
assert(r !== null && r.state === 'thinking' && r.since === 100, "末尾多条同状态(thinking x3, ts:100/200/300) → since:100(最早那条,不是300)");
|
|
35
|
+
}
|
|
36
|
+
// 4) 末尾单条状态与前一条不同 → since 就是该条自身的 ts,不把前一条算进去
|
|
37
|
+
{
|
|
38
|
+
const events = [
|
|
39
|
+
{ state: 'idle', ts: 100 },
|
|
40
|
+
{ state: 'thinking', ts: 200 },
|
|
41
|
+
];
|
|
42
|
+
const r = currentStateSince(events);
|
|
43
|
+
assert(r !== null && r.state === 'thinking' && r.since === 200, "末尾状态与前一条不同([idle@100, thinking@200]) → {state:'thinking', since:200}");
|
|
44
|
+
}
|
|
45
|
+
// 5) 中间有切换但末尾又切回同一状态 → 只看末尾连续同状态的最新一段,不被更早处同状态干扰
|
|
46
|
+
{
|
|
47
|
+
const events = [
|
|
48
|
+
{ state: 'idle', ts: 100 },
|
|
49
|
+
{ state: 'thinking', ts: 200 },
|
|
50
|
+
{ state: 'idle', ts: 300 },
|
|
51
|
+
];
|
|
52
|
+
const r = currentStateSince(events);
|
|
53
|
+
assert(r !== null && r.state === 'idle' && r.since === 300, "末尾切回同状态但中间有切换([idle@100, thinking@200, idle@300]) → {state:'idle', since:300}(不误判为100)");
|
|
54
|
+
}
|
|
55
|
+
console.log(`\nOK: ${passed} passed, 0 failed`);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// PBT: Property 2 (Mood 确定性,纯函数) —— packages/pet-app/src/mood.ts 的 deriveMood。
|
|
2
|
+
// 覆盖 tasks.md 任务 2.2 / design.md Property 2:
|
|
3
|
+
// 给定任意 (events, now, thresholds) 输入,连续多次调用 deriveMood 返回值恒定
|
|
4
|
+
// (无隐藏可变状态,纯函数),且调用过程不修改传入的 events 数组(无副作用)。
|
|
5
|
+
// 运行:npx tsx packages/pet-app/src/mood-determinism.pbt.ts (在 f:\mocode 根目录下)
|
|
6
|
+
import fc from 'fast-check';
|
|
7
|
+
import { deriveMood, DEFAULT_MOOD_THRESHOLDS } from './mood.js';
|
|
8
|
+
import { PET_STATES } from './protocol.js';
|
|
9
|
+
let passed = 0;
|
|
10
|
+
const assert = (cond, msg) => {
|
|
11
|
+
if (!cond) {
|
|
12
|
+
console.error('✗ ' + msg);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
passed++;
|
|
16
|
+
console.log('✓ ' + msg);
|
|
17
|
+
};
|
|
18
|
+
// 任意单条 MoodEvent:state 从 8 个合法 PetState 里选,ts 为非负整数。
|
|
19
|
+
const moodEventArb = fc.record({
|
|
20
|
+
state: fc.constantFrom(...PET_STATES),
|
|
21
|
+
ts: fc.integer({ min: 0, max: 10_000_000 }),
|
|
22
|
+
});
|
|
23
|
+
// 任意 MoodEvent[]:生成后按 ts 升序排序,满足 deriveMood 的前置条件(events 按 ts 非降序排列)。
|
|
24
|
+
const moodEventsArb = fc
|
|
25
|
+
.array(moodEventArb, { maxLength: 40 })
|
|
26
|
+
.map((events) => [...events].sort((a, b) => a.ts - b.ts));
|
|
27
|
+
// 任意正整数(用于自定义阈值的 7 个字段)。
|
|
28
|
+
const positiveIntArb = fc.integer({ min: 1, max: 1_000_000 });
|
|
29
|
+
// 任意 MoodThresholds:一半概率直接用 DEFAULT_MOOD_THRESHOLDS,一半概率用随机生成的自定义阈值。
|
|
30
|
+
const thresholdsArb = fc.oneof(fc.constant(DEFAULT_MOOD_THRESHOLDS), fc.record({
|
|
31
|
+
tiredMs: positiveIntArb,
|
|
32
|
+
boredMs: positiveIntArb,
|
|
33
|
+
urgingMs: positiveIntArb,
|
|
34
|
+
errorWindowMs: positiveIntArb,
|
|
35
|
+
errorCount: positiveIntArb,
|
|
36
|
+
toolWindowMs: positiveIntArb,
|
|
37
|
+
toolCount: positiveIntArb,
|
|
38
|
+
}));
|
|
39
|
+
async function main() {
|
|
40
|
+
// Property 2: 对任意 (events, now, thresholds),多次调用 deriveMood 返回值恒定,且不修改 events。
|
|
41
|
+
try {
|
|
42
|
+
fc.assert(fc.property(moodEventsArb, fc.integer({ min: 0, max: 10_000_000 }), thresholdsArb, (events, now, thresholds) => {
|
|
43
|
+
const before = JSON.stringify(events);
|
|
44
|
+
const r1 = deriveMood(events, now, thresholds);
|
|
45
|
+
const r2 = deriveMood(events, now, thresholds);
|
|
46
|
+
const r3 = deriveMood(events, now, thresholds);
|
|
47
|
+
const after = JSON.stringify(events);
|
|
48
|
+
// 返回值恒定(===,mood 是字符串或 null,可直接比较)。
|
|
49
|
+
if (!(r1 === r2 && r2 === r3))
|
|
50
|
+
return false;
|
|
51
|
+
// events 数组未被修改(无副作用)。
|
|
52
|
+
if (before !== after)
|
|
53
|
+
return false;
|
|
54
|
+
return true;
|
|
55
|
+
}), { numRuns: 500 });
|
|
56
|
+
assert(true, 'Property 2 (Mood 确定性): 500 次随机样本,deriveMood 多次调用返回值恒定且不修改 events');
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
console.error('✗ Property 2 (Mood 确定性) 失败:');
|
|
60
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
console.log(`\nOK: ${passed} passed, 0 failed`);
|
|
64
|
+
process.exit(0);
|
|
65
|
+
}
|
|
66
|
+
main();
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// createMoodTracker().evaluate() 的"立即求值"与"定时刷新文案"时序单测。
|
|
2
|
+
// 覆盖 mood-tracker.ts 的四条分支(见该文件 evaluate 内部逻辑):
|
|
3
|
+
// 1) mood 从 null → 非 null(“进入”新 mood):立即返回 {mood, quip}(不等 ticker)。
|
|
4
|
+
// 2) mood 不变且非 null,未到 QUIP_REFRESH_MS(15000ms):返回 null(不重复推送)。
|
|
5
|
+
// 3) mood 不变且非 null,达到 QUIP_REFRESH_MS(>=):返回新的 {mood(不变), quip(新文案)}。
|
|
6
|
+
// 4) mood 从非 null → null(“退出”mood):立即返回 {mood: null}(不等 ticker);
|
|
7
|
+
// 之后 mood 仍为 null 与上次一致 → 直接 return null(mood 为 null 时不进入文案刷新判断分支)。
|
|
8
|
+
// pickQuip 内部用 Math.random() 挑文案,内容不可预测,只断言"是否有新结果"与 mood/quip 类型。
|
|
9
|
+
// 运行:npx tsx packages/pet-app/src/mood-tracker-timing.test.ts
|
|
10
|
+
import { createMoodTracker } from './mood-tracker.js';
|
|
11
|
+
let passed = 0;
|
|
12
|
+
const assert = (cond, msg) => {
|
|
13
|
+
if (!cond) {
|
|
14
|
+
console.error('✗ ' + msg);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
passed++;
|
|
18
|
+
console.log('✓ ' + msg);
|
|
19
|
+
};
|
|
20
|
+
// ── 场景1:mood “进入”立即产生推送(不必等 ticker)───────────────────────
|
|
21
|
+
// 注:flustered 判定是"滑动窗口"(见 mood.ts countInWindow,toolWindowMs 默认 10000ms),
|
|
22
|
+
// 不是"状态持续不变"型判定(那类是 tired/urging/bored,用 currentStateSince,天然会随时间推移
|
|
23
|
+
// 一直保持成立)。若只记 5 条 ts=0..4000 的 tool_call,窗口会在 now 推进过程中把早期事件滑出去
|
|
24
|
+
// (例如 now=10001 时窗口 [1,10001] 只剩 4 条,已跌破阈值 5,mood 会提前变回 null,
|
|
25
|
+
// 与场景2/3"mood 在 4000~19001 期间应保持 flustered 不变"的前提矛盾)。
|
|
26
|
+
// 因此这里持续每隔 1000ms 记一条 tool_call 直到 ts=19000,以确保 flustered 的滑动窗口在
|
|
27
|
+
// 整个测试所需的时间跨度内始终 ≥5 条,从而让 mood 保持不变、只测"文案刷新"这一件事。
|
|
28
|
+
const tracker = createMoodTracker();
|
|
29
|
+
for (let t = 0; t <= 19000; t += 1000) {
|
|
30
|
+
tracker.recordState('tool_call', t);
|
|
31
|
+
}
|
|
32
|
+
{
|
|
33
|
+
const r = tracker.evaluate(4000);
|
|
34
|
+
assert(r !== null && r.mood === 'flustered' && typeof r.quip === 'string', '场景1: 5 次 tool_call(10s 窗口内达阈值)→ evaluate 立即返回 {mood: "flustered", quip: string}');
|
|
35
|
+
}
|
|
36
|
+
// ── 场景2:mood 保持不变,未到 QUIP_REFRESH_MS(15000ms)→ 应返回 null ──────
|
|
37
|
+
{
|
|
38
|
+
const r = tracker.evaluate(4001);
|
|
39
|
+
assert(r === null, '场景2a: mood 不变(仍 flustered),仅过 1ms(远小于 15000ms)→ evaluate 返回 null');
|
|
40
|
+
}
|
|
41
|
+
{
|
|
42
|
+
const r = tracker.evaluate(4000 + 15000 - 1); // 18999
|
|
43
|
+
assert(r === null, '场景2b: mood 不变,距上次推文案差 1ms 未到 15000ms 刷新阈值 → evaluate 返回 null');
|
|
44
|
+
}
|
|
45
|
+
// ── 场景3:mood 不变但达到 QUIP_REFRESH_MS(>=)→ 应重新推送新文案 ──────────
|
|
46
|
+
{
|
|
47
|
+
const r = tracker.evaluate(4000 + 15000); // 19000,刚好达到刷新阈值(>=)
|
|
48
|
+
assert(r !== null && r.mood === 'flustered' && typeof r.quip === 'string', '场景3a: mood 不变,刚好达到 15000ms 刷新阈值(>=)→ evaluate 返回新的 {mood: "flustered", quip: string}');
|
|
49
|
+
}
|
|
50
|
+
{
|
|
51
|
+
const r = tracker.evaluate(19001);
|
|
52
|
+
assert(r === null, '场景3b: 刚刚才刷新过文案,立即再 evaluate(仅过 1ms)→ 返回 null');
|
|
53
|
+
}
|
|
54
|
+
// ── 场景4:mood 消失时也应立即推送一次 {mood: null},不等定时器 ────────────
|
|
55
|
+
const tracker2 = createMoodTracker();
|
|
56
|
+
tracker2.recordState('error', 0);
|
|
57
|
+
tracker2.recordState('error', 100);
|
|
58
|
+
{
|
|
59
|
+
const r = tracker2.evaluate(100);
|
|
60
|
+
assert(r !== null && r.mood === 'frustrated', '场景4a: 60s 窗口内 2 次 error(达阈值)→ evaluate 返回 {mood: "frustrated", ...}');
|
|
61
|
+
}
|
|
62
|
+
{
|
|
63
|
+
const r = tracker2.evaluate(100 + 60001); // 两次 error 事件均已滑出 60000ms 窗口
|
|
64
|
+
assert(r !== null && r.mood === null, '场景4b: error 事件滑出窗口,mood 从 frustrated 变为 null → evaluate 立即返回 {mood: null}(不等定时器)');
|
|
65
|
+
}
|
|
66
|
+
{
|
|
67
|
+
const r = tracker2.evaluate(100 + 60002);
|
|
68
|
+
assert(r === null, '场景4c: mood 仍为 null(与上次推送一致)→ evaluate 直接返回 null(mood 为 null 时不检查文案刷新)');
|
|
69
|
+
}
|
|
70
|
+
console.log(`\nOK: ${passed} passed, 0 failed`);
|