Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCardPreviewAlphaBackground.test.tsx
suzmii 181e364d80 AGC 资源卡棋盘格底改按图像真实 alpha 判定
- 原生预览新增头部级 alpha 判据:`LocalProjectImagePreview.hasAlpha`(PNG colorType 4/6 或 tRNS、WebP VP8X/VP8L alpha 标志;JPEG 恒 false),只读签名与 chunk 头、不做像素解码。
- 前端预览 payload 增加可选 `hasAlpha`,并在 `materializeProjectResourceCardPreview` 的两条分支里透传。
- 资源卡根节点新增排障属性 `data-preview-has-alpha`:只有预览已加载且判据为真才写 `'true'`,其余一律不写(缺属性与不透明同档)。
- 棋盘格底选择器加 `[data-preview-has-alpha='true']` 条件;无 alpha 时退回卡片既有纯色底,不引入第二套底色。
- 新增 TS 用例(真渲染资源卡 + styles.css 声明级层叠求值)与 Rust 用例(PNG/WebP/JPEG 头部判据、tRNS、坏文件失败关闭、非解码证明、IPC 字段序列化契约)。
- 验收文档补记 `data-preview-has-alpha` 判据与「真假棋盘格」取证脚本,便于现场区分真透明底与 AI 画出的假棋盘格。
2026-09-14 17:13:43 +08:00

435 lines
15 KiB
TypeScript
Raw Permalink 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 把棋盘格画进像素里的不透明 PNGcolorType 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,
supervisor: 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,
);
}
});
});