Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCardPreviewAlphaBackground.test.tsx
T
k88936 6981648796
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m56s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m48s
Project CI / Native shell tests (pull_request) Failing after 45s
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / Frontend tests (pull_request) Failing after 1m52s
Project CI / AI game creator shell web tests (pull_request) Failing after 1m42s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m57s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 8m1s
合并 origin/master:Supervisor 永久退役,项目对话收敛为 DirectProject 与 Design Agent
- 解决 refactor/split-direct-project 与 origin/master 在 App.tsx、立项策划聊天视图、Direct composer/引用输入区、styles.css、Rust direct user item 与 appSurface 用例上的冲突,按「Supervisor 永久退役」口径保留 DirectProject 独立聊天容器与 Design Agent 两条产品路径
- 采纳 master 的策划 Agent V1/V2 退役:删除 GDD 审批卡、策划输入卡、planningLane、planningSessionV2、planningSessionContract、规划展示适配与 Rust planning_*_v2 命令、模块、契约及对应用例,不保留兼容别名或双跑路径
- 把 master「折叠思考显示单行预览」的目的落到当前结构:新增共享表现 chat/components/AgentReasoning/AgentReasoning.tsx(折叠态单行纯文本预览 + 箭头、展开态安全 Markdown),DirectProject 回合与策划回合共用,删掉两处写死的 pre 折叠实现
- 把 master「策划入口可选模型 / 推理档」的目的接到当前策划输入盒:复用 ConversationModelSelect 与 ComposerReasoningEffortSelect,配置写回仍走客户端配置通道
- App.tsx 删除只服务退役 Supervisor / 策划 V2 的 state、ref、effect、回调与死参数,并删除两条读路径都退役后的 workspaceProjectKind;openWorkspace 的工程类型入参保留为未使用契约
- Rust 侧保留本分支 canonical→wire 投影、无审计 Direct 回合与 direct user item 严格校验,并入 master 的 prepare_new_web_project_at 前置复核
- 更新 ADR 与 shared-memory 决策记录:策划当前只有 Design Agent、两条路径的共享表现清单,以及本次合并的口径、代价与验证证据
- 验证:AGC 与仓库 typecheck、check:encoding、check:doc-index、git diff --check、改动文件 eslint 0 error;AGC vitest 168 个文件中除 5 个 jsdom localStorage 环境失败文件与本分支既有 resourceTagStatsRefresh 失败外全绿,appSurface 198 passed / 13 skipped;Rust 定向用例 direct_codex_user_item、skill_pack、sessions 全过(整套分片在本容器受 /sbin -> usr/bin 触发沙箱预检失败,与本合并无关)
2026-09-21 21:09:34 +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,
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(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,
);
}
});
});