Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCardPreviewAlphaBackground.test.tsx
T
k88936 876529e668 删除 Project Supervisor 前端链路并将项目对话收敛到 DirectProject 与立项策划
- 删除 ProjectSupervisorView、SupervisorChatOnlyView、ProjectWorkspaceChatPane、AgentConversationOverlay、DeveloperProjectPanels、DeveloperRuntimePanels 与 features/agent-runtime/panels.tsx
- 删除 Supervisor 独立调试窗口:windows.rs 的 supervisor_chat_window_url / open_project_supervisor_chat_window、main.rs 的 invoke 注册、?supervisor-chat 与 ?agent-chat 前端入口、developer.json capability 和对应 Rust 用例
- 删除工作台壳的开发者 Agent 面板:DeveloperAgentPanel、useDeveloperAgentPanel、useDeveloperAgentState、developerAgentControls
- App.tsx 删除只服务退役面板的 state/ref/effect/handler(agentConversation*、文件/记忆/资产/画板/预览面板处理、commandLog、llmConfigStatus、editorBaseUrl、回放历史与 trace 面板状态、selectedAgent 等)以及由此产生的空分支,并把 requestRuntimeConfigOpen 接回本地 RuntimeConfigDialog
- 立项策划独立成模块:Design Agent 与 Planning V2 的容器和表现移到 view/project-development/planning/(PlanningChatView、PlanningUserInputCard、GddApprovalCard、DesignAgentSurface、PlanningLaneRuntimeStrip、planningLane、planningSessionV2、planningSessionContract)
- 改名到中性概念:ProjectSupervisorComponentProps→ProjectChatComponentProps、WorkspaceLauncherShellProps.ProjectSupervisor→ProjectChat、ProjectDevelopmentView.supervisor→chat、orchestrationMode→agentDockVisible、initialSupervisorMessageClaims→initialTurnClaims、ProjectManifestSnapshotSource 'supervisor'→'chat'、CSS project-supervisor-*/project-planning-*→project-chat-*
- 删除 PROJECT_SUPERVISOR_AGENT_ID 与 PROJECT_SUPERVISOR_PLAN_SOURCE 常量
- 工作台壳自己订阅 game-creator-manifest-invalidated,用 revision→清单→revision 配对读(source: asset-event)并入 currentProjectContext,且不重新检查项目目录;测试夹具改为按监听器集合广播该事件
- 删除只钉住退役界面的 appSurface 用例(旧调试窗口、开发者面板、Agent 对话浮层、运行历史面板)
- 同步文档:ADR、DirectProject 聊天模块抽离实施计划与里程碑、Provider 推理里程碑、策划会话 RuntimeV2、AI 游戏创作实施计划,并在 decision-log 新增 2026-09-19 条
- 删除随退役面板失去调用方的模块与导出:projectSummaryCommands.ts(1054 行旧 Supervisor 斜杠命令处理器)、AGENT_RUN_HISTORY_* 常量、agent-runtime/model.ts 与 project-summary/agentPresentation.ts 里的死导出,以及 agentRunTrace / memoryCommands / projectCommandPolicy 中无调用方的校验与解析函数
- 更新 scripts/check-config.mjs 与 scripts/check-native-shells.mjs 的窗口与命令守卫
2026-09-19 21:59:44 +08:00

435 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
/**
* 资源卡的棋盘格底必须由**这张图真实的 alpha** 决定,而不是「预览分支是图片」。
*
* 现场缺陷(验收截图):所有 PNG/JPEG 卡一律铺 CSS 棋盘格,于是「真透明底」与
* 「AI 把棋盘格画进像素里」在卡面上完全同形,验收时无法区分两者。
*
* 本文件把整条链路钉住:
* 1. 真的渲染 `ProjectDevelopmentView`,用假的 `read_local_project_image_preview`
* 返回原生头部判据 `hasAlpha`,断言卡片根节点的 `data-preview-has-alpha`;
* 2. 把真实 DOM 上的 `data-preview-kind` / `data-preview-has-alpha` 喂给
* `styles.css` 的声明级层叠求值,断言只有真透明卡片的 `.game-resource-card-visual`
* 最终生效声明里才有棋盘格(判据取 `background-size: 16px 16px` 与渐变层)。
*
* 为什么不用 `getComputedStyle(visual).backgroundImage`:jsdom 不加载样式表,且本仓库
* 测试环境的 cssstyle 解析不了渐变 —— 实测把 `background: linear-gradient(...)` 与
* `background-image: linear-gradient(...)` 写进 `<style>` 后,`getComputedStyle` 的
* `backgroundImage` 恒为 `''`(连 `background` 都退化成 `rgba(0, 0, 0, 0)`)。
* 因此沿用本仓库既有口径(见 `resourceCardCurrentVersionStyle.test.ts`、`styleCascade.ts`):
* 在源文件声明上按真实层叠求值,肉眼效果留给真机确认。
*/
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import {
act,
createGameCreationAppManifest,
describe,
expect,
findResourceSelectButton,
fireEvent,
it,
ProjectDevelopmentView,
React,
readFileSync,
render,
resolve,
screen,
vi,
waitFor,
} from './appSurface/harness';
import {
declaration,
parseStyleSheet,
resolveDeclarations,
} from './styleCascade';
const PROJECT_PATH = '/tmp/workbench-preview-alpha';
const PROJECT_ID = 'workbench-preview-alpha';
/** 棋盘格底的两条判据:`background-size` 是这套底独有的档位,渐变层是像素级图案本身。 */
const CHECKERBOARD_BACKGROUND_SIZE = '16px 16px';
const CHECKERBOARD_LAYER =
'linear-gradient(45deg, #f1ebe7 25%, transparent 25%)';
/** 无 alpha 时必须退回的卡片既有纯色底(`.game-resource-card-visual` 本体那条)。 */
const PLAIN_CARD_BACKGROUND = 'linear-gradient(145deg, #fffaf6, #f6ece6)';
function installResourceCardIntersectionObserver() {
const instances: Array<{
callback: IntersectionObserverCallback;
observed: Set<Element>;
observer: IntersectionObserver;
}> = [];
class ResourceCardIntersectionObserver {
readonly root = null;
readonly rootMargin = '160px';
readonly thresholds = [0];
readonly observed = new Set<Element>();
constructor(readonly callback: IntersectionObserverCallback) {
instances.push({
callback,
observed: this.observed,
observer: this as unknown as IntersectionObserver,
});
}
observe(element: Element) {
this.observed.add(element);
}
unobserve(element: Element) {
this.observed.delete(element);
}
disconnect() {
this.observed.clear();
}
takeRecords() {
return [];
}
}
Object.defineProperty(window, 'IntersectionObserver', {
configurable: true,
value: ResourceCardIntersectionObserver,
});
return {
triggerVisible(elements?: Element[]) {
const instance = instances.at(-1);
if (!instance) {
throw new Error('resource card IntersectionObserver was not created');
}
const targets = elements ?? Array.from(instance.observed);
instance.callback(
targets.map(
(target) =>
({
target,
isIntersecting: true,
intersectionRatio: 1,
}) as IntersectionObserverEntry,
),
instance.observer,
);
},
observedCount() {
return instances.at(-1)?.observed.size ?? 0;
},
};
}
/**
* 四张卡都是 `raster-image` 预览分支,只有**头部 alpha 判据**不同:
* - `alpha.png`:真有 alpha 通道(PNG colorType 6)⇒ `hasAlpha: true`;
* - `painted.png`:AI 把棋盘格画进像素里的不透明 PNG(colorType 2)⇒ `hasAlpha: false`;
* - `photo.jpg`:JPEG 恒不透明 ⇒ `hasAlpha: false`;
* - `unknown.png`:预览 payload 里**没有** `hasAlpha` 字段(旧原生构建 / 判据未透出)⇒ 必须与
* `false` 同样处理:不铺棋盘格。这条把「缺属性」与「显式 false」钉成同一档,避免出现第三种中间态。
*/
const ALPHA_PATHS = new Set(['assets/alpha.png']);
const KNOWN_ALPHA_PATHS = new Set([
'assets/alpha.png',
'assets/painted.png',
'assets/photo.jpg',
]);
const LABELS = [
'alpha.png',
'painted.png',
'photo.jpg',
'unknown.png',
] as const;
function createFixtureManifest(): GameCreationAppManifest {
const manifest = createGameCreationAppManifest(
PROJECT_ID,
'资源卡预览 alpha 底',
);
const characterAsset = (
id: string,
localPath: string,
mediaType: string,
) => ({
id,
kind: 'character',
category: 'character' as const,
mediaType,
localPath,
source: { kind: 'generated' as const },
});
manifest.assets = [
characterAsset('asset-alpha', 'assets/alpha.png', 'image/png'),
characterAsset('asset-painted', 'assets/painted.png', 'image/png'),
characterAsset('asset-photo', 'assets/photo.jpg', 'image/jpeg'),
characterAsset('asset-unknown', 'assets/unknown.png', 'image/png'),
];
return manifest;
}
function graphFor(manifest: GameCreationAppManifest) {
const resourceIds = manifest.assets.map((asset) => `asset:${asset.id}`);
return {
resourceIds,
referenceEdges: [],
taskFlows: [],
connectionIndex: resourceIds.map((resourceId) => ({
resourceId,
upstreamReferenceResourceIds: [],
downstreamReferenceResourceIds: [],
referenceEdgeIds: [],
taskFlowIds: [],
})),
producerAssignments: [],
dependencyDepths: resourceIds.map((resourceId) => ({
resourceId,
dependencyDepth: 0,
})),
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
producerMappingTruncated: false,
};
}
function installPreviewTauri(manifest: GameCreationAppManifest) {
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return graphFor(manifest);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: PROJECT_ID,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'list_pending_local_project_resource_edits') {
return [];
}
if (command === 'update_local_project_resource_canvas_layout') {
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: PROJECT_ID,
mode: args?.mode,
revision: 1,
positions: args?.positions,
updatedAt: 1,
},
};
}
if (command === 'read_local_project_image_preview') {
const relativePath = String(args?.relativePath ?? '');
const isJpeg = relativePath.endsWith('.jpg');
// 原生侧 `LocalProjectImagePreview.hasAlpha`:头部级判据,不做像素解码。
// `unknown.png` 刻意不带该字段:缺属性必须与显式 false 同档。
const hasAlpha = KNOWN_ALPHA_PATHS.has(relativePath)
? ALPHA_PATHS.has(relativePath)
: undefined;
return {
path: relativePath,
mediaType: isJpeg ? 'image/jpeg' : 'image/png',
byteLen: 1,
pixelWidth: 1,
pixelHeight: 1,
...(hasAlpha === undefined ? {} : { hasAlpha }),
dataUrl: isJpeg
? 'data:image/jpeg;base64,AA=='
: 'data:image/png;base64,AA==',
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke: invoke as never } };
return invoke;
}
async function renderedCards(manifest: GameCreationAppManifest) {
const observer = installResourceCardIntersectionObserver();
installPreviewTauri(manifest);
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: PROJECT_PATH,
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
chat: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
fireEvent.click(
await screen.findByRole('button', { name: '打开角色与对象' }),
);
// 四张卡都渲染出来之后再等注册:观察数就是「卡片真的挂上了预览管线」的证据。
for (const label of LABELS) {
await findResourceSelectButton(label);
}
await waitFor(() => expect(observer.observedCount()).toBe(4));
return observer;
}
function cardFor(label: string) {
const button = document.querySelector<HTMLElement>(
`.game-resource-card-select[aria-label*="${label}"]`,
);
const card = button?.closest<HTMLElement>('.game-resource-card');
expect(card, `卡片未渲染:${label}`).not.toBeNull();
return card!;
}
/**
* 用**真实 DOM 上的属性**拼出这个卡面元素会命中的选择器集合,再按 styles.css 的
* 真实层叠算最终生效声明。这样「卡片写了什么属性」与「CSS 认什么属性」之间没有中间假设。
*/
function visualDeclarationsForCard(card: HTMLElement) {
const rules = parseStyleSheet(
readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
'utf8',
),
);
const kind = card.getAttribute('data-preview-kind');
const hasAlpha = card.getAttribute('data-preview-has-alpha');
const selectors = ['.game-resource-card-visual'];
if (kind) {
selectors.push(
`.game-resource-card[data-preview-kind='${kind}'] .game-resource-card-visual`,
);
}
if (kind && hasAlpha) {
selectors.push(
`.game-resource-card[data-preview-kind='${kind}'][data-preview-has-alpha='${hasAlpha}'] .game-resource-card-visual`,
);
}
return resolveDeclarations(rules, selectors, 1280);
}
describe('资源卡棋盘格底按真实 alpha 决定', () => {
it('真透明 PNG 卡带 data-preview-has-alpha=true,不透明 PNG/JPEG 卡不带该属性', async () => {
const manifest = createFixtureManifest();
const observer = await renderedCards(manifest);
act(() => observer.triggerVisible());
await waitFor(() => {
for (const label of LABELS) {
expect(cardFor(label).getAttribute('data-preview-status')).toBe(
'loaded',
);
}
});
// 四张卡走的是同一个预览分支:只按 kind 判定的旧口径无法区分它们,
// 这正是本次缺陷的成因(所有 PNG/JPEG 卡一律铺棋盘格)。
expect(
LABELS.map((label) => cardFor(label).getAttribute('data-preview-kind')),
).toEqual(['raster-image', 'raster-image', 'raster-image', 'raster-image']);
expect(cardFor('alpha.png').getAttribute('data-preview-has-alpha')).toBe(
'true',
);
// 不透明卡不写属性(而不是写 'false'):缺属性与 'false' 在样式上必须等价。
for (const label of ['painted.png', 'photo.jpg', 'unknown.png']) {
expect(
cardFor(label).getAttribute('data-preview-has-alpha'),
`${label} 不得写 data-preview-has-alpha`,
).toBeNull();
}
});
it('真透明 PNG 卡面最终生效棋盘格底,假棋盘格的不透明 PNG/JPEG 卡退回纯色底', async () => {
const manifest = createFixtureManifest();
const observer = await renderedCards(manifest);
act(() => observer.triggerVisible());
await waitFor(() => {
expect(cardFor('alpha.png').getAttribute('data-preview-status')).toBe(
'loaded',
);
});
const alphaDeclarations = visualDeclarationsForCard(cardFor('alpha.png'));
expect(declaration(alphaDeclarations, 'background')).toContain(
CHECKERBOARD_LAYER,
);
expect(declaration(alphaDeclarations, 'background-size')).toBe(
CHECKERBOARD_BACKGROUND_SIZE,
);
for (const label of ['painted.png', 'photo.jpg', 'unknown.png']) {
const declarations = visualDeclarationsForCard(cardFor(label));
// 变异判据:把 CSS 的 alpha 条件去掉(退回只按 data-preview-kind),
// 这里会重新出现 `background-size: 16px 16px` ⇒ 变红。
expect(
declarations.has('background-size'),
`${label} 不该有棋盘格档位`,
).toBe(false);
expect(declaration(declarations, 'background')).toBe(
PLAIN_CARD_BACKGROUND,
);
}
});
it('透明图自身不被填底色:没有任何规则给 .game-resource-card-visual > img 声明背景', () => {
const rules = parseStyleSheet(
readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
'utf8',
),
);
const declarations = resolveDeclarations(
rules,
['.game-resource-card-visual > img'],
1280,
);
for (const property of [
'background',
'background-image',
'background-color',
'background-size',
]) {
expect(
declarations.has(property),
`.game-resource-card-visual > img 不得声明 ${property}`,
).toBe(false);
}
});
it('media-image / video 目前没有头部 alpha 判据,因此退回纯色底而不是棋盘格', () => {
// 这是本次刻意收窄的行为:拿不到真实 alpha 时宁可不铺棋盘格,
// 也不要用装饰性棋盘格继续冒充「透明底」。将来给这些格式补上头部判据时,
// 这条用例必须同步改成「有判据才铺」。
const rules = parseStyleSheet(
readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
'utf8',
),
);
for (const kind of ['media-image', 'video']) {
const declarations = resolveDeclarations(
rules,
[
'.game-resource-card-visual',
`.game-resource-card[data-preview-kind='${kind}'] .game-resource-card-visual`,
],
1280,
);
expect(
declarations.has('background-size'),
`${kind} 无 alpha 判据时不得铺棋盘格`,
).toBe(false);
expect(declaration(declarations, 'background')).toBe(
PLAIN_CARD_BACKGROUND,
);
}
});
});