素材类型选项改成纵向单选列表,不再横排成一条
- 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:
@@ -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 tabindex:Tab 进组只停一次,停在选中项上。
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user