Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCardPreviewAlphaBackground.test.tsx
T
suzmii 2a2e4c1cea 按 review 修:动画在途拦截、样式守卫扩到三面板、窄屏差异显式放行、用例不再依赖 cwd
- 在途拦截补齐生成动画:提交记录的「属于哪张资源」从 draftKey 拆成 resourcePath,
  `resourceCanvasResourceEditSubmissionBlocksResource` 被快速编辑与生成动画共用;
  此前动画这条键对不上、拦不住,重开面板再提交会复用同一 operationId 卡在原生锁上,
  或(改了提示词)直接多出一笔付费生成
- 样式缺口守卫扩到三块面板(动画面板 / 快速编辑面板 / 生图面板),判据改为按
  「选择器分支 + 声明」逐条比对;两个面板的实测缺口一并照抄补上(快速编辑面板的
  `overflow: auto → visible` 与窄屏底栏两条按宿主差异显式登记,不静默漂移)
- 窄屏只搬动画面板那一支:网页端 ≤760px 会把快速编辑浮层也变成整宽底栏,AGC 的
  快速编辑是「居中贴资源卡」的绝对定位,跟随它会压住栏目工具栏与任务侧栏
- 用例路径不再依赖 cwd:新增 tests/repoPath.ts(按 import.meta.url 反推仓库根),
  19 个用例文件的 `resolve(process.cwd(), …)` 统一改用它;顺手修掉
  `HOST_STYLE_SHEETS.filter(existsSync)` 把仓库相对路径交给 cwd 解析的假红
- 清掉两处死代码:不可达的 `quickEditPanel.status === 'generating'` 判据与
  未被使用的 prune 导出
2026-09-21 01:23:35 +08:00

432 lines
14 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,
screen,
vi,
waitFor,
} from './appSurface/harness';
import { repoPath } from './repoPath';
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(repoPath('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(
repoPath('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(
repoPath('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,
);
}
});
});