161 lines
5.5 KiB
TypeScript
161 lines
5.5 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import { createElement, createRef } from "react";
|
|
import { renderToString } from "react-dom/server";
|
|
import test from "node:test";
|
|
|
|
import type { ChatMessageFeedback } from "../src/components/chat-message-actions.tsx";
|
|
import {
|
|
RectificationMessageEntry,
|
|
UnsplitRectificationMessageList,
|
|
type RectificationMessageActions,
|
|
type RectificationMessageEntryProps,
|
|
type RenderMessage,
|
|
} from "../src/components/rectification-message-entry.tsx";
|
|
import {
|
|
disableHomeStreamingRenderProbe,
|
|
enableHomeStreamingRenderProbe,
|
|
homeStreamingRenderProbeSnapshot,
|
|
resetHomeStreamingRenderProbe,
|
|
} from "../src/lib/home-streaming-render-probe.ts";
|
|
|
|
const chatSource = readFileSync(
|
|
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const entrySource = readFileSync(
|
|
new URL("../src/components/rectification-message-entry.tsx", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const rowSource = readFileSync(
|
|
new URL("../src/components/chat-message-row.tsx", import.meta.url),
|
|
"utf8",
|
|
);
|
|
|
|
function settled(role: "user" | "assistant", text: string, key: string): RenderMessage {
|
|
return {
|
|
role,
|
|
text,
|
|
renderKey: key,
|
|
state: "settled",
|
|
};
|
|
}
|
|
|
|
function liveAssistant(text: string): RenderMessage {
|
|
return {
|
|
role: "assistant",
|
|
text,
|
|
renderKey: "live-assistant",
|
|
state: "streaming",
|
|
};
|
|
}
|
|
|
|
function emptyActions(): RectificationMessageEntryProps["actionsRef"] {
|
|
const actionsRef = createRef<RectificationMessageActions>() as RectificationMessageEntryProps["actionsRef"];
|
|
actionsRef.current = {
|
|
submitChoice() {},
|
|
submitStop() {},
|
|
copyMessage() {},
|
|
regenerateMessage() {},
|
|
onFeedback() {},
|
|
};
|
|
return actionsRef;
|
|
}
|
|
|
|
function sharedProps(): Omit<
|
|
RectificationMessageEntryProps,
|
|
"message" | "regenerating" | "canRegenerate" | "copied" | "feedback"
|
|
> {
|
|
return {
|
|
busy: true,
|
|
readonly: false,
|
|
currentQuestionFocusId: null,
|
|
interactive: false,
|
|
liveChoiceCard: null,
|
|
choiceNonce: 0,
|
|
savedTime: null,
|
|
actionsRef: emptyActions(),
|
|
};
|
|
}
|
|
|
|
function entryProps(
|
|
message: RenderMessage,
|
|
extras: Partial<Pick<RectificationMessageEntryProps, "regenerating" | "canRegenerate" | "copied" | "feedback">> = {},
|
|
): RectificationMessageEntryProps {
|
|
return {
|
|
...sharedProps(),
|
|
message,
|
|
regenerating: extras.regenerating ?? false,
|
|
canRegenerate: extras.canRegenerate ?? false,
|
|
copied: extras.copied ?? false,
|
|
feedback: extras.feedback,
|
|
};
|
|
}
|
|
|
|
test("the chat container no longer derives per-row timeline, varga, or choice cards", () => {
|
|
assert.doesNotMatch(chatSource, /rectificationTimelineRows\s*\(/);
|
|
assert.doesNotMatch(chatSource, /vargaSentenceFromMethods\s*\(/);
|
|
assert.doesNotMatch(chatSource, /choiceCardFromQuestion\s*\(/);
|
|
assert.match(entrySource, /rectificationTimelineRows\s*\(/);
|
|
assert.match(entrySource, /vargaSentenceFromMethods\s*\(/);
|
|
assert.match(entrySource, /choiceCardFromQuestion\s*\(/);
|
|
});
|
|
|
|
test("the extracted row is memoised and takes no function props except actionsRef", () => {
|
|
assert.match(entrySource, /export const RectificationMessageEntry = memo\(function RectificationMessageEntry/);
|
|
const propsBlock = entrySource.slice(
|
|
entrySource.indexOf("export type RectificationMessageEntryProps"),
|
|
entrySource.indexOf("export function choiceCardFromQuestion"),
|
|
);
|
|
assert.match(propsBlock, /actionsRef: MutableRefObject<RectificationMessageActions>/);
|
|
assert.doesNotMatch(propsBlock, /=>/);
|
|
assert.doesNotMatch(propsBlock, /on[A-Z]\w+\?:/);
|
|
assert.match(rowSource, /export const ChatMessageRow = memo\(function ChatMessageRow/);
|
|
});
|
|
|
|
test("the split architecture renders settled rectification rows once while streaming tokens", () => {
|
|
const history: RenderMessage[] = [
|
|
settled("user", "第一件经历", "u1"),
|
|
settled("assistant", "记下了第一件。", "a1"),
|
|
settled("user", "第二件经历", "u2"),
|
|
settled("assistant", "记下了第二件。", "a2"),
|
|
];
|
|
const tokens = ["甲", "甲乙", "甲乙丙", "甲乙丙丁", "甲乙丙丁戊"];
|
|
const shared = sharedProps();
|
|
const feedbackByKey: Readonly<Record<string, ChatMessageFeedback | undefined>> = {};
|
|
|
|
resetHomeStreamingRenderProbe();
|
|
enableHomeStreamingRenderProbe();
|
|
for (const message of history) {
|
|
renderToString(createElement(RectificationMessageEntry, entryProps(message)));
|
|
}
|
|
for (const streamingText of tokens) {
|
|
renderToString(createElement(RectificationMessageEntry, entryProps(liveAssistant(streamingText))));
|
|
}
|
|
const split = homeStreamingRenderProbeSnapshot();
|
|
disableHomeStreamingRenderProbe();
|
|
|
|
resetHomeStreamingRenderProbe();
|
|
enableHomeStreamingRenderProbe();
|
|
for (const streamingText of tokens) {
|
|
renderToString(createElement(UnsplitRectificationMessageList, {
|
|
...shared,
|
|
messages: [...history, liveAssistant(streamingText)],
|
|
latestRegeneratableKey: "a2",
|
|
copiedMessageKey: null,
|
|
feedbackByKey,
|
|
regeneratingMessageKey: null,
|
|
}));
|
|
}
|
|
const unsplit = homeStreamingRenderProbeSnapshot();
|
|
disableHomeStreamingRenderProbe();
|
|
|
|
assert.equal(split.settledRowRenders, history.length);
|
|
assert.ok(split.streamingRowRenders <= tokens.length);
|
|
assert.ok(split.streamingRowRenders >= 1);
|
|
assert.equal(unsplit.unsplitListRenders, tokens.length);
|
|
// Unsplit counts every row on every frame, including the live one.
|
|
assert.equal(unsplit.settledRowRenders, (history.length + 1) * tokens.length);
|
|
assert.ok(unsplit.settledRowRenders > split.settledRowRenders);
|
|
});
|