dsh-lite-memory 0.16.2 → 0.17.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/README.md +9 -9
- package/TRIGGERS.md +14 -13
- package/anchor.js +259 -24
- package/client.js +1211 -206
- package/docs/CONVENTIONS.md +143 -0
- package/docs/RELEASE-0.17.0.md +8 -0
- package/index.js +337 -95
- package/learning.js +456 -48
- package/package.json +5 -2
- package/state.js +110 -5
- package/store.js +447 -61
- package/test/anchor.test.mjs +240 -0
- package/test/client.test.mjs +176 -6
- package/test/contract.test.mjs +9 -1
- package/test/learning.test.mjs +484 -2
- package/test/palace.test.mjs +102 -0
- package/test/runtime-context.test.mjs +186 -0
- package/test/state.test.mjs +121 -2
- package/test/store.test.mjs +454 -15
- package/view.js +60 -3
package/test/learning.test.mjs
CHANGED
|
@@ -148,10 +148,31 @@ test('P00 作品验收不直接形成长期规则', () => {
|
|
|
148
148
|
assert.equal(parsed({ ...NEW_CHANGE, feedbackType: 'artifact_accept' }).ok, false);
|
|
149
149
|
});
|
|
150
150
|
|
|
151
|
-
test('P00
|
|
151
|
+
test('P00 长期声明本身就算规范变化', () => {
|
|
152
|
+
const cue = learning.classifyFeedback('这个项目默认用 pnpm');
|
|
153
|
+
assert.equal(cue.worth, true);
|
|
154
|
+
assert.equal(cue.type, 'spec_change');
|
|
155
|
+
assert.equal(cue.explicitLongTerm, true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('P00 未绑定的作品可以回落到项目候选', () => {
|
|
152
159
|
const human = (id, text) => ({ id, source: { kind: 'user' }, content: [{ type: 'text', text }] });
|
|
153
160
|
const base = { episodeId: 'ep_x', status: 'active', updatedAt: '2026-01-01T00:00:00.000Z' };
|
|
154
161
|
assert.equal(learning.candidateFrom({ messages: [human('m1', '以后开头先给结论')], anchor: { ...base, status: 'unbound' } }), null);
|
|
162
|
+
const projectCandidate = learning.candidateFrom({
|
|
163
|
+
messages: [human('m2', '以后这个项目默认用 pnpm')],
|
|
164
|
+
anchor: { ...base, status: 'unbound' }, sessionId: 's', projectId: 'p',
|
|
165
|
+
});
|
|
166
|
+
assert.equal(projectCandidate.scope, 'project');
|
|
167
|
+
assert.equal(projectCandidate.episodeId, '');
|
|
168
|
+
assert.equal(projectCandidate.expectedTarget.scope, 'project');
|
|
169
|
+
const correction = learning.candidateFrom({
|
|
170
|
+
messages: [human('m3', '这里改成蓝色')], anchor: { ...base, status: 'unbound' }, projectId: 'p',
|
|
171
|
+
});
|
|
172
|
+
assert.equal(correction.scope, 'project');
|
|
173
|
+
assert.equal(learning.candidateFrom({
|
|
174
|
+
messages: [human('m4', '这次这里改成蓝色')], anchor: { ...base, status: 'unbound' }, projectId: 'p',
|
|
175
|
+
}), null);
|
|
155
176
|
assert.equal(learning.candidateFrom({ messages: [{ id: 'a1', source: { kind: 'assistant' }, content: '收到' }], anchor: base }), null);
|
|
156
177
|
const candidate = learning.candidateFrom({ messages: [human('m1', '以后开头都先给结论,别再铺垫')], anchor: base, sessionId: 's', projectId: 'p' });
|
|
157
178
|
assert.equal(candidate.episodeId, 'ep_x');
|
|
@@ -189,6 +210,18 @@ test('B01 调用输入不超过 8 KiB 且只有本地引用', () => {
|
|
|
189
210
|
assert.doesNotMatch(input.system, /工具调用/);
|
|
190
211
|
});
|
|
191
212
|
|
|
213
|
+
test('B01 项目层整理输入不带作品身份', () => {
|
|
214
|
+
const input = learning.buildCallInput({
|
|
215
|
+
projectId: 'p',
|
|
216
|
+
episodeId: '',
|
|
217
|
+
feedback: { text: '以后这个项目默认用 pnpm', messageIds: ['m1'], cue: { type: 'spec_change', explicitLongTerm: true }, scope: 'project' },
|
|
218
|
+
rules: [],
|
|
219
|
+
});
|
|
220
|
+
assert.match(input.prompt, /"scope":"project"/);
|
|
221
|
+
assert.doesNotMatch(input.prompt, /"episodeId"/);
|
|
222
|
+
assert.match(input.system, /待确认候选/);
|
|
223
|
+
});
|
|
224
|
+
|
|
192
225
|
test('B01 截断、工具调用、额外字段整批拒绝', () => {
|
|
193
226
|
assert.equal(learning.parseOutput('{"changes":[{"target":"new"', { finish: { kind: 'stop' } }).ok, false);
|
|
194
227
|
assert.equal(learning.parseOutput('<tool>memory_save</tool>', { finish: { kind: 'stop' } }).ok, false);
|
|
@@ -433,7 +466,7 @@ test('P06 作品版本变了旧整理整批作废', () => {
|
|
|
433
466
|
store.saveTaskState(scope, later.tasks, { operationId: 'tasks_later' });
|
|
434
467
|
const stale = commitNew(scope, grown, started, NEW_CHANGE, { now: Date.now() + 2000 });
|
|
435
468
|
assert.equal(stale.ok, false);
|
|
436
|
-
assert.match(stale.reason,
|
|
469
|
+
assert.match(stale.reason, /换到别的作品|改过这期|失效|没采用/);
|
|
437
470
|
assert.equal(ruleRows(scope).length, 0);
|
|
438
471
|
});
|
|
439
472
|
|
|
@@ -617,6 +650,29 @@ test('B06 失败调用只留回执不写正文', () => {
|
|
|
617
650
|
assert.equal(receipt.reason, '整理没有返回内容');
|
|
618
651
|
});
|
|
619
652
|
|
|
653
|
+
test('P07 项目层候选无需作品也能落规则', () => {
|
|
654
|
+
const scope = fresh('project-rule');
|
|
655
|
+
enable(scope);
|
|
656
|
+
const started = store.beginLearningAttempt(scope, { episodeId: '', operationId: 'start_project' });
|
|
657
|
+
assert.equal(started.ok, true, started.reason);
|
|
658
|
+
const check = parsed(NEW_CHANGE);
|
|
659
|
+
assert.equal(check.ok, true, check.reason);
|
|
660
|
+
const result = store.commitLearning(scope, {
|
|
661
|
+
operationId: 'learn_project',
|
|
662
|
+
epoch: started.epoch,
|
|
663
|
+
episodeId: '',
|
|
664
|
+
projectId: scope.key,
|
|
665
|
+
sessionId: 's1',
|
|
666
|
+
messageIds: ['m1'],
|
|
667
|
+
changes: check.changes,
|
|
668
|
+
preparedTargets: [],
|
|
669
|
+
});
|
|
670
|
+
assert.equal(result.ok, true, result.reason);
|
|
671
|
+
assert.equal(ruleRows(scope).length, 1);
|
|
672
|
+
assert.equal(ruleRows(scope)[0].ownerProjectId, scope.key);
|
|
673
|
+
assert.equal(ruleRows(scope)[0].learning.state, 'trial');
|
|
674
|
+
});
|
|
675
|
+
|
|
620
676
|
test('P07 两条同批变化一次写入', () => {
|
|
621
677
|
const scope = fresh('two-changes');
|
|
622
678
|
enable(scope);
|
|
@@ -769,4 +825,430 @@ test('D10 效果摘要只算最近 64 条,未知用量不补零', () => {
|
|
|
769
825
|
assert.equal(after.outputTokens, null);
|
|
770
826
|
});
|
|
771
827
|
|
|
828
|
+
test('L01 没有作品绑定的长期项目要求仍是待确认候选', () => {
|
|
829
|
+
const message = (id, text) => ({ id, source: { kind: 'user' }, content: [{ type: 'text', text }] });
|
|
830
|
+
const candidate = learning.candidateFrom({
|
|
831
|
+
messages: [message('m1', '以后数据库查询都改成参数化查询')],
|
|
832
|
+
anchor: null,
|
|
833
|
+
sessionId: 's1',
|
|
834
|
+
projectId: 'proj_a',
|
|
835
|
+
now: 1000,
|
|
836
|
+
});
|
|
837
|
+
assert.ok(candidate);
|
|
838
|
+
assert.equal(candidate.scope, 'project');
|
|
839
|
+
assert.equal(candidate.state, 'pending');
|
|
840
|
+
assert.equal(candidate.episodeId, '');
|
|
841
|
+
assert.equal(candidate.seriesId, '');
|
|
842
|
+
assert.deepEqual(candidate.messageIds, ['m1']);
|
|
843
|
+
assert.equal(candidate.expectedTarget.scope, 'project');
|
|
844
|
+
assert.equal(candidate.expectedTarget.projectId, 'proj_a');
|
|
845
|
+
assert.equal(candidate.expectedTarget.applicability, 'unresolved');
|
|
846
|
+
assert.match(candidate.sourceExcerpt, /参数化查询/);
|
|
847
|
+
/** 跨项目一律不接收;没有项目身份的裸反馈也不进候选。 */
|
|
848
|
+
assert.equal(learning.candidateFrom({
|
|
849
|
+
messages: [message('m1', '以后数据库查询都改成参数化查询')],
|
|
850
|
+
anchor: { episodeId: 'e0', projectId: 'proj_b', status: 'active' },
|
|
851
|
+
projectId: 'proj_a',
|
|
852
|
+
}), null);
|
|
853
|
+
assert.equal(learning.candidateFrom({ messages: [message('m1', '以后都参数化查询')] }), null);
|
|
854
|
+
/** 短期例外即使没绑定作品也不当项目规范。 */
|
|
855
|
+
assert.equal(learning.candidateFrom({
|
|
856
|
+
messages: [message('m1', '这次开头先别放字幕')],
|
|
857
|
+
anchor: null,
|
|
858
|
+
projectId: 'proj_a',
|
|
859
|
+
}), null);
|
|
860
|
+
/** 有作品绑定仍走作品作用域,且不把项目当成单条栏目。 */
|
|
861
|
+
const bound = learning.candidateFrom({
|
|
862
|
+
messages: [message('m1', '以后字幕都改成底部安全区')],
|
|
863
|
+
anchor: { episodeId: 'e1', projectId: 'proj_a', status: 'active', taskKind: 'video', seriesId: 's1' },
|
|
864
|
+
sessionId: 's1',
|
|
865
|
+
projectId: 'proj_a',
|
|
866
|
+
});
|
|
867
|
+
assert.equal(bound.scope, 'episode');
|
|
868
|
+
assert.equal(bound.episodeId, 'e1');
|
|
869
|
+
assert.equal(bound.expectedTarget.episodeId, 'e1');
|
|
870
|
+
assert.equal(bound.seriesId, 's1');
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
test('L02 先按相关性选规则,再截断,字幕规则不会被挤掉', () => {
|
|
874
|
+
const rows = [];
|
|
875
|
+
for (let index = 0; index < 6; index += 1) {
|
|
876
|
+
rows.push({
|
|
877
|
+
id: `mem_a${index}`, topicKey: `配色${index}`, title: `配色${index}`,
|
|
878
|
+
body: `配色方案${index}`, summary: `配色经验${index}`,
|
|
879
|
+
conditions: [], applicability: 'all_in_scope',
|
|
880
|
+
learning: { state: 'active' }, updated: '2026-01-01T00:00:00.000Z',
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
rows.push({
|
|
884
|
+
id: 'mem_sub', topicKey: '字幕样式', title: '字幕样式', body: '字幕放底部安全区',
|
|
885
|
+
summary: '字幕经验', conditions: [{ key: 'seriesId', value: '字幕' }],
|
|
886
|
+
applicability: 'conditional', learning: { state: 'trial' }, updated: '2025-01-01T00:00:00.000Z',
|
|
887
|
+
});
|
|
888
|
+
rows.push({
|
|
889
|
+
id: 'mem_old', topicKey: '标题', title: '标题', body: '标题要短',
|
|
890
|
+
summary: '标题经验', conditions: [], applicability: 'all_in_scope',
|
|
891
|
+
learning: { state: 'pending' }, updated: '2025-01-01T00:00:00.000Z',
|
|
892
|
+
});
|
|
893
|
+
rows.push({
|
|
894
|
+
id: 'mem_bgm', topicKey: '配乐', title: '配乐', body: '配乐低一点',
|
|
895
|
+
summary: '配乐经验', conditions: [], applicability: 'all_in_scope',
|
|
896
|
+
learning: { state: 'active' }, updated: '2025-01-01T00:00:00.000Z',
|
|
897
|
+
});
|
|
898
|
+
rows.push({
|
|
899
|
+
id: 'mem_safe', topicKey: '字幕安全区', title: '字幕安全区', body: '字幕不要贴边',
|
|
900
|
+
summary: '字幕留安全区', conditions: [{ key: 'seriesId', value: '字幕' }],
|
|
901
|
+
applicability: 'conditional', learning: { state: 'active' }, updated: '2024-01-01T00:00:00.000Z',
|
|
902
|
+
});
|
|
903
|
+
const context = { query: '字幕位置太低,以后字幕都放底部安全区', dimensions: ['字幕'] };
|
|
904
|
+
const ranked = learning.rankRules(rows, context);
|
|
905
|
+
assert.equal(ranked[0].id, 'mem_safe');
|
|
906
|
+
assert.equal(ranked[1].id, 'mem_sub');
|
|
907
|
+
const selected = learning.selectRules(rows, { ...context, limit: 6 });
|
|
908
|
+
assert.equal(selected.length, 6);
|
|
909
|
+
assert.equal(selected[0].id, 'mem_safe');
|
|
910
|
+
assert.equal(selected[1].id, 'mem_sub');
|
|
911
|
+
assert.deepEqual(learning.rankRules(rows, context).map((row) => row.id), ranked.map((row) => row.id));
|
|
912
|
+
/** buildCallInput 的输入上限是 6,但字幕规则必须在里面。 */
|
|
913
|
+
const input = learning.buildCallInput({
|
|
914
|
+
projectId: 'proj', episodeId: 'e1', taskKind: 'video', seriesId: '字幕', phase: 'p1',
|
|
915
|
+
feedback: { text: '字幕位置太低,以后字幕都放底部安全区', cue: { dimension: '字幕' }, messageIds: ['m1'] },
|
|
916
|
+
rules: rows, ranges: RANGES,
|
|
917
|
+
});
|
|
918
|
+
assert.ok(input.rules.length <= 6);
|
|
919
|
+
const ids = new Set(Object.values(input.refs.rules));
|
|
920
|
+
assert.ok(ids.has('mem_safe'));
|
|
921
|
+
assert.ok(ids.has('mem_sub'));
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
test('L03 反馈类型限制动作:例外/检查点/认可不能写规范', () => {
|
|
925
|
+
const rules = [{ ref: 'r0', id: 'mem_1', revision: 2, conditions: [{ key: 'seriesId', value: '口播' }] }];
|
|
926
|
+
const refs = { feedback: { u0: 'm1' }, rules: { r0: 'mem_1' } };
|
|
927
|
+
const check = (change) => learning.validateChange(change, { refs, rules, ranges: RANGES });
|
|
928
|
+
assert.equal(check({ ...NEW_CHANGE, target: 'new', feedbackType: 'task_exception' }).ok, false);
|
|
929
|
+
assert.equal(check({ ...NEW_CHANGE, target: 'new', feedbackType: 'unclear' }).ok, false);
|
|
930
|
+
const checkpoint = check({ target: 'r0', feedbackType: 'execution_miss', currentText: '发布前核对字幕安全区', evidence: ['u0'] });
|
|
931
|
+
assert.equal(checkpoint.ok, true, checkpoint.reason);
|
|
932
|
+
const rewritten = check({ target: 'r0', feedbackType: 'execution_miss', currentText: '新正文', title: '新标题' });
|
|
933
|
+
assert.equal(rewritten.ok, false);
|
|
934
|
+
const newAccept = check({ target: 'new', feedbackType: 'aspect_accept', topicKey: '字幕', currentText: '字幕放底部' });
|
|
935
|
+
assert.equal(newAccept.ok, false);
|
|
936
|
+
const accept = check({ target: 'r0', feedbackType: 'aspect_accept', conditions: [{ key: 'seriesId', value: '口播' }], evidence: ['u0'] });
|
|
937
|
+
assert.equal(accept.ok, true, accept.reason);
|
|
938
|
+
const mismatched = check({ target: 'r0', feedbackType: 'aspect_accept', conditions: [{ key: 'seriesId', value: '字幕' }] });
|
|
939
|
+
assert.equal(mismatched.ok, false);
|
|
940
|
+
assert.equal(mismatched.reason, '认可条件与目标规则不一致');
|
|
941
|
+
const rewrite = check({ target: 'r0', feedbackType: 'aspect_accept', currentText: '顺手把正文改了' });
|
|
942
|
+
assert.equal(rewrite.ok, false);
|
|
943
|
+
/** 同类型走 nextLearningState 也不抬状态。 */
|
|
944
|
+
const active = { id: 'mem_1', revision: 2, topicKey: '字幕', body: '字幕放底部', applicability: 'conditional', conditions: [{ key: 'seriesId', value: '口播' }], learning: { state: 'active', supportedEpisodeIds: ['e1', 'e2'], evidence: [] } };
|
|
945
|
+
for (const feedbackType of ['execution_miss', 'aspect_accept', 'artifact_accept', 'task_exception', 'unclear']) {
|
|
946
|
+
const next = learning.nextLearningState({ existing: active, change: { id: 'mem_1', feedbackType }, evidence: { episodeId: 'e3' }, now: 1000 });
|
|
947
|
+
assert.equal(next.state, 'active', feedbackType);
|
|
948
|
+
assert.deepEqual(next.supportedEpisodeIds, ['e1', 'e2'], feedbackType);
|
|
949
|
+
const created = learning.nextLearningState({ existing: null, change: { feedbackType }, evidence: { episodeId: 'e1' }, now: 1000 });
|
|
950
|
+
assert.notEqual(created.state, 'active', feedbackType);
|
|
951
|
+
}
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
test('L03b 明确长期要求整理为空时也留下规则', () => {
|
|
955
|
+
const change = learning.pendingChangeFrom({ text: '以后这个项目默认用 pnpm。', cue: { explicitLongTerm: true } });
|
|
956
|
+
assert.equal(change.target, 'new');
|
|
957
|
+
assert.equal(change.feedbackType, 'spec_change');
|
|
958
|
+
assert.equal(change.currentText, '以后这个项目默认用 pnpm。');
|
|
959
|
+
assert.equal(change.evidence[0], 'u0');
|
|
960
|
+
const check = learning.validateChange(change, { refs: { feedback: { u0: 'm1' }, rules: {} }, rules: [], ranges: RANGES });
|
|
961
|
+
assert.equal(check.ok, true, check.reason);
|
|
962
|
+
assert.equal(learning.pendingChangeFrom({ text: ' ' }), null);
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
test('L04 先算目标状态再查配额,过期候选可恢复', () => {
|
|
966
|
+
const base = { existing: null, evidence: { episodeId: 'e1', messageIds: ['m1'], sourceExcerpt: '以后都用参数化查询' }, pending: 12, rules: 0, now: 1000 };
|
|
967
|
+
const active = learning.admitChange({ ...base, change: NEW_CHANGE, explicitLongTerm: true });
|
|
968
|
+
assert.equal(active.ok, true, active.reason);
|
|
969
|
+
assert.equal(active.learning.state, 'active');
|
|
970
|
+
assert.equal(active.merged.current.body, NEW_CHANGE.currentText);
|
|
971
|
+
const candidate = learning.admitChange({
|
|
972
|
+
...base,
|
|
973
|
+
pending: 12,
|
|
974
|
+
change: { ...NEW_CHANGE, conditions: [], applicability: 'unresolved' },
|
|
975
|
+
});
|
|
976
|
+
assert.equal(candidate.ok, false);
|
|
977
|
+
assert.equal(candidate.reason, '待确认候选已经到上限');
|
|
978
|
+
const full = learning.admitChange({ ...base, rules: learning.LIMITS.rules, pending: 0, change: NEW_CHANGE });
|
|
979
|
+
assert.equal(full.ok, false);
|
|
980
|
+
assert.equal(full.reason, '项目实验规则已经到上限');
|
|
981
|
+
const existing = {
|
|
982
|
+
id: 'mem_1', revision: 3, topicKey: '口播开头', body: '开头三句内给结论。',
|
|
983
|
+
applicability: 'conditional', conditions: [{ key: 'seriesId', value: '口播' }],
|
|
984
|
+
learning: {
|
|
985
|
+
state: 'trial',
|
|
986
|
+
specDigest: learning.specDigestOf({ topicKey: '口播开头', body: '开头三句内给结论。', applicability: 'conditional', conditions: [{ key: 'seriesId', value: '口播' }] }),
|
|
987
|
+
supportedEpisodeIds: [],
|
|
988
|
+
evidence: [],
|
|
989
|
+
},
|
|
990
|
+
};
|
|
991
|
+
const update = learning.admitChange({
|
|
992
|
+
existing, change: { ...NEW_CHANGE, target: 'r0' }, evidence: { episodeId: 'e2' }, pending: 99, rules: 99, now: 1000,
|
|
993
|
+
});
|
|
994
|
+
assert.equal(update.ok, true, update.reason);
|
|
995
|
+
assert.equal(update.learning.state, 'trial');
|
|
996
|
+
/** 过期待确认既不是永久候选,也不该被删;能重新唤起,且不占有效名额。 */
|
|
997
|
+
const stale = { id: 'mem_stale', origin: 'project_evolution', learning: { state: 'pending', createdAt: new Date(1000).toISOString() } };
|
|
998
|
+
const now = 1000 + learning.LIMITS.trialMs + 1000;
|
|
999
|
+
assert.equal(learning.candidateExpired(stale.learning, now), true);
|
|
1000
|
+
assert.equal(learning.pendingCount([stale], now), 0);
|
|
1001
|
+
assert.equal(learning.pendingCount([stale], 1000), 1);
|
|
1002
|
+
const plan = learning.lifecycle([stale], now);
|
|
1003
|
+
assert.deepEqual(plan.purge, []);
|
|
1004
|
+
assert.deepEqual(plan.recoverable, ['mem_stale']);
|
|
1005
|
+
const revived = learning.reviveCandidate(stale.learning, { now });
|
|
1006
|
+
assert.equal(revived.state, 'pending');
|
|
1007
|
+
assert.equal(learning.candidateExpired(revived, now), false);
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
test('L05 规范或目标变了旧证据清零,来源与版本绑定留在规则上', () => {
|
|
1011
|
+
const conditions = [{ key: 'seriesId', value: '口播' }];
|
|
1012
|
+
const body = '开头三句内给结论。';
|
|
1013
|
+
const existing = {
|
|
1014
|
+
id: 'mem_1', revision: 4, topicKey: '口播开头', title: '口播开头', body,
|
|
1015
|
+
applicability: 'conditional', conditions,
|
|
1016
|
+
learning: {
|
|
1017
|
+
state: 'active', specDigest: learning.specDigestOf({ topicKey: '口播开头', body, applicability: 'conditional', conditions }),
|
|
1018
|
+
supportedEpisodeIds: ['e1', 'e2'],
|
|
1019
|
+
evidence: [{ episodeId: 'e1' }, { episodeId: 'e2' }],
|
|
1020
|
+
samples: [{ episodeId: 'e1', dimension: '字幕' }],
|
|
1021
|
+
},
|
|
1022
|
+
};
|
|
1023
|
+
const evidence = { episodeId: 'e3', messageIds: ['m1', 'm2', 'm3', 'm4'], sourceExcerpt: '原始反馈:以后开头先给结论' };
|
|
1024
|
+
const changed = learning.nextLearningState({
|
|
1025
|
+
existing,
|
|
1026
|
+
change: { id: 'mem_1', currentText: '开头五句内给结论。', feedbackType: 'spec_change' },
|
|
1027
|
+
evidence: { ...evidence, explicitLongTerm: true },
|
|
1028
|
+
specChanged: true,
|
|
1029
|
+
now: 2000,
|
|
1030
|
+
});
|
|
1031
|
+
assert.deepEqual(changed.supportedEpisodeIds, ['e3']);
|
|
1032
|
+
assert.equal(changed.evidence.length, 1);
|
|
1033
|
+
assert.equal(changed.evidenceResetReason, 'spec_changed');
|
|
1034
|
+
assert.equal(changed.samples, undefined);
|
|
1035
|
+
assert.equal(changed.sourceExcerpt, evidence.sourceExcerpt);
|
|
1036
|
+
assert.deepEqual(changed.binding.messageIds, ['m2', 'm3', 'm4']);
|
|
1037
|
+
assert.equal(changed.binding.targetId, 'mem_1');
|
|
1038
|
+
assert.equal(changed.binding.baseRevision, 4);
|
|
1039
|
+
assert.equal(changed.state, 'active');
|
|
1040
|
+
/** 没有规范变化时保留证据,只追加本次作品。 */
|
|
1041
|
+
const kept = learning.nextLearningState({
|
|
1042
|
+
existing,
|
|
1043
|
+
change: { id: 'mem_1', currentText: body, feedbackType: 'spec_change' },
|
|
1044
|
+
evidence: { episodeId: 'e9', sourceExcerpt: '补充' },
|
|
1045
|
+
specChanged: false,
|
|
1046
|
+
now: 2000,
|
|
1047
|
+
});
|
|
1048
|
+
assert.deepEqual(kept.supportedEpisodeIds, ['e1', 'e2', 'e9']);
|
|
1049
|
+
assert.equal(kept.evidenceResetReason, undefined);
|
|
1050
|
+
/** 目标对象换了也清零。 */
|
|
1051
|
+
const retargeted = learning.nextLearningState({
|
|
1052
|
+
existing,
|
|
1053
|
+
change: { id: 'mem_2', feedbackType: 'spec_change' },
|
|
1054
|
+
evidence: { episodeId: 'e3' },
|
|
1055
|
+
specChanged: false,
|
|
1056
|
+
now: 2000,
|
|
1057
|
+
});
|
|
1058
|
+
assert.deepEqual(retargeted.supportedEpisodeIds, ['e3']);
|
|
1059
|
+
assert.equal(retargeted.evidenceResetReason, 'target_changed');
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
test('L06 运行上下文只认传入身份,未绑定的 episodeId 不挡项目候选', () => {
|
|
1063
|
+
const message = (id, text) => ({ id, source: { kind: 'user' }, content: [{ type: 'text', text }] });
|
|
1064
|
+
const messages = [message('m1', '以后数据库查询都改成参数化查询')];
|
|
1065
|
+
const runtime = learning.runtimeContextOf({
|
|
1066
|
+
projectId: 'proj_a',
|
|
1067
|
+
sessionId: 's1',
|
|
1068
|
+
requestId: 'req_1',
|
|
1069
|
+
anchor: { episodeId: 'e1', status: 'active', projectId: 'proj_a', taskKind: 'video', seriesId: '口播', phase: 'script' },
|
|
1070
|
+
});
|
|
1071
|
+
assert.deepEqual(
|
|
1072
|
+
[runtime.projectId, runtime.sessionId, runtime.requestId, runtime.episodeId, runtime.bound],
|
|
1073
|
+
['proj_a', 's1', 'req_1', 'e1', true],
|
|
1074
|
+
);
|
|
1075
|
+
const unbound = learning.runtimeContextOf({ projectId: 'proj_a', anchor: { episodeId: 'e9', status: 'unbound', projectId: 'proj_a' } });
|
|
1076
|
+
assert.equal(unbound.bound, false);
|
|
1077
|
+
assert.equal(unbound.episodeId, 'e9');
|
|
1078
|
+
/** 未绑定但任务里留着 episodeId:仍按项目候选收集,而不是「绑定失效」整条丢掉。 */
|
|
1079
|
+
for (const anchorRow of [
|
|
1080
|
+
{ episodeId: 'e9', status: 'unbound', projectId: 'proj_a' },
|
|
1081
|
+
{ episodeId: 'e9', projectId: 'proj_a' },
|
|
1082
|
+
{ episodeId: 'e9', status: 'paused', projectId: 'proj_a' },
|
|
1083
|
+
]) {
|
|
1084
|
+
const candidate = learning.candidateFrom({ messages, anchor: anchorRow, projectId: 'proj_a', sessionId: 's1', now: 1000 });
|
|
1085
|
+
assert.ok(candidate, JSON.stringify(anchorRow));
|
|
1086
|
+
assert.equal(candidate.scope, 'project', JSON.stringify(anchorRow));
|
|
1087
|
+
assert.equal(candidate.episodeId, '');
|
|
1088
|
+
assert.equal(candidate.projectId, 'proj_a');
|
|
1089
|
+
}
|
|
1090
|
+
/** 上下文之外的任何身份都不补:没传项目就是没有。 */
|
|
1091
|
+
assert.equal(learning.runtimeContextOf({ anchor: { episodeId: 'e1', status: 'active' } }).projectId, '');
|
|
1092
|
+
assert.equal(learning.candidateFrom({ messages }), null);
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
test('L07 sourceExcerpt 从候选到调用输入到兜底变化全程不丢', () => {
|
|
1096
|
+
const message = (id, text) => ({ id, source: { kind: 'user' }, content: [{ type: 'text', text }] });
|
|
1097
|
+
const text = '以后字幕都改成底部安全区,别再顶到画面中间';
|
|
1098
|
+
const candidate = learning.candidateFrom({
|
|
1099
|
+
messages: [message('m1', text), message('m2', '这期先这样')],
|
|
1100
|
+
anchor: { episodeId: 'e1', status: 'active', projectId: 'proj_a' },
|
|
1101
|
+
projectId: 'proj_a',
|
|
1102
|
+
sessionId: 's1',
|
|
1103
|
+
now: 1000,
|
|
1104
|
+
});
|
|
1105
|
+
assert.ok(candidate);
|
|
1106
|
+
assert.match(candidate.sourceExcerpt, /底部安全区/);
|
|
1107
|
+
const input = learning.buildCallInput({
|
|
1108
|
+
projectId: 'proj_a',
|
|
1109
|
+
episodeId: 'e1',
|
|
1110
|
+
feedback: candidate,
|
|
1111
|
+
rules: [],
|
|
1112
|
+
ranges: RANGES,
|
|
1113
|
+
});
|
|
1114
|
+
assert.equal(input.overLimit, undefined);
|
|
1115
|
+
assert.equal(input.sourceExcerpt, candidate.sourceExcerpt);
|
|
1116
|
+
assert.deepEqual(input.messageIds, ['m1', 'm2']);
|
|
1117
|
+
const rendered = JSON.parse(input.prompt);
|
|
1118
|
+
assert.equal(rendered.feedback.sourceExcerpt, candidate.sourceExcerpt);
|
|
1119
|
+
assert.deepEqual(rendered.feedback.messageIds, ['m1', 'm2']);
|
|
1120
|
+
assert.deepEqual(input.refs.feedback, { u0: 'm1' });
|
|
1121
|
+
/** 整理器返回空时的兜底变化也带着同一段来源。 */
|
|
1122
|
+
const change = learning.pendingChangeFrom(candidate);
|
|
1123
|
+
assert.equal(change.sourceExcerpt, candidate.sourceExcerpt);
|
|
1124
|
+
const checked = learning.validateChange(change, { refs: input.refs, rules: [], ranges: RANGES });
|
|
1125
|
+
assert.equal(checked.ok, true, checked.reason);
|
|
1126
|
+
assert.equal(checked.change.sourceExcerpt, candidate.sourceExcerpt);
|
|
1127
|
+
assert.equal(learning.pendingChangeFrom({ text: ' ' }), null);
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
test('L08 admissionOf 统一收集与展示,启发式推断不等于用户确认', () => {
|
|
1131
|
+
const message = (id, text) => ({ id, source: { kind: 'user' }, content: [{ type: 'text', text }] });
|
|
1132
|
+
const bound = learning.candidateFrom({
|
|
1133
|
+
messages: [message('m1', '以后字幕都改成底部安全区')],
|
|
1134
|
+
anchor: { episodeId: 'e1', status: 'active', projectId: 'proj_a', taskKind: 'video' },
|
|
1135
|
+
projectId: 'proj_a',
|
|
1136
|
+
sessionId: 's1',
|
|
1137
|
+
now: 1000,
|
|
1138
|
+
});
|
|
1139
|
+
for (const purpose of ['collect', 'display']) {
|
|
1140
|
+
const admission = learning.admissionOf(bound, {
|
|
1141
|
+
projectId: 'proj_a',
|
|
1142
|
+
sessionId: 's1',
|
|
1143
|
+
anchor: { episodeId: 'e1', status: 'active', projectId: 'proj_a' },
|
|
1144
|
+
}, purpose);
|
|
1145
|
+
assert.equal(admission.ok, true, admission.reason);
|
|
1146
|
+
assert.equal(admission.scope, 'episode');
|
|
1147
|
+
assert.equal(admission.episodeId, 'e1');
|
|
1148
|
+
assert.equal(admission.confirmed, false);
|
|
1149
|
+
assert.equal(admission.requiresUserConfirmation, true);
|
|
1150
|
+
assert.equal(admission.persistence, 'project_candidate');
|
|
1151
|
+
assert.equal(admission.visibility, 'candidate');
|
|
1152
|
+
}
|
|
1153
|
+
/** 明确长期要求也只是候选:用户说过“以后”,不等于点过“记住”。 */
|
|
1154
|
+
const longTerm = learning.candidateFrom({
|
|
1155
|
+
messages: [message('m1', '以后数据库查询都改成参数化查询')],
|
|
1156
|
+
anchor: null,
|
|
1157
|
+
projectId: 'proj_a',
|
|
1158
|
+
now: 1000,
|
|
1159
|
+
});
|
|
1160
|
+
assert.equal(learning.admissionOf(longTerm, { projectId: 'proj_a' }).confirmed, false);
|
|
1161
|
+
/** 未绑定但带着 episodeId 的任务:作品候选失效,项目候选照常准入。 */
|
|
1162
|
+
const unbound = learning.admissionOf(bound, {
|
|
1163
|
+
projectId: 'proj_a',
|
|
1164
|
+
anchor: { episodeId: 'e1', status: 'unbound', projectId: 'proj_a' },
|
|
1165
|
+
});
|
|
1166
|
+
assert.equal(unbound.ok, false);
|
|
1167
|
+
assert.equal(unbound.reason, '作品绑定已经失效');
|
|
1168
|
+
assert.equal(learning.admissionOf({ ...longTerm }, {
|
|
1169
|
+
projectId: 'proj_a',
|
|
1170
|
+
anchor: { episodeId: 'e9', status: 'unbound', projectId: 'proj_a' },
|
|
1171
|
+
}).ok, true);
|
|
1172
|
+
assert.equal(learning.admissionOf({ ...bound, confirmed: true }, {
|
|
1173
|
+
projectId: 'proj_a',
|
|
1174
|
+
anchor: { episodeId: 'e1', status: 'active', projectId: 'proj_a' },
|
|
1175
|
+
}).requiresUserConfirmation, false);
|
|
1176
|
+
assert.equal(learning.admissionOf(bound, { projectId: 'proj_a', anchor: { episodeId: 'e1', status: 'active' } }, 'commit').ok, false);
|
|
1177
|
+
assert.equal(learning.admissionOf(null).ok, false);
|
|
1178
|
+
assert.equal(learning.admissionOf(bound, { projectId: 'proj_b', anchor: { episodeId: 'e1', status: 'active' } }).ok, false);
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
test('L09 项目滚动额度与单次去重分离,开关不重置已用额度', () => {
|
|
1182
|
+
let ledger = {};
|
|
1183
|
+
const base = Date.parse('2026-01-01T00:00:00.000Z');
|
|
1184
|
+
/** 同一期最多两次:去重按 episode 行记,滚动 recent 清空也不放宽。 */
|
|
1185
|
+
ledger = learning.recordAttempt(ledger, { episodeId: 'e1', now: base });
|
|
1186
|
+
assert.equal(learning.quota(ledger, { episodeId: 'e1', now: base }).ok, true);
|
|
1187
|
+
ledger = learning.recordAttempt(ledger, { episodeId: 'e1', now: base + 1000 });
|
|
1188
|
+
const episodeLimit = learning.quota(ledger, { episodeId: 'e1', now: base + 2000 });
|
|
1189
|
+
assert.equal(episodeLimit.reason, 'episode_limit');
|
|
1190
|
+
assert.equal(episodeLimit.episodeCount, learning.LIMITS.attemptsPerEpisode);
|
|
1191
|
+
/** 模拟实验关开:state.applyEvolution 会清空 recent,attempts 保留。 */
|
|
1192
|
+
const toggled = { ...learning.recordAttempt(ledger, { episodeId: 'e2', now: base + 3000 }), recent: [] };
|
|
1193
|
+
assert.equal(learning.quota(toggled, { episodeId: 'e1', now: base + 4000 }).reason, 'episode_limit');
|
|
1194
|
+
assert.equal(learning.quota(toggled, { episodeId: 'e2', now: base + 4000 }).episodeCount, 1);
|
|
1195
|
+
/** 跨作品项目上限按窗口内总次数算,不依赖 recent。 */
|
|
1196
|
+
let project = {};
|
|
1197
|
+
for (const episodeId of ['p1', 'p1', 'p2', 'p2', 'p3', 'p3']) {
|
|
1198
|
+
project = learning.recordAttempt(project, { episodeId, now: base });
|
|
1199
|
+
}
|
|
1200
|
+
project = { ...project, recent: [] };
|
|
1201
|
+
const window = learning.attemptWindowOf(project, { now: base + 1000 });
|
|
1202
|
+
assert.equal(window.projectCount, learning.LIMITS.attemptsPerProject);
|
|
1203
|
+
assert.equal(learning.quota(project, { episodeId: 'p4', now: base + 1000 }).reason, 'project_limit');
|
|
1204
|
+
/** 只按窗口自然衰减,不因开关或去重重置。 */
|
|
1205
|
+
const later = base + learning.LIMITS.windowMs + 1;
|
|
1206
|
+
assert.equal(learning.quota(project, { episodeId: 'p4', now: later }).ok, true);
|
|
1207
|
+
assert.equal(learning.attemptWindowOf(project, { now: later }).projectCount, 0);
|
|
1208
|
+
/** 项目层候选按 project:<key> 记账:不能被“每期两次”终身卡死,只受滚动项目额度约束。 */
|
|
1209
|
+
let pseudo = {};
|
|
1210
|
+
for (let index = 0; index < 3; index += 1) pseudo = learning.recordAttempt(pseudo, { episodeId: 'project:proj_a', now: base + index });
|
|
1211
|
+
const pseudoQuota = learning.quota(pseudo, { episodeId: 'project:proj_a', now: base + 10 });
|
|
1212
|
+
assert.equal(pseudoQuota.ok, true, JSON.stringify(pseudoQuota));
|
|
1213
|
+
assert.equal(pseudoQuota.episodeCount, 0);
|
|
1214
|
+
assert.equal(learning.quota(pseudo, { episodeId: '', now: base + 10 }).ok, true);
|
|
1215
|
+
/** 两条不同作品各自占一次额度,单条去重不合并跨期调用。 */
|
|
1216
|
+
const distinct = learning.recordAttempt(learning.recordAttempt({}, { episodeId: 'q1', now: base }), { episodeId: 'q2', now: base });
|
|
1217
|
+
assert.equal(learning.quota(distinct, { episodeId: 'q3', now: base }).projectCount, 2);
|
|
1218
|
+
});
|
|
1219
|
+
|
|
1220
|
+
test('L10 整理器规则先稳定排序再取 8', () => {
|
|
1221
|
+
const rules = [];
|
|
1222
|
+
for (let index = 0; index < 9; index += 1) {
|
|
1223
|
+
rules.push({
|
|
1224
|
+
id: `mem_${index}`,
|
|
1225
|
+
status: 'active',
|
|
1226
|
+
learning: { state: 'pending', updatedAt: new Date(Date.UTC(2026, 0, 1, 0, index)).toISOString() },
|
|
1227
|
+
updated: new Date(Date.UTC(2026, 0, 1, 0, index)).toISOString(),
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
/** 真正可用的旧经验排在输入最后:先截断就会丢掉它。 */
|
|
1231
|
+
rules.push({
|
|
1232
|
+
id: 'mem_star',
|
|
1233
|
+
status: 'active',
|
|
1234
|
+
learning: { state: 'active', lastUsedAt: '2025-12-31T00:00:00.000Z', updatedAt: '2025-01-01T00:00:00.000Z' },
|
|
1235
|
+
updated: '2025-01-01T00:00:00.000Z',
|
|
1236
|
+
});
|
|
1237
|
+
assert.equal(rules.slice(0, 8).some((row) => row.id === 'mem_star'), false);
|
|
1238
|
+
const ordered = learning.orderOrganizerRules(rules);
|
|
1239
|
+
assert.equal(ordered.length, learning.LIMITS.organizerRules);
|
|
1240
|
+
assert.equal(ordered[0].id, 'mem_star');
|
|
1241
|
+
assert.equal(ordered.some((row) => row.id === 'mem_star'), true);
|
|
1242
|
+
/** 同样输入换个顺序结果一样;同状态按最近使用、再按时间、最后按 id。 */
|
|
1243
|
+
const shuffled = [...rules].reverse();
|
|
1244
|
+
assert.deepEqual(learning.orderOrganizerRules(shuffled).map((row) => row.id), ordered.map((row) => row.id));
|
|
1245
|
+
const sameState = [
|
|
1246
|
+
{ id: 'b', learning: { state: 'active', updatedAt: '2025-01-01T00:00:00.000Z' } },
|
|
1247
|
+
{ id: 'a', learning: { state: 'active', lastUsedAt: '2026-01-01T00:00:00.000Z', updatedAt: '2025-01-01T00:00:00.000Z' } },
|
|
1248
|
+
{ id: 'c', learning: { state: 'active', updatedAt: '2025-01-01T00:00:00.000Z' } },
|
|
1249
|
+
];
|
|
1250
|
+
assert.deepEqual(learning.orderOrganizerRules(sameState, { limit: 2 }).map((row) => row.id), ['a', 'b']);
|
|
1251
|
+
assert.equal(learning.orderOrganizerRules([], { limit: 3 }).length, 0);
|
|
1252
|
+
});
|
|
1253
|
+
|
|
772
1254
|
console.log(`learning.test.mjs: ${passed} passed`);
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
|
|
3
|
+
let seen = null;
|
|
4
|
+
const makeNode = (type, props, ...children) => ({ type, props: props || {}, children: children.flat(Infinity) });
|
|
5
|
+
const fakeReact = {
|
|
6
|
+
createElement: makeNode,
|
|
7
|
+
useRef: (value) => ({ current: value }),
|
|
8
|
+
useState: (value) => [value, () => {}],
|
|
9
|
+
useEffect: () => {},
|
|
10
|
+
useMemo: (fn) => fn(),
|
|
11
|
+
};
|
|
12
|
+
globalThis.window = {
|
|
13
|
+
__ModuleLoader__: {
|
|
14
|
+
load: (definition) => {
|
|
15
|
+
definition.factory((name) => (name === 'react' ? fakeReact : null));
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
globalThis.__LITE_MEMORY_TEST__ = (helpers) => { seen = helpers; };
|
|
20
|
+
await import('../client.js');
|
|
21
|
+
assert.ok(seen, 'client test seam missing');
|
|
22
|
+
|
|
23
|
+
const {
|
|
24
|
+
MemoryPalace, MemoryPanel, viewModeOf, palaceRoomsOf, palaceRoomPageOf,
|
|
25
|
+
palacePageWindow, palaceToggleOf, palaceMotionOf, palaceRoomIdOf,
|
|
26
|
+
} = seen;
|
|
27
|
+
|
|
28
|
+
const record = (id, kind, title = id) => ({ id, kind, title });
|
|
29
|
+
|
|
30
|
+
// 房间按固定顺序归类,未知类型落到「其他记录」,不丢记录。
|
|
31
|
+
const rooms = palaceRoomsOf([
|
|
32
|
+
record('mem_1', 'note'),
|
|
33
|
+
record('mem_2', 'pointer'),
|
|
34
|
+
record('mem_3', 'procedure'),
|
|
35
|
+
record('mem_4', 'preference'),
|
|
36
|
+
record('mem_5', 'convention'),
|
|
37
|
+
record('mem_6', 'pitfall'),
|
|
38
|
+
record('mem_7', 'credential_hint'),
|
|
39
|
+
record('mem_8', 'unknown_kind'),
|
|
40
|
+
]);
|
|
41
|
+
assert.deepEqual(rooms.map((room) => room.name), ['习惯与规范', '方法与经验', '资料与入口', '其他记录']);
|
|
42
|
+
assert.deepEqual(rooms.map((room) => room.total), [2, 2, 2, 2]);
|
|
43
|
+
assert.equal(palaceRoomIdOf('unknown_kind'), 'notes');
|
|
44
|
+
assert.deepEqual(palaceRoomsOf([record('mem_9', 'note')]).map((room) => room.total), [0, 0, 0, 1], '空房间保留位置');
|
|
45
|
+
|
|
46
|
+
// 一页固定 24 个槽位,页码夹在有效范围内。
|
|
47
|
+
const many = Array.from({ length: 25 }, (_, index) => record(`mem_${index}`, 'preference', `P${index}`));
|
|
48
|
+
const manyRooms = palaceRoomsOf(many);
|
|
49
|
+
assert.equal(manyRooms.length, 4);
|
|
50
|
+
assert.equal(manyRooms[0].total, 25);
|
|
51
|
+
assert.equal(manyRooms[0].pages, 2);
|
|
52
|
+
const first = palaceRoomPageOf(many, 'habits', 1);
|
|
53
|
+
assert.equal(first.items.length, 24);
|
|
54
|
+
assert.equal(first.page, 1);
|
|
55
|
+
const second = palaceRoomPageOf(many, 'habits', 2);
|
|
56
|
+
assert.equal(second.items.length, 1);
|
|
57
|
+
assert.equal(second.page, 2);
|
|
58
|
+
assert.equal(palaceRoomPageOf(many, 'habits', 99).page, 2);
|
|
59
|
+
assert.equal(palaceRoomPageOf(many, 'habits', 0).page, 1);
|
|
60
|
+
assert.deepEqual(palacePageWindow(1, 3), [1, 2, 3]);
|
|
61
|
+
assert.deepEqual(palacePageWindow(5, 12), [2, 3, 4, 5, 6, 7, 8]);
|
|
62
|
+
assert.equal(palacePageWindow(12, 12).includes(12), true);
|
|
63
|
+
|
|
64
|
+
// 同一时刻只展开一间:再点已展开的房间会收起。
|
|
65
|
+
assert.equal(palaceToggleOf('', 'methods'), 'methods');
|
|
66
|
+
assert.equal(palaceToggleOf('methods', 'methods'), '');
|
|
67
|
+
assert.equal(palaceToggleOf('methods', 'habits'), 'habits');
|
|
68
|
+
|
|
69
|
+
// 视图开关关闭时回列表,关闭的宫殿不进入组件树。
|
|
70
|
+
assert.equal(viewModeOf({ graph: true, palace: false }, 'palace'), 'list');
|
|
71
|
+
assert.equal(viewModeOf({ graph: false, palace: true }, 'palace'), 'palace');
|
|
72
|
+
assert.equal(viewModeOf({ graph: false, palace: false }, 'graph'), 'list');
|
|
73
|
+
|
|
74
|
+
// 减少动态时无动画,普通情况 180ms。
|
|
75
|
+
assert.equal(palaceMotionOf(true).duration, 0);
|
|
76
|
+
assert.equal(palaceMotionOf(false).duration, 180);
|
|
77
|
+
|
|
78
|
+
// 点击展品只把 record.id 交给现有详情入口,不另造详情。
|
|
79
|
+
let picked = '';
|
|
80
|
+
const tree = MemoryPalace({
|
|
81
|
+
records: [record('mem_a', 'preference', 'A')],
|
|
82
|
+
selectedId: '', onPick: (id) => { picked = id; }, emptyText: '这个项目还没有记忆',
|
|
83
|
+
});
|
|
84
|
+
const walk = (node, out = [], expand = false) => {
|
|
85
|
+
if (!node || typeof node !== 'object') return out;
|
|
86
|
+
if (expand && typeof node.type === 'function') return walk(node.type(node.props), out, true);
|
|
87
|
+
out.push(node);
|
|
88
|
+
for (const child of node.children || []) walk(child, out, expand);
|
|
89
|
+
return out;
|
|
90
|
+
};
|
|
91
|
+
const exhibit = walk(tree, [], true).find((node) => node.type === 'button' && node.props.title === 'A');
|
|
92
|
+
assert.ok(exhibit, '展品应是原生按钮');
|
|
93
|
+
exhibit.props.onClick();
|
|
94
|
+
assert.equal(picked, 'mem_a');
|
|
95
|
+
|
|
96
|
+
// 首帧列表视图不挂载宫殿;空数据时给下一步提示。
|
|
97
|
+
const firstPaint = MemoryPanel({ sessionId: 'demo', onClose: () => {} });
|
|
98
|
+
assert.equal(walk(firstPaint).some((node) => node.type === MemoryPalace), false);
|
|
99
|
+
const emptyTree = MemoryPalace({ records: [], selectedId: '', onPick: () => {}, emptyText: '这个项目还没有记忆' });
|
|
100
|
+
assert.equal(walk(emptyTree).some((node) => node.children.includes('这个项目还没有记忆')), true);
|
|
101
|
+
|
|
102
|
+
console.log('palace.test.mjs: all passed');
|