Files
ST-Bionic-Memory-Ecology/tests/mobile-status-regressions.mjs
2026-04-11 01:45:47 +08:00

571 lines
15 KiB
JavaScript

import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import vm from "node:vm";
import { onManualExtractController } from "../maintenance/extraction-controller.js";
import { onRebuildController } from "../ui/ui-actions-controller.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const indexPath = path.resolve(__dirname, "../index.js");
const indexSource = await fs.readFile(indexPath, "utf8");
function extractSnippet(startMarker, endMarker) {
const start = indexSource.indexOf(startMarker);
const end = indexSource.indexOf(endMarker);
if (start < 0 || end < 0 || end <= start) {
throw new Error(`无法提取 index.js 片段: ${startMarker} -> ${endMarker}`);
}
return indexSource.slice(start, end).replace(/^export\s+/gm, "");
}
const statusSnippet = extractSnippet(
"function setRuntimeStatus(",
"function notifyExtractionIssue(",
);
const vectorSnippet = extractSnippet(
"async function syncVectorState({",
"async function ensureVectorReadyIfNeeded(",
);
const manualExtractSnippet = extractSnippet(
"async function onManualExtract(options = {}) {",
"async function onReroll(",
);
const rebuildSnippet = extractSnippet(
"async function onRebuild() {",
"async function onManualCompress() {",
);
function createBaseStatusContext() {
return {
console,
Date,
createUiStatus(text = "待命", meta = "", level = "idle") {
return {
text: String(text || "待命"),
meta: String(meta || ""),
level,
updatedAt: Date.now(),
};
},
runtimeStatus: { text: "待命", meta: "", level: "idle" },
lastExtractionStatus: { text: "待命", meta: "", level: "idle" },
lastVectorStatus: { text: "待命", meta: "", level: "idle" },
lastRecallStatus: { text: "待命", meta: "", level: "idle" },
lastStatusToastAt: {},
STATUS_TOAST_THROTTLE_MS: 1500,
_panelModule: {
updateFloatingBallStatus() {},
},
refreshPanelLiveState() {},
updateStageNotice() {},
notifyStatusToast() {},
toastr: {
info() {},
success() {},
warning() {},
error() {},
},
};
}
function testIndexDefinesLastProcessedAssistantFloorHelper() {
assert.match(
indexSource,
/function\s+getLastProcessedAssistantFloor\s*\(/,
);
}
async function testVectorSyncTerminalStateUpdatesRuntime() {
const context = {
...createBaseStatusContext(),
currentGraph: {
vectorIndexState: {
dirty: true,
lastWarning: "",
},
},
ensureCurrentGraphRuntimeState() {
return context.currentGraph;
},
getEmbeddingConfig() {
return { mode: "direct" };
},
validateVectorConfig() {
return { valid: true };
},
async syncGraphVectorIndex() {
return {
insertedHashes: [],
stats: {
indexed: 12,
pending: 0,
},
};
},
getCurrentChatId() {
return "chat-mobile";
},
getVectorIndexStats() {
return { indexed: 12, pending: 0 };
},
isAbortError() {
return false;
},
markVectorStateDirty() {},
result: null,
};
vm.createContext(context);
vm.runInContext(
`${statusSnippet}\n${vectorSnippet}\nresult = { syncVectorState };`,
context,
{ filename: indexPath },
);
const result = await context.result.syncVectorState({ force: true });
assert.equal(result.stats.indexed, 12);
assert.equal(context.lastVectorStatus.text, "向量完成");
assert.equal(context.runtimeStatus.text, "向量完成");
assert.equal(context.runtimeStatus.level, "success");
}
async function testManualExtractNoBatchesDoesNotStayRunning() {
let assistantTurnCallCount = 0;
const chat = [{ is_user: true, mes: "u" }, { is_user: false, mes: "a" }];
const context = {
...createBaseStatusContext(),
isExtracting: false,
currentGraph: {},
graphPersistenceState: {
pendingPersist: false,
},
getCurrentChatId() {
return "chat-mobile";
},
getGraphPersistenceState() {
return { pendingPersist: false };
},
ensureGraphMutationReady() {
return true;
},
async recoverHistoryIfNeeded() {
return true;
},
normalizeGraphRuntimeState(graph) {
return graph;
},
createEmptyGraph() {
return {};
},
getContext() {
return { chat };
},
getAssistantTurns() {
assistantTurnCallCount += 1;
return assistantTurnCallCount === 1 ? [1] : [];
},
getLastProcessedAssistantFloor() {
return 0;
},
clampInt(value, fallback) {
return Number.isFinite(Number(value)) ? Number(value) : fallback;
},
getSettings() {
return { extractEvery: 1 };
},
beginStageAbortController() {
return { signal: {} };
},
async executeExtractionBatch() {
throw new Error("不应进入批次执行");
},
async retryPendingGraphPersist() {
return {
accepted: false,
reason: "no-pending-persist",
};
},
isAbortError() {
return false;
},
onManualExtractController,
finishStageAbortController() {},
result: null,
};
vm.createContext(context);
vm.runInContext(
`${statusSnippet}\n${manualExtractSnippet}\nresult = { onManualExtract };`,
context,
{ filename: indexPath },
);
await context.result.onManualExtract();
assert.equal(context.isExtracting, false);
assert.equal(context.lastExtractionStatus.text, "无待提取内容");
assert.equal(context.runtimeStatus.text, "无待提取内容");
assert.notEqual(context.runtimeStatus.level, "running");
}
async function testManualExtractIgnoresSupersededPendingPersistence() {
let executeExtractionBatchCalls = 0;
let assistantTurnCallCount = 0;
const chat = [{ is_user: true, mes: "u" }, { is_user: false, mes: "a" }];
const context = {
...createBaseStatusContext(),
isExtracting: false,
graphPersistenceState: {
pendingPersist: false,
lastAcceptedRevision: 7,
},
currentGraph: {
historyState: {
lastBatchStatus: {
processedRange: [1, 1],
persistence: {
outcome: "queued",
accepted: false,
revision: 7,
reason: "extraction-batch-complete:pending",
storageTier: "none",
},
},
},
},
getCurrentChatId() {
return "chat-mobile";
},
getCurrentGraph() {
return context.currentGraph;
},
getIsExtracting() {
return context.isExtracting;
},
getGraphPersistenceState() {
return {
pendingPersist: false,
lastAcceptedRevision: 7,
};
},
ensureGraphMutationReady() {
return true;
},
async recoverHistoryIfNeeded() {
return true;
},
normalizeGraphRuntimeState(graph) {
return graph;
},
setCurrentGraph(graph) {
context.currentGraph = graph;
},
createEmptyGraph() {
return {};
},
getContext() {
return { chat };
},
getAssistantTurns() {
assistantTurnCallCount += 1;
return assistantTurnCallCount <= 2 ? [1] : [];
},
getLastProcessedAssistantFloor() {
return 0;
},
clampInt(value, fallback) {
return Number.isFinite(Number(value)) ? Number(value) : fallback;
},
getSettings() {
return { extractEvery: 1 };
},
beginStageAbortController() {
return { signal: {} };
},
async executeExtractionBatch() {
executeExtractionBatchCalls += 1;
return {
success: true,
result: {
newNodes: 0,
updatedNodes: 0,
newEdges: 0,
},
effects: {},
batchStatus: {
persistence: {
accepted: true,
},
},
historyAdvanceAllowed: true,
};
},
async retryPendingGraphPersist() {
return {
accepted: false,
reason: "no-pending-persist",
};
},
isAbortError() {
return false;
},
onManualExtractController,
finishStageAbortController() {},
setIsExtracting(value) {
context.isExtracting = value;
},
setLastExtractionStatus(text, meta, level) {
context.lastExtractionStatus = { text, meta, level };
context.runtimeStatus = { text, meta, level };
},
toastr: {
info() {},
success() {},
warning() {},
error() {},
},
result: null,
};
await onManualExtractController(context, { drainAll: false });
assert.equal(executeExtractionBatchCalls, 1);
assert.notEqual(context.lastExtractionStatus.text, "等待持久化确认");
}
async function testManualExtractIgnoresFailedBatchWithoutPersistenceAttempt() {
let executeExtractionBatchCalls = 0;
const chat = [{ is_user: true, mes: "u" }, { is_user: false, mes: "a" }];
const context = {
...createBaseStatusContext(),
isExtracting: false,
graphPersistenceState: {
pendingPersist: false,
lastAcceptedRevision: 0,
},
currentGraph: {
historyState: {
lastBatchStatus: {
outcome: "failed",
processedRange: [1, 1],
persistence: {
outcome: "queued",
accepted: false,
revision: 0,
reason: "",
storageTier: "none",
},
},
},
},
getCurrentChatId() {
return "chat-mobile";
},
getCurrentGraph() {
return context.currentGraph;
},
getIsExtracting() {
return context.isExtracting;
},
getGraphPersistenceState() {
return {
pendingPersist: false,
lastAcceptedRevision: 0,
};
},
ensureGraphMutationReady() {
return true;
},
async recoverHistoryIfNeeded() {
return true;
},
normalizeGraphRuntimeState(graph) {
return graph;
},
setCurrentGraph(graph) {
context.currentGraph = graph;
},
createEmptyGraph() {
return {};
},
getContext() {
return { chat };
},
getAssistantTurns() {
return [1];
},
getLastProcessedAssistantFloor() {
return 0;
},
clampInt(value, fallback) {
return Number.isFinite(Number(value)) ? Number(value) : fallback;
},
getSettings() {
return { extractEvery: 1 };
},
beginStageAbortController() {
return { signal: {} };
},
async executeExtractionBatch() {
executeExtractionBatchCalls += 1;
return {
success: true,
result: {
newNodes: 0,
updatedNodes: 0,
newEdges: 0,
},
effects: {},
batchStatus: {
persistence: {
accepted: true,
revision: 1,
attempted: true,
},
},
historyAdvanceAllowed: true,
};
},
async retryPendingGraphPersist() {
return {
accepted: false,
reason: "no-pending-persist",
};
},
isAbortError() {
return false;
},
onManualExtractController,
finishStageAbortController() {},
setIsExtracting(value) {
context.isExtracting = value;
},
setLastExtractionStatus(text, meta, level) {
context.lastExtractionStatus = { text, meta, level };
context.runtimeStatus = { text, meta, level };
},
toastr: {
info() {},
success() {},
warning() {},
error() {},
},
result: null,
};
await onManualExtractController(context, { drainAll: false });
assert.equal(executeExtractionBatchCalls, 1);
assert.notEqual(context.lastExtractionStatus.text, "等待持久化确认");
}
async function testManualRebuildSetsTerminalRuntimeStatus() {
const chat = [{ is_user: true, mes: "u" }, { is_user: false, mes: "a" }];
let savedHashes = null;
let savedNeedRefresh = null;
const context = {
...createBaseStatusContext(),
__confirmHost: true,
currentGraph: {
historyState: {
lastProcessedAssistantFloor: -1,
processedMessageHashes: {},
processedMessageHashesNeedRefresh: false,
},
vectorIndexState: {
lastWarning: "",
},
batchJournal: [],
},
confirm() {
assert.equal(this?.__confirmHost, true);
return true;
},
ensureGraphMutationReady() {
return true;
},
getContext() {
return { chat };
},
cloneGraphSnapshot(graph) {
return graph;
},
snapshotRuntimeUiState() {
return {};
},
getSettings() {
return {};
},
normalizeGraphRuntimeState(graph) {
return graph;
},
createEmptyGraph() {
return {
historyState: {
lastProcessedAssistantFloor: -1,
processedMessageHashes: {},
processedMessageHashesNeedRefresh: false,
},
vectorIndexState: {
lastWarning: "",
},
batchJournal: [],
};
},
getCurrentChatId() {
return "chat-mobile";
},
clearInjectionState() {},
async prepareVectorStateForReplay() {},
async replayExtractionFromHistory() {
context.currentGraph.historyState.lastProcessedAssistantFloor = 1;
context.currentGraph.vectorIndexState.lastWarning = "";
return 2;
},
clearHistoryDirty(graph) {
graph.historyState.processedMessageHashes = {};
graph.historyState.processedMessageHashesNeedRefresh = true;
},
buildRecoveryResult(status, extra = {}) {
return { status, ...extra };
},
updateProcessedHistorySnapshot(chatInput, floor) {
context.currentGraph.historyState.lastProcessedAssistantFloor = floor;
context.currentGraph.historyState.processedMessageHashes = {};
for (let index = 0; index <= floor; index += 1) {
context.currentGraph.historyState.processedMessageHashes[index] =
String(chatInput[index]?.mes || "");
}
context.currentGraph.historyState.processedMessageHashesNeedRefresh = false;
},
saveGraphToChat() {
savedHashes = { ...context.currentGraph.historyState.processedMessageHashes };
savedNeedRefresh =
context.currentGraph.historyState.processedMessageHashesNeedRefresh;
},
restoreRuntimeUiState() {},
onRebuildController,
result: null,
};
vm.createContext(context);
vm.runInContext(
`${statusSnippet}\n${rebuildSnippet}\nresult = { onRebuild };`,
context,
{ filename: indexPath },
);
await context.result.onRebuild();
assert.equal(context.lastExtractionStatus.text, "图谱重建完成");
assert.equal(context.runtimeStatus.text, "图谱重建完成");
assert.equal(context.runtimeStatus.level, "success");
assert.deepEqual(savedHashes, {
0: "u",
1: "a",
});
assert.equal(savedNeedRefresh, false);
}
testIndexDefinesLastProcessedAssistantFloorHelper();
await testVectorSyncTerminalStateUpdatesRuntime();
await testManualExtractNoBatchesDoesNotStayRunning();
await testManualExtractIgnoresSupersededPendingPersistence();
await testManualExtractIgnoresFailedBatchWithoutPersistenceAttempt();
await testManualRebuildSetsTerminalRuntimeStatus();
console.log("mobile-status-regressions tests passed");