素材类型选项改成纵向单选列表,不再横排成一条

- ResourceTypePanel:选项区从 PlatformSegmentedTabs(3~6 列网格,6 项正好挤成一行)换成纵向单选列表,复用共享的 PlatformNavigableListItem 作行骨架
- ResourceTypePanel:容器改为 role="radiogroup" + aria-label,每项 role="radio" + aria-checked,选中态与读屏读的是同一个属性
- ResourceTypePanel:roving tabindex(只有选中项 tabIndex=0)与方向键在选项间移动焦点,走到头不越界;方向键不顺手选中,Enter/Space 才落盘(选中=一次 CAS 写盘,浏览选项不该连环写盘)
- ResourceTypePanel:选中行额外渲染勾选图标作为第二视觉线索,图标 aria-hidden 不污染可读名字
- resourceTypePanel.css:选项容器改成单列网格(grid-template-columns: minmax(0, 1fr) + grid-auto-flow: row + align-content: start),一行一个选项
- resourceTypePanel.css:选项行补 width/min-width/min-height: 44px 与 overflow-wrap,移动端热区与窄屏换行都不依赖共享件的工具类
- resourceTypePanel.css:选中态由 [aria-checked='true'] 驱动并显式提权到 (0,3,0) 以上(含覆盖 :hover:not(:disabled)),避免被共享列表行的悬停底色顶掉
- resourceTypePanel.css:滚动从 body 挪到选项列表(列表现有 max-height: min(320px, 40dvh) + overflow-y: auto),body 改为 auto/minmax(0,1fr)/auto 三段,标题、素材名与错误提示不跟着滚
- 测试:新增「选项区是纵向单列列表」用例,按 styleCascade 解析真实生效声明(360/390/1440 三档宽度都是单列、行满宽、热区 44px),并断言 6 项都是 radiogroup 的直接子元素
- 测试:新增「单选语义与键盘」用例(radiogroup/radio + aria-checked + roving tabindex,方向键只移焦点不落盘,Enter 才写盘)
- 测试:新增「选中态由 aria-checked 驱动并压过共享列表行悬停底色」用例
- 测试:把「面板有高度上界且中间行可滚动」改成「选项区有上界并独立滚动,标题与错误提示不滚」
- 测试:集成用例的类型选项查询从 button/aria-pressed 改为 radio/aria-checked
This commit is contained in:
2026-09-14 18:16:56 +08:00
parent ef3e783ab1
commit 928a0ea13e
4 changed files with 412 additions and 69 deletions
@@ -1,12 +1,12 @@
/*
* 「设置素材类型」面板的弹窗骨架与信息浮层的类型入口。
* 「设置素材类型」面板的弹窗骨架、纵向单选列表与信息浮层的类型入口。
*
* 单独一个文件而不是塞进 styles.css:与「编辑素材标签」面板当初同样的理由 ——
* 这份样式只服务本次的素材类型入口,与工作台其它区块没有共享选择器,独立文件让改动
* 边界更清楚,也不会与同一时段其它 Agent 在 styles.css 里的编辑互相踩。
*
* 骨架沿用「编辑素材标签」那套三段式契约(`auto / minmax(0, 1fr)`):标题常驻、
* 中间一行可压缩可滚动、`max-height` 兜住上界。类型面板没有底部按钮,所以只有两行。
* 中间一行可压缩、`max-height` 兜住上界。类型面板没有底部按钮,所以只有两行。
*/
.game-resource-type-dialog {
width: min(480px, 100%);
@@ -15,16 +15,16 @@
}
/*
* 滚动落在 body 这一行:`min-height: 0` 是网格项能被 `1fr` 压缩的前提,
* 否则内容高度会顶回轨道、`overflow-y` 永远不触发。
* body 分三段:提示 / 选项列表 / 错误提示。
*
* `min-height: 0` 是网格项能被 `1fr` 压缩的前提;**滚动不在这里**——滚动权交给选项列表
* (见下),否则往下滚时素材名和错误提示会跟着跑掉,用户看不到"改的是哪件素材、为什么失败"。
*/
.game-resource-type-body {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 10px;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
/*
@@ -36,6 +36,67 @@
font-size: 12px;
}
/*
* 纵向单选列表(`role="radiogroup"`):**一行一个选项**。
*
* 之前 6 项横排在一条里(`PlatformSegmentedTabs` 的 3~6 列网格),窄屏上互相叠字读不出来。
* 单列网格 + 按行流向是"每项一行、互不重叠"的充分条件:只声明一列,6 个子元素必然上下排 6 行,
* 不存在两项挤一行的可能。选项多时列表自己滚(`max-height` + `overflow-y: auto`),
* 面板不会被撑高。移动端优先:360px 宽的窄屏同样是这一套声明(没有按宽度改列数的媒体查询)。
*/
.game-resource-type-options {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-auto-flow: row;
align-content: start;
gap: 6px;
min-height: 0;
max-height: min(320px, 40dvh);
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
/*
* 单个选项行:复用共享的 `PlatformNavigableListItem` 骨架(w-full / flex / text-left /
* 圆角 / 悬停 / 焦点环都由它给),这里只补"整行可点 + 明确选中态"的表现。
*
* `width/min-width` 显式写出来,不依赖共享件里的 Tailwind `w-full`:这一行是不是满宽
* 决定了"一项一行"能不能成立,不能挂在另一份文件的工具类上。
* `min-height: 44px` 是移动端点击热区下限;`overflow-wrap` 让长选项名在窄屏换行而不是溢出。
*/
.game-resource-type-option {
width: 100%;
min-width: 0;
min-height: 44px;
padding: 8px 12px;
border: 1px solid var(--platform-subpanel-border);
background: rgb(255 255 255 / 62%);
color: var(--platform-text-base);
font-size: 13px;
font-weight: 600;
overflow-wrap: anywhere;
}
.game-resource-type-option:hover:not(:disabled) {
border-color: var(--platform-surface-hover-border);
}
/*
* 选中态完全由 `aria-checked="true"` 驱动:视觉与读屏读的是同一个属性,不会各说一套。
*
* 选择器显式提权到 (0,3,0) 以上:共享列表行自带的 `.platform-navigable-list-item:hover:not(:disabled)`
* 也是 (0,3,0),只写 `.game-resource-type-option[aria-checked='true']`(0,2,0))会在悬停时
* 被它的底色顶掉;带 `:hover:not(:disabled)` 的那条 (0,5,0) 保证选中行悬停时也不变色。
*/
.game-resource-type-options .game-resource-type-option[aria-checked='true'],
.game-resource-type-options
.game-resource-type-option[aria-checked='true']:hover:not(:disabled) {
border-color: var(--platform-warm-border);
background: var(--platform-warm-bg);
color: var(--platform-text-strong);
}
.game-resource-type-error {
margin: 0;
color: #b3261e;
@@ -1,8 +1,13 @@
import '../../features/project-workspace/resourceTypePanel.css';
import { useState } from 'react';
import { Check } from 'lucide-react';
import {
type KeyboardEvent as ReactKeyboardEvent,
useRef,
useState,
} from 'react';
import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs';
import { PlatformNavigableListItem } from '../../../../../packages/shared/src/components/PlatformNavigableListItem';
import {
GAME_CREATION_APP_ASSET_CATEGORIES,
type GameCreationAppAssetCategory,
@@ -81,6 +86,36 @@ export function ResourceTypePanel({
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const activeCategory = pendingCategory ?? gameCreationAppAssetCategory(asset);
const optionsRef = useRef<HTMLDivElement | null>(null);
/**
* 单选组的键盘口径:Tab 进组只停一次(roving tabindex,见下面的 `tabIndex`);
* 方向键在选项之间移动**焦点**Enter/Space`<button>` 的原生行为)才落盘。
*
* 方向键刻意不顺手选中:这里的"选中"是一次 CAS 写盘动作,"浏览选项"不该变成连环写盘。
* 读屏仍能逐项读到"选项名 + 已选中/未选中"`aria-checked`),所以浏览时不丢上下文。
* 走到头不越界(不回卷):单选组里回卷会让焦点从最后一项跳回第一项,方向感丢失。
*/
function handleOptionKeyDown(
event: ReactKeyboardEvent<HTMLButtonElement>,
index: number,
) {
const step =
event.key === 'ArrowDown' || event.key === 'ArrowRight'
? 1
: event.key === 'ArrowUp' || event.key === 'ArrowLeft'
? -1
: 0;
if (step === 0) return;
event.preventDefault();
// 不在子组件上挂 ref(共享列表行不透传 ref),按住处从自己的容器里数。
const options =
optionsRef.current?.querySelectorAll<HTMLButtonElement>('[role="radio"]');
if (!options || options.length === 0) return;
const target =
options[Math.min(Math.max(index + step, 0), options.length - 1)];
target?.focus();
}
async function saveResourceType(
category: GameCreationAppAssetCategory,
@@ -154,21 +189,43 @@ export function ResourceTypePanel({
{/* 只说这一屏要选什么,不写规则说明或开发解释。 */}
<p className="game-resource-type-hint"></p>
{/*
纵向单选列表(`role="radiogroup"` + 每项 `role="radio"`):
一行一个选项,不再横排成一条 —— 6 项挤在一行时窄屏会互相叠字。
选中项就是这张卡当前所在的画布栏目;点任意一项即落盘(含点当前已选中的那一项:
用户显式确认归属,不做隐式 no-op)。
用户显式确认归属,不做隐式 no-op)。视觉选中态由 `aria-checked="true"` 驱动,
与读屏读到的状态是同一个属性。
*/}
<div role="group" aria-label="素材类型">
<PlatformSegmentedTabs
items={RESOURCE_TYPE_CATEGORY_OPTIONS}
activeId={activeCategory}
onChange={(category) => {
setPendingCategory(category);
void saveResourceType(category);
}}
columns="threeToSix"
gap="sm"
disabled={saving}
/>
<div
ref={optionsRef}
role="radiogroup"
aria-label="素材类型"
className="game-resource-type-options"
>
{RESOURCE_TYPE_CATEGORY_OPTIONS.map((option, index) => {
const active = option.id === activeCategory;
return (
<PlatformNavigableListItem
key={option.id}
role="radio"
aria-checked={active}
// roving tabindex:只有选中项进入 Tab 序列,Tab 进组只停一次。
tabIndex={active ? 0 : -1}
disabled={saving}
className="game-resource-type-option"
trailing={
active ? <Check size={14} aria-hidden="true" /> : null
}
onClick={() => {
setPendingCategory(option.id);
void saveResourceType(option.id);
}}
onKeyDown={(event) => handleOptionKeyDown(event, index)}
>
{option.label}
</PlatformNavigableListItem>
);
})}
</div>
{error ? (
<p className="game-resource-type-error" role="alert">
@@ -1959,13 +1959,13 @@ describe('project resource live canvas integration', () => {
name: '设置素材类型',
});
// 面板里的类型选择器显示的就是卡片当前所在栏目。
const categoryTab = within(dialog).getByRole('button', {
// 面板里的类型单选列表显示的就是卡片当前所在栏目(纵向列表:一行一个选项)
const categoryOption = within(dialog).getByRole('radio', {
name: '角色与对象',
});
expect(categoryTab.getAttribute('aria-pressed')).toBe('true');
expect(categoryOption.getAttribute('aria-checked')).toBe('true');
// 「选中即落盘」:这一次点击本身就是完整动作,不需要任何标签动作。
fireEvent.click(within(dialog).getByRole('button', { name: '场景与环境' }));
fireEvent.click(within(dialog).getByRole('radio', { name: '场景与环境' }));
await waitFor(() => {
expect(classificationWrites(invoke)).toHaveLength(1);
@@ -2135,11 +2135,11 @@ describe('project resource live canvas integration', () => {
const dialog = await screen.findByRole('dialog', { name: '设置素材类型' });
expect(
within(dialog)
.getByRole('button', { name: '角色与对象' })
.getAttribute('aria-pressed'),
.getByRole('radio', { name: '角色与对象' })
.getAttribute('aria-checked'),
).toBe('true');
fireEvent.click(within(dialog).getByRole('button', { name: '音频' }));
fireEvent.click(within(dialog).getByRole('radio', { name: '音频' }));
await waitFor(() => {
expect(classificationWrites(invoke)).toHaveLength(1);
@@ -14,6 +14,11 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp';
import { ResourceTypePanel } from '../src/view/project-development/ResourceTypePanel';
import {
declaration,
parseStyleSheet,
resolveDeclarations,
} from './styleCascade';
const asset: GameCreationAppAssetManifestEntry = {
id: 'asset-hero',
@@ -82,11 +87,29 @@ function classificationWrites(invoke: ReturnType<typeof vi.fn>) {
}
function typeTab(label: string) {
return screen.getByRole('button', { name: label });
return screen.getByRole('radio', { name: label });
}
function pressed(label: string) {
return typeTab(label).getAttribute('aria-pressed');
function checked(label: string) {
return typeTab(label).getAttribute('aria-checked');
}
/** 选项区(单选列表)容器。 */
function optionsList() {
return screen.getByRole('radiogroup', { name: '素材类型' });
}
/** 面板样式源文件:类型面板的布局声明都在这份 AGC 内独立文件里。 */
function panelStyleSheet() {
return parseStyleSheet(
readFileSync(
resolve(
process.cwd(),
'apps/ai-game-creator-shell/src/features/project-workspace/resourceTypePanel.css',
),
'utf8',
),
);
}
/** 一次写入的落地载荷(没有写入时返回 `null`)。 */
@@ -128,9 +151,10 @@ describe('ResourceTypePanel 设置素材类型', () => {
expect(screen.queryByText('assets/UI Assets/生成UI设计图.png')).toBeNull();
// 6 个合法分类,顺序就是画布栏目顺序,文案与栏目同源。
const tabs = within(document.querySelector('.platform-segmented-tabs')!);
expect(
tabs.getAllByRole('button').map((button) => button.textContent),
within(optionsList())
.getAllByRole('radio')
.map((option) => option.textContent),
).toEqual([
'UI 交互',
'角色与对象',
@@ -140,6 +164,9 @@ describe('ResourceTypePanel 设置素材类型', () => {
'待归类',
]);
// 旧的横排分段控件必须彻底消失:它是"6 项挤成一条、互相叠字"的来源。
expect(document.querySelector('.platform-segmented-tabs')).toBeNull();
// 标签不是这个面板的编辑对象:输入框与「添加」都不在这里。
expect(
screen.queryByPlaceholderText('新增标签,多个用逗号分隔'),
@@ -148,6 +175,179 @@ describe('ResourceTypePanel 设置素材类型', () => {
expect(screen.queryByRole('list', { name: '已有标签' })).toBeNull();
});
/**
* 选项区是**纵向单列列表**:一行一个选项,不是横排成一条。
*
* jsdom 没有排版引擎,所以"会不会叠字"不能靠量矩形;这里按本仓既有做法
* `resourceCardCurrentVersionStyle.test.ts` + `styleCascade.ts`)解析**真实生效的声明**:
* 容器是单列网格 + 按行流向,选项行满宽且不收缩 —— 三条一起才排得出"每项一行、互不重叠"。
* 同时做结构断言:6 项都是同一个 radiogroup 的直接子元素,没有中间包裹层能把两项并到一行。
*
* 变异验证:把容器改回 `display: flex` 横排(或去掉单列 `grid-template-columns`),
* 本用例必须失败。
*/
test('选项区是纵向单列列表:每项一行、满宽、6 项同为列表直接子元素', () => {
const rules = panelStyleSheet();
// 移动端窄屏与桌面宽屏都要是单列:这份布局不含任何按宽度改列数的媒体查询。
for (const viewport of [360, 390, 1440]) {
const list = resolveDeclarations(
rules,
['.game-resource-type-options'],
viewport,
);
expect(declaration(list, 'display')).toBe('grid');
// 只声明一列 ⇒ 6 个子元素必然上下排 6 行。
expect(declaration(list, 'grid-template-columns')).toBe('minmax(0, 1fr)');
expect(declaration(list, 'grid-auto-flow')).toBe('row');
expect(declaration(list, 'align-content')).toBe('start');
const option = resolveDeclarations(
rules,
['.game-resource-type-option'],
viewport,
);
expect(declaration(option, 'width')).toBe('100%');
expect(declaration(option, 'min-width')).toBe('0');
// 移动端点击热区不低于 44px。
expect(declaration(option, 'min-height')).toBe('44px');
}
installInvoke(async () => undefined);
renderPanel();
const list = optionsList();
const options = within(list).getAllByRole('radio');
expect(options).toHaveLength(6);
// 6 项直接挂在列表上:任何"两项包一层"的结构都会让 children 数不等于 6。
expect(list.children).toHaveLength(6);
for (const option of options) {
expect(option.parentElement).toBe(list);
// 全部可点(不是禁用态),且带完整的可读名字。
expect((option as HTMLButtonElement).disabled).toBe(false);
expect(option.textContent?.trim()).not.toBe('');
}
// 列表本身不再被任何横排容器包住。
expect(list.querySelector('.platform-segmented-tabs')).toBeNull();
});
/**
* 单选语义:`role="radiogroup"` + 每项 `role="radio"` + `aria-checked`。
*
* 选它的理由:这里的心智是"6 选 1"(选中项 = 卡片当前所在栏目),`aria-checked` 比分段按钮的
* `aria-pressed` 更贴;读屏进组先读组名「素材类型」,再逐项读"选项名 + 已选中/未选中"。
* 键盘按单选组口径:**Tab 进组只停一次**roving tabindex:只有选中项 `tabIndex=0`,其余 -1),
* 方向键在选项间移动焦点,Enter/Space(button 原生行为)才落盘。方向键刻意**不**顺手选中 ——
* 这里的"选中"是一次 CAS 写盘,"浏览选项"不该变成连环写盘。
*/
/**
* 选中态必须是"看得见"的:底色 / 边框 / 文字色都由 `aria-checked="true"` 驱动,
* 与读屏读到的状态是同一个属性,不会出现"视觉说选中、读屏说没有"。
*
* 提权也要钉住:共享的 `PlatformNavigableListItem` 自带
* `.platform-navigable-list-item:hover:not(:disabled)`(0,3,0))会给悬停行换底色,
* 选中态只写到 (0,2,0) 的话,鼠标划过选中行就会变色。所以这里要求选中态选择器
* 达到 (0,3,0) 以上,并额外覆盖 `:hover:not(:disabled)`。
*
* 变异验证:删掉选中态规则(或把它降回单一类选择器),本用例必须失败。
*/
test('选中态由 aria-checked 驱动,并压过共享列表行的悬停底色', () => {
const rules = panelStyleSheet();
// 选中行在 DOM 上命中的两条选择器(第二条必须真的存在于样式源里)。
const checked = resolveDeclarations(
rules,
[
'.game-resource-type-option',
".game-resource-type-options .game-resource-type-option[aria-checked='true']",
],
1440,
);
expect(declaration(checked, 'background')).toBe('var(--platform-warm-bg)');
expect(declaration(checked, 'border-color')).toBe(
'var(--platform-warm-border)',
);
expect(declaration(checked, 'color')).toBe('var(--platform-text-strong)');
// 未选中的行只有普通底色,不带选中态的暖色提权。
const idle = resolveDeclarations(
rules,
['.game-resource-type-option'],
1440,
);
expect(declaration(idle, 'background')).toBe('rgb(255 255 255 / 62%)');
const source = readFileSync(
resolve(
process.cwd(),
'apps/ai-game-creator-shell/src/features/project-workspace/resourceTypePanel.css',
),
'utf8',
);
expect(source).toContain(
".game-resource-type-options\n .game-resource-type-option[aria-checked='true']:hover:not(:disabled)",
);
// DOM 断言:选中行确实带着这个属性,且它带的是被上面那条规则命中的类。
installInvoke(async () => undefined);
renderPanel({ asset: selfHealingAsset });
const selected = typeTab('UI 交互');
expect(selected.getAttribute('aria-checked')).toBe('true');
expect(selected.classList.contains('game-resource-type-option')).toBe(true);
// 未选中行不显示选中标记(勾选图标只在选中行渲染)。
expect(selected.querySelector('svg')).not.toBeNull();
expect(typeTab('音频').querySelector('svg')).toBeNull();
});
test('单选语义与键盘:radiogroup/radio + aria-checked,方向键只移焦点不落盘', async () => {
const user = userEvent.setup();
const invoke = installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 7 };
}
return { asset: selfHealingAsset, committedProjectRevision: 8 };
});
renderPanel({ asset: selfHealingAsset });
const list = optionsList();
expect(list.getAttribute('role')).toBe('radiogroup');
const options = within(list).getAllByRole('radio');
expect(
options.map((option) => option.getAttribute('aria-checked')),
).toEqual(['true', 'false', 'false', 'false', 'false', 'false']);
// 只有一个选中项:单选语义下不允许出现两个为真的项。
expect(
options.filter(
(option) => option.getAttribute('aria-checked') === 'true',
),
).toHaveLength(1);
// roving tabindexTab 进组只停一次,停在选中项上。
expect(
options.map((option) => (option as HTMLButtonElement).tabIndex),
).toEqual([0, -1, -1, -1, -1, -1]);
const first = typeTab('UI 交互');
first.focus();
await user.keyboard('{ArrowDown}');
// 焦点走到下一项,但一次写入都没有发生。
expect(document.activeElement).toBe(typeTab('角色与对象'));
expect(classificationWrites(invoke)).toHaveLength(0);
// 移动焦点也不改选中态。
expect(checked('UI 交互')).toBe('true');
expect(checked('角色与对象')).toBe('false');
// 最后一项再按向下不越界。
typeTab('待归类').focus();
await user.keyboard('{ArrowDown}');
expect(document.activeElement).toBe(typeTab('待归类'));
// Enter(button 的原生行为)才落盘:载荷是焦点所在项。
await user.keyboard('{Enter}');
await waitFor(() => {
expect(classificationWrites(invoke)).toHaveLength(1);
});
expect(firstWriteInput(invoke)?.category).toBe('unclassified');
});
/**
* 选中项是**读显示口径**(与资源卡栏目、角标同一份读数):该资产落盘 `unclassified`
* 画布把它放在「UI 交互」栏,面板必须也显示「UI 交互」。
@@ -159,8 +359,8 @@ describe('ResourceTypePanel 设置素材类型', () => {
installInvoke(async () => undefined);
renderPanel({ asset: selfHealingAsset });
expect(pressed('UI 交互')).toBe('true');
expect(pressed('待归类')).toBe('false');
expect(checked('UI 交互')).toBe('true');
expect(checked('待归类')).toBe('false');
});
/**
@@ -182,7 +382,7 @@ describe('ResourceTypePanel 设置素材类型', () => {
const onClose = vi.fn();
renderPanel({ asset: selfHealingAsset, onSaved, onClose });
await user.click(screen.getByRole('button', { name: '场景与环境' }));
await user.click(typeTab('场景与环境'));
await waitFor(() => {
expect(classificationWrites(invoke)).toHaveLength(1);
@@ -216,7 +416,7 @@ describe('ResourceTypePanel 设置素材类型', () => {
});
renderPanel({ asset: { ...asset, tags: ['主角', '待定稿'] } });
await user.click(screen.getByRole('button', { name: '音频' }));
await user.click(typeTab('音频'));
await waitFor(() => {
expect(classificationWrites(invoke)).toHaveLength(1);
@@ -247,7 +447,7 @@ describe('ResourceTypePanel 设置素材类型', () => {
renderPanel({ asset: selfHealingAsset, onClose });
// 显示口径确实是 ui-interaction(没有被写成 unclassified 的现场)。
expect(pressed('UI 交互')).toBe('true');
expect(checked('UI 交互')).toBe('true');
await user.click(screen.getByRole('button', { name: '关闭设置素材类型' }));
@@ -272,7 +472,7 @@ describe('ResourceTypePanel 设置素材类型', () => {
});
renderPanel({ asset: selfHealingAsset });
await user.click(screen.getByRole('button', { name: '待归类' }));
await user.click(typeTab('待归类'));
await waitFor(() => {
expect(classificationWrites(invoke)).toHaveLength(1);
@@ -291,14 +491,14 @@ describe('ResourceTypePanel 设置素材类型', () => {
});
renderPanel({ asset: selfHealingAsset, onSaved });
await user.click(screen.getByRole('button', { name: '场景与环境' }));
await user.click(typeTab('场景与环境'));
await screen.findByRole('alert');
expect(screen.getByRole('alert').textContent).toContain('非法资源分类');
expect(onSaved).not.toHaveBeenCalled();
// 写入没成功:选中态不能被留在失败的选项上。
expect(pressed('UI 交互')).toBe('true');
expect(pressed('场景与环境')).toBe('false');
expect(checked('UI 交互')).toBe('true');
expect(checked('场景与环境')).toBe('false');
});
test('客户端外不写盘,只给提示', async () => {
@@ -306,7 +506,7 @@ describe('ResourceTypePanel 设置素材类型', () => {
const onSaved = vi.fn();
renderPanel({ asset: selfHealingAsset, onSaved });
await user.click(screen.getByRole('button', { name: '场景与环境' }));
await user.click(typeTab('场景与环境'));
await screen.findByRole('alert');
expect(screen.getByRole('alert').textContent).toContain('客户端');
@@ -336,7 +536,7 @@ describe('ResourceTypePanel 设置素材类型', () => {
const onSaved = vi.fn();
renderPanel({ asset: selfHealingAsset, onClose, onSaved });
await user.click(screen.getByRole('button', { name: '场景与环境' }));
await user.click(typeTab('场景与环境'));
const dialog = screen.getByRole('dialog', { name: '设置素材类型' });
await waitFor(() => {
@@ -380,32 +580,49 @@ describe('ResourceTypePanel 设置素材类型', () => {
});
/**
* 弹窗骨架(`max-height` + 可压缩可滚的中间行)不能只挂在「编辑素材标签」上:
* 类型面板的 6 个选项在窄屏上是 3 行,缺了上界同样会顶出视口。
* 选项区有界、独立滚动,标题 / 副标题 / 提示 / 错误提示都不滚。
*
* 6 个选项在窄屏上很高:滚动必须落在选项列表这一行,而不是整块 body —— 否则往下滚时
* 素材名和错误提示会跟着跑掉,用户看不到"改的是哪件素材、为什么失败"。
*
* 变异验证:把滚动从列表挪回 `.game-resource-type-body`(或在列表上去掉 `max-height`),
* 本用例必须失败。
*/
test('面板有高度上界且中间行可滚动', () => {
const styles = readFileSync(
resolve(
process.cwd(),
'apps/ai-game-creator-shell/src/features/project-workspace/resourceTypePanel.css',
),
'utf8',
);
const dialogRule = styles.match(
/\.game-resource-type-dialog\s*\{([^}]*)\}/s,
)?.[1];
expect(dialogRule).toBeDefined();
expect(dialogRule).toMatch(
/max-height:\s*min\(720px, calc\(100dvh - 40px\)\)/,
);
expect(dialogRule).toMatch(/grid-template-rows:\s*auto minmax\(0, 1fr\)/);
test('选项区有高度上界并独立滚动,标题与错误提示不滚', () => {
const rules = panelStyleSheet();
const bodyRule = styles.match(
/\.game-resource-type-body\s*\{([^}]*)\}/s,
)?.[1];
expect(bodyRule).toBeDefined();
expect(bodyRule).toMatch(/min-height:\s*0/);
expect(bodyRule).toMatch(/overflow-y:\s*auto/);
const dialog = resolveDeclarations(
rules,
['.game-approval-dialog', '.game-resource-type-dialog'],
1440,
);
// 上界:没有它,选项一多面板就长出视口(外层遮罩是不安全居中,全链没有可滚容器)。
expect(declaration(dialog, 'max-height')).toBe(
'min(720px, calc(100dvh - 40px))',
);
expect(declaration(dialog, 'grid-template-rows')).toBe(
'auto minmax(0, 1fr)',
);
const body = resolveDeclarations(rules, ['.game-resource-type-body'], 1440);
expect(declaration(body, 'min-height')).toBe('0');
// body 自己不是滚动容器:提示与错误提示跟着列表一起滚就白写了。
expect(body.get('overflow-y')).toBeUndefined();
// 三段轨道:提示 / 选项列表 / 错误提示,只有中间那行可压缩。
expect(declaration(body, 'grid-template-rows')).toBe(
'auto minmax(0, 1fr) auto',
);
const list = resolveDeclarations(
rules,
['.game-resource-type-options'],
1440,
);
expect(declaration(list, 'max-height')).toBe('min(320px, 40dvh)');
expect(declaration(list, 'min-height')).toBe('0');
expect(declaration(list, 'overflow-y')).toBe('auto');
expect(declaration(list, 'overscroll-behavior')).toBe('contain');
expect(declaration(list, 'scrollbar-gutter')).toBe('stable');
installInvoke(async () => undefined);
renderPanel();
@@ -418,5 +635,13 @@ describe('ResourceTypePanel 设置素材类型', () => {
: child.tagName.toLowerCase(),
),
).toEqual(['header', 'body']);
// 素材名(副标题)在 header 里、提示与列表在 body 里:都不在滚动容器内部。
const body_node = panel!.querySelector('.game-resource-type-body')!;
expect(body_node.querySelector('h2')).toBeNull();
const hint = body_node.querySelector('.game-resource-type-hint');
expect(hint).not.toBeNull();
expect(optionsList().contains(hint)).toBe(false);
expect(Array.from(body_node.children).indexOf(optionsList())).toBe(1);
});
});