扩展宿主调用链扫描

自动发现 H5 HostBridge 真实能力调用链

补齐 runtime 回读与 wrapper 消费者扫描兜底

区分壳源码和 H5 业务调用链的禁替身词表

将视觉小说生产预览数据从 mock 命名收口为本地预览命名

更新原生壳验收文档和共享决策记录
This commit is contained in:
2026-06-19 02:45:56 +08:00
parent 429e65952d
commit deea0e8655
13 changed files with 321 additions and 96 deletions
@@ -416,7 +416,7 @@ import {
buildVisualNovelEntryGenerationProgress,
type VisualNovelEntryFormPayload,
} from '../visual-novel-creation/visualNovelEntryGeneration';
import { createMockVisualNovelRunFromDraft } from '../visual-novel-runtime/visualNovelMockData';
import { createLocalPreviewVisualNovelRunFromDraft } from '../visual-novel-runtime/visualNovelLocalPreviewData';
import {
buildBarkBattleDraftConfigFromWorkSummary,
buildBarkBattlePublishedConfigFromDraft,
@@ -7267,7 +7267,7 @@ export function PlatformEntryFlowShellImpl({
const profileId =
visualNovelWork?.summary.profileId?.trim() ?? draft.profileId?.trim();
if (!profileId) {
const run = createMockVisualNovelRunFromDraft(draft);
const run = createLocalPreviewVisualNovelRunFromDraft(draft);
setVisualNovelRun(run);
setVisualNovelRuntimeReturnStage('visual-novel-result');
setSelectionStage('visual-novel-runtime');
@@ -7287,7 +7287,7 @@ export function PlatformEntryFlowShellImpl({
} catch (error) {
// 中文注释:VN-07 只要求结果页进入测试运行时;真实 runtime 接口未就绪时,
// 使用当前 draft 生成本地 test run,不扩展到正式玩家运行链路。
setVisualNovelRun(createMockVisualNovelRunFromDraft(draft));
setVisualNovelRun(createLocalPreviewVisualNovelRunFromDraft(draft));
setVisualNovelRuntimeReturnStage('visual-novel-result');
setSelectionStage('visual-novel-runtime');
setVisualNovelError(
@@ -5,7 +5,7 @@ import type { ComponentProps } from 'react';
import { expect, test, vi } from 'vitest';
import { buildVisualNovelForbiddenCopyPattern } from '../visual-novel-runtime/visualNovelForbiddenCopy';
import { mockVisualNovelSession } from '../visual-novel-runtime/visualNovelMockData';
import { visualNovelLocalPreviewSession } from '../visual-novel-runtime/visualNovelLocalPreviewData';
import { VisualNovelAgentWorkspace } from './VisualNovelAgentWorkspace';
import {
buildVisualNovelEntryGenerationAnchorEntries,
@@ -107,7 +107,7 @@ test('visual novel workspace submits idea and selected visual style as seed text
});
test('visual novel workspace restores idea text from existing session', () => {
renderWorkspace({ session: mockVisualNovelSession });
renderWorkspace({ session: visualNovelLocalPreviewSession });
expect(
(screen.getByLabelText('一句话创作') as HTMLTextAreaElement).value,
@@ -5,7 +5,7 @@ import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import { buildVisualNovelForbiddenCopyPattern } from '../visual-novel-runtime/visualNovelForbiddenCopy';
import { mockVisualNovelDraft } from '../visual-novel-runtime/visualNovelMockData';
import { visualNovelLocalPreviewDraft } from '../visual-novel-runtime/visualNovelLocalPreviewData';
import { VisualNovelResultView } from './VisualNovelResultView';
vi.mock('../../services/visual-novel-creation', () => ({
@@ -26,7 +26,7 @@ vi.mock('../../services/assetReadUrlService', () => ({
test('visual novel profile tab uses PlatformSubpanel shells', () => {
const { container } = render(
<VisualNovelResultView draft={mockVisualNovelDraft} onBack={() => {}} />,
<VisualNovelResultView draft={visualNovelLocalPreviewDraft} onBack={() => {}} />,
);
const profilePanels = Array.from(
@@ -48,7 +48,7 @@ test('visual novel result header back button reuses shared compact back action b
render(
<VisualNovelResultView
draft={mockVisualNovelDraft}
draft={visualNovelLocalPreviewDraft}
onBack={onBack}
isBusy
/>,
@@ -69,7 +69,7 @@ test('visual novel result header back button reuses shared compact back action b
test('visual novel profile media previews use PlatformMediaFrame aspects', () => {
const draftWithCover = {
...mockVisualNovelDraft,
...visualNovelLocalPreviewDraft,
coverImageSrc: '/visual-novel/cover.png',
};
@@ -104,7 +104,7 @@ test('visual novel upload-only asset picker uses PlatformEmptyState chrome', asy
const user = userEvent.setup();
render(
<VisualNovelResultView draft={mockVisualNovelDraft} onBack={() => {}} />,
<VisualNovelResultView draft={visualNovelLocalPreviewDraft} onBack={() => {}} />,
);
await user.click(screen.getByRole('button', { name: '封面' }));
@@ -130,7 +130,7 @@ test('visual novel entity list items use interactive PlatformSubpanel shells', a
const user = userEvent.setup();
render(
<VisualNovelResultView draft={mockVisualNovelDraft} onBack={() => {}} />,
<VisualNovelResultView draft={visualNovelLocalPreviewDraft} onBack={() => {}} />,
);
await user.click(screen.getByRole('button', { name: '角色' }));
@@ -146,7 +146,7 @@ test('visual novel entity list items use interactive PlatformSubpanel shells', a
test('visual novel empty entity list uses PlatformEmptyState shell', async () => {
const user = userEvent.setup();
const emptyCharacterDraft = {
...mockVisualNovelDraft,
...visualNovelLocalPreviewDraft,
characters: [],
};
@@ -173,7 +173,7 @@ test('visual novel result opens complex editors as a dialog', async () => {
const user = userEvent.setup();
render(
<VisualNovelResultView draft={mockVisualNovelDraft} onBack={() => {}} />,
<VisualNovelResultView draft={visualNovelLocalPreviewDraft} onBack={() => {}} />,
);
await user.click(screen.getByRole('button', { name: '角色' }));
@@ -190,7 +190,7 @@ test('visual novel result exposes test run action with current draft', async ()
render(
<VisualNovelResultView
draft={mockVisualNovelDraft}
draft={visualNovelLocalPreviewDraft}
onBack={() => {}}
onStartTestRun={onStartTestRun}
/>,
@@ -209,7 +209,7 @@ test('visual novel result sends edited character draft to save and test run', as
render(
<VisualNovelResultView
draft={mockVisualNovelDraft}
draft={visualNovelLocalPreviewDraft}
onBack={() => {}}
onSaveDraft={onSaveDraft}
onStartTestRun={onStartTestRun}
@@ -251,7 +251,7 @@ test('visual novel result uploads scene and character assets into platform refer
render(
<VisualNovelResultView
draft={mockVisualNovelDraft}
draft={visualNovelLocalPreviewDraft}
onBack={() => {}}
onSaveDraft={onSaveDraft}
/>,
@@ -304,7 +304,7 @@ test('visual novel result generates scene background from asset picker', async (
render(
<VisualNovelResultView
draft={mockVisualNovelDraft}
draft={visualNovelLocalPreviewDraft}
onBack={() => {}}
onSaveDraft={onSaveDraft}
/>,
@@ -330,7 +330,7 @@ test('visual novel result generates scene background from asset picker', async (
expect.objectContaining({
kind: 'scene_background',
scene: expect.objectContaining({
sceneId: mockVisualNovelDraft.scenes[0]?.sceneId,
sceneId: visualNovelLocalPreviewDraft.scenes[0]?.sceneId,
}),
prompt: '默认图片提示词',
}),
@@ -57,7 +57,7 @@ import {
PlatformTextField,
} from '../common/PlatformTextField';
import { PlatformToggleRow } from '../common/PlatformToggleRow';
import { mockVisualNovelDraft } from '../visual-novel-runtime/visualNovelMockData';
import { visualNovelLocalPreviewDraft } from '../visual-novel-runtime/visualNovelLocalPreviewData';
type VisualNovelResultViewProps = {
draft?: VisualNovelResultDraft | null;
@@ -1994,7 +1994,7 @@ function VisualNovelEditorDialog({
}
export function VisualNovelResultView({
draft = mockVisualNovelDraft,
draft = visualNovelLocalPreviewDraft,
isBusy = false,
error = null,
onBack,
@@ -2004,7 +2004,7 @@ export function VisualNovelResultView({
onPublish,
}: VisualNovelResultViewProps) {
const [editDraft, setEditDraft] = useState(() =>
cloneDraft(draft ?? mockVisualNovelDraft),
cloneDraft(draft ?? visualNovelLocalPreviewDraft),
);
const [activeTab, setActiveTab] = useState<VisualNovelResultTab>('profile');
const [editorTarget, setEditorTarget] =
@@ -5,14 +5,14 @@ import { expect, test } from 'vitest';
import { VisualNovelAttributePanel } from './VisualNovelAttributePanel';
import { VisualNovelHistoryPanel } from './VisualNovelHistoryPanel';
import { mockVisualNovelRun } from './visualNovelMockData';
import { visualNovelLocalPreviewRun } from './visualNovelLocalPreviewData';
import { VisualNovelSavePanel } from './VisualNovelSavePanel';
test('visual novel history panel uses platform empty state', () => {
render(
<VisualNovelHistoryPanel
run={{
...mockVisualNovelRun,
...visualNovelLocalPreviewRun,
history: [],
}}
/>,
@@ -28,7 +28,7 @@ test('visual novel attribute panel uses platform empty state', () => {
render(
<VisualNovelAttributePanel
run={{
...mockVisualNovelRun,
...visualNovelLocalPreviewRun,
metrics: {},
}}
/>,
@@ -43,7 +43,7 @@ test('visual novel attribute panel uses platform empty state', () => {
test('visual novel save panel uses platform empty states', () => {
const { rerender } = render(
<VisualNovelSavePanel
run={mockVisualNovelRun}
run={visualNovelLocalPreviewRun}
saveArchives={[]}
isLoadingArchives
/>,
@@ -52,7 +52,7 @@ test('visual novel save panel uses platform empty states', () => {
expect(screen.getByText('读取中').className).toContain('bg-white/74');
expect(screen.queryByText('暂无存档')).toBeNull();
rerender(<VisualNovelSavePanel run={mockVisualNovelRun} saveArchives={[]} />);
rerender(<VisualNovelSavePanel run={visualNovelLocalPreviewRun} saveArchives={[]} />);
expect(screen.getByText('暂无存档').className).toContain('bg-white/74');
expect(screen.queryByText('读取中')).toBeNull();
@@ -60,7 +60,7 @@ test('visual novel save panel uses platform empty states', () => {
test('visual novel runtime list cards use platform subpanel chrome', () => {
const { rerender } = render(
<VisualNovelHistoryPanel run={mockVisualNovelRun} />,
<VisualNovelHistoryPanel run={visualNovelLocalPreviewRun} />,
);
const historyCard = screen.getByText('#1').closest('article');
@@ -71,12 +71,12 @@ test('visual novel runtime list cards use platform subpanel chrome', () => {
rerender(
<VisualNovelSavePanel
run={mockVisualNovelRun}
run={visualNovelLocalPreviewRun}
saveArchives={[
{
worldKey: 'visual-novel:archive-1',
ownerUserId: 'mock-user',
profileId: 'vn-profile-mock-1',
ownerUserId: 'local-preview-user',
profileId: 'vn-profile-preview-1',
worldType: 'visual-novel',
worldName: '雪线电台',
subtitle: '风雪站台',
@@ -5,7 +5,7 @@ import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import { buildVisualNovelForbiddenCopyPattern } from './visualNovelForbiddenCopy';
import { mockVisualNovelDraft, mockVisualNovelRun } from './visualNovelMockData';
import { visualNovelLocalPreviewDraft, visualNovelLocalPreviewRun } from './visualNovelLocalPreviewData';
import { VisualNovelRuntimeShell } from './VisualNovelRuntimeShell';
test('visual novel runtime renders mock play surface and opens panels as dialogs', async () => {
@@ -13,8 +13,8 @@ test('visual novel runtime renders mock play surface and opens panels as dialogs
render(
<VisualNovelRuntimeShell
draft={mockVisualNovelDraft}
run={mockVisualNovelRun}
draft={visualNovelLocalPreviewDraft}
run={visualNovelLocalPreviewRun}
onBack={() => {}}
/>,
);
@@ -35,8 +35,8 @@ test('visual novel runtime submits free text action with client event id', async
render(
<VisualNovelRuntimeShell
draft={mockVisualNovelDraft}
run={mockVisualNovelRun}
draft={visualNovelLocalPreviewDraft}
run={visualNovelLocalPreviewRun}
onBack={() => {}}
onSubmitAction={onSubmitAction}
/>,
@@ -61,14 +61,14 @@ test('visual novel runtime submits choice and continue actions', async () => {
const onSubmitAction = vi.fn();
const onContinue = vi.fn();
const runWithoutChoices = {
...mockVisualNovelRun,
...visualNovelLocalPreviewRun,
availableChoices: [],
};
const { rerender } = render(
<VisualNovelRuntimeShell
draft={mockVisualNovelDraft}
run={mockVisualNovelRun}
draft={visualNovelLocalPreviewDraft}
run={visualNovelLocalPreviewRun}
onBack={() => {}}
onSubmitAction={onSubmitAction}
onContinue={onContinue}
@@ -88,7 +88,7 @@ test('visual novel runtime submits choice and continue actions', async () => {
rerender(
<VisualNovelRuntimeShell
draft={mockVisualNovelDraft}
draft={visualNovelLocalPreviewDraft}
run={runWithoutChoices}
onBack={() => {}}
onSubmitAction={onSubmitAction}
@@ -108,8 +108,8 @@ test('visual novel runtime panels call regeneration and platform archive actions
render(
<VisualNovelRuntimeShell
draft={mockVisualNovelDraft}
run={mockVisualNovelRun}
draft={visualNovelLocalPreviewDraft}
run={visualNovelLocalPreviewRun}
onBack={() => {}}
onRegenerateHistoryEntry={onRegenerateHistoryEntry}
onSaveRun={onSaveRun}
@@ -117,8 +117,8 @@ test('visual novel runtime panels call regeneration and platform archive actions
saveArchives={[
{
worldKey: 'visual-novel:archive-1',
ownerUserId: 'mock-user',
profileId: 'vn-profile-mock-1',
ownerUserId: 'local-preview-user',
profileId: 'vn-profile-preview-1',
worldType: 'visual-novel',
worldName: '雪线电台',
subtitle: '风雪站台',
@@ -147,8 +147,8 @@ test('visual novel runtime shows raw text only as transient stream text', () =>
render(
<VisualNovelRuntimeShell
draft={mockVisualNovelDraft}
run={mockVisualNovelRun}
draft={visualNovelLocalPreviewDraft}
run={visualNovelLocalPreviewRun}
streamingText={transientText}
onBack={() => {}}
/>,
@@ -25,7 +25,7 @@ import type {
} from '../../../packages/shared/src/contracts/visualNovel';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { useVisualNovelRuntimeController } from './useVisualNovelRuntimeController';
import { mockVisualNovelDraft, mockVisualNovelRun } from './visualNovelMockData';
import { visualNovelLocalPreviewDraft, visualNovelLocalPreviewRun } from './visualNovelLocalPreviewData';
import {
VisualNovelRuntimePanel,
type VisualNovelRuntimePanelKind,
@@ -218,8 +218,8 @@ function resolveChoices(
}
export function VisualNovelRuntimeShell({
draft = mockVisualNovelDraft,
run = mockVisualNovelRun,
draft = visualNovelLocalPreviewDraft,
run = visualNovelLocalPreviewRun,
isBusy = false,
isSaving = false,
isLoadingArchives = false,
@@ -243,8 +243,8 @@ export function VisualNovelRuntimeShell({
const [localTextModeEnabled, setLocalTextModeEnabled] = useState(
run?.textModeEnabled ?? draft?.runtimeConfig.defaultTextMode ?? false,
);
const displayDraft = draft ?? mockVisualNovelDraft;
const baseRun = run ?? mockVisualNovelRun;
const displayDraft = draft ?? visualNovelLocalPreviewDraft;
const baseRun = run ?? visualNovelLocalPreviewRun;
const runtimeController = useVisualNovelRuntimeController({
draft: displayDraft,
initialRun: baseRun,
@@ -5,11 +5,11 @@ import type {
VisualNovelSourceMode,
} from '../../../packages/shared/src/contracts/visualNovel';
const MOCK_NOW = '2026-05-05T12:00:00.000Z';
const LOCAL_PREVIEW_NOW = '2026-05-05T12:00:00.000Z';
// 中文注释:VN-04 只提供 UI 骨架,mock 数据必须保持在前端组件域内,避免被误用成业务真相源。
export const mockVisualNovelDraft: VisualNovelResultDraft = {
profileId: 'vn-profile-mock-1',
// 中文注释:VN-04 只提供 UI 骨架,本地预览数据必须保持在前端组件域内,避免被误用成业务真相源。
export const visualNovelLocalPreviewDraft: VisualNovelResultDraft = {
profileId: 'vn-profile-preview-1',
workTitle: '雪线电台',
workDescription: '一列停在雪夜边境的列车,牵出旧电台、失踪乘客和未寄出的告白。',
workTags: ['悬疑', '冬夜', '双人叙事'],
@@ -140,12 +140,12 @@ export const mockVisualNovelDraft: VisualNovelResultDraft = {
message: '封面待补齐。',
},
],
updatedAt: MOCK_NOW,
updatedAt: LOCAL_PREVIEW_NOW,
};
export const mockVisualNovelSession: VisualNovelAgentSessionSnapshot = {
sessionId: 'vn-session-mock-1',
ownerUserId: 'mock-user',
export const visualNovelLocalPreviewSession: VisualNovelAgentSessionSnapshot = {
sessionId: 'vn-session-preview-1',
ownerUserId: 'local-preview-user',
sourceMode: 'idea',
status: 'ready',
messages: [
@@ -154,24 +154,24 @@ export const mockVisualNovelSession: VisualNovelAgentSessionSnapshot = {
role: 'user',
kind: 'chat',
text: '想做一个雪夜列车和旧电台有关的悬疑视觉小说。',
createdAt: MOCK_NOW,
createdAt: LOCAL_PREVIEW_NOW,
},
{
id: 'vn-message-2',
role: 'assistant',
kind: 'summary',
text: '底稿已整理到世界观、角色、场景和前两段剧情阶段。',
createdAt: MOCK_NOW,
createdAt: LOCAL_PREVIEW_NOW,
},
],
draft: mockVisualNovelDraft,
draft: visualNovelLocalPreviewDraft,
pendingAction: {
actionId: 'vn-action-compile-mock',
actionId: 'vn-action-compile-preview',
kind: 'compile_work_profile',
label: '生成结果页',
},
createdAt: MOCK_NOW,
updatedAt: MOCK_NOW,
createdAt: LOCAL_PREVIEW_NOW,
updatedAt: LOCAL_PREVIEW_NOW,
};
function createVisualNovelDraftId(prefix: string) {
@@ -278,7 +278,7 @@ export function createBlankVisualNovelDraft(
};
}
export function createMockVisualNovelSessionFromDraft(
export function createLocalPreviewVisualNovelSessionFromDraft(
draft: VisualNovelResultDraft,
): VisualNovelAgentSessionSnapshot {
const now = new Date().toISOString();
@@ -307,7 +307,7 @@ export function createMockVisualNovelSessionFromDraft(
};
}
export function createMockVisualNovelRunFromDraft(
export function createLocalPreviewVisualNovelRunFromDraft(
draft: VisualNovelResultDraft,
): VisualNovelRunSnapshot {
const now = new Date().toISOString();
@@ -388,10 +388,10 @@ export function createMockVisualNovelRunFromDraft(
};
}
export const mockVisualNovelRun: VisualNovelRunSnapshot = {
runId: 'vn-run-mock-1',
ownerUserId: 'mock-user',
profileId: 'vn-profile-mock-1',
export const visualNovelLocalPreviewRun: VisualNovelRunSnapshot = {
runId: 'vn-run-preview-1',
ownerUserId: 'local-preview-user',
profileId: 'vn-profile-preview-1',
mode: 'test',
status: 'active',
currentSceneId: 'vn-scene-platform',
@@ -408,7 +408,7 @@ export const mockVisualNovelRun: VisualNovelRunSnapshot = {
history: [
{
entryId: 'vn-history-1',
runId: 'vn-run-mock-1',
runId: 'vn-run-preview-1',
turnIndex: 1,
source: 'assistant',
actionText: null,
@@ -432,24 +432,24 @@ export const mockVisualNovelRun: VisualNovelRunSnapshot = {
},
],
snapshotBeforeHash: null,
snapshotAfterHash: 'mock-hash-1',
createdAt: MOCK_NOW,
snapshotAfterHash: 'preview-hash-1',
createdAt: LOCAL_PREVIEW_NOW,
},
{
entryId: 'vn-history-2',
runId: 'vn-run-mock-1',
runId: 'vn-run-preview-1',
turnIndex: 2,
source: 'player',
actionText:
'靠近广播柜,确认频段来源,同时留意林遥是否在隐瞒什么。',
steps: [],
snapshotBeforeHash: 'mock-hash-1',
snapshotAfterHash: 'mock-hash-2',
createdAt: MOCK_NOW,
snapshotBeforeHash: 'preview-hash-1',
snapshotAfterHash: 'preview-hash-2',
createdAt: LOCAL_PREVIEW_NOW,
},
],
availableChoices: mockVisualNovelDraft.opening.initialChoices,
availableChoices: visualNovelLocalPreviewDraft.opening.initialChoices,
textModeEnabled: true,
createdAt: MOCK_NOW,
updatedAt: MOCK_NOW,
createdAt: LOCAL_PREVIEW_NOW,
updatedAt: LOCAL_PREVIEW_NOW,
};