181e364d80
- 原生预览新增头部级 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 画出的假棋盘格。
435 lines
15 KiB
TypeScript
435 lines
15 KiB
TypeScript
// @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,
|
||
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,
|
||
);
|
||
}
|
||
});
|
||
});
|