修复 PR316 review:共享分段页签宽度下限作用域、标签契约与资源画布样式

- packages/shared/src/components/styles.css:528 [bug medium] min-width: max-content 收窄到新的 .platform-segmented-tabs--scroll 修饰类:grid 分支没有溢出容器、轨道是 minmax(0,1fr),同样的下限只会让内容画到相邻格上;grid 侧靠轨道等分与宿主 truncateLabels 收口,选中 PlatformSegmentedTabs.tsx 的 scroll 分支补修修饰类。
- packages/shared/src/components/PlatformResourceFilterBar.test.tsx 同步改成钉「下限只挂在 --scroll」+「grid 分支不带 --scroll/overflow-x-auto」(声明级断言,jsdom 不计算外部样式表,属弱验证,真机判据见提交说明)。
- packages/shared/src/components/PlatformResourceFilterBar.tsx:24 [maintainability low] Props 改为以 tagItems 为判别项的联合类型:给 tagItems 就必须给 onToggleTag,非法状态编译期不可表达;组件内部再兜 disabled={!onToggleTag} 供给不带类型检查的调用方,并补对应运行时用例。
- packages/shared/src/components/PlatformResourceFilterBar.tsx:107 [style low] 标签行补 scrollbar-hide:它是唯一还画滚动条的横向滚动容器,与 styles.css 里 .scrollbar-hide 的注释口径对齐;补类名与声明级用例。
- packages/shared/src/contracts/gameCreationAppAssetTagLibrary.ts:51 [maintainability low] assetIds 排序改为确定性 code-unit sort(),不再依赖运行环境默认 locale(标签那一层仍显式钉 zh-CN),补跨输入顺序可复现的用例。
- packages/shared/src/contracts/gameCreationAppAssetTagLibrary.ts:68 [bug low] assetTagsMatchSelection 两侧对称归一化:selectedTags 也过 normalizeGameCreationAppAssetTags,' 像素风 ' 这类未归一化的选中项不再静默匹配不上。
- packages/shared/src/contracts/gameCreationApp.test.ts [test low] 新增解析 Rust PROJECT_RESOURCE_CANVAS_SECTIONS 与 TS 常量对齐的交叉校验(仿 assetKindCanonicalMapping.test.ts 解析 Rust 常量先例),钉住两侧分区顺序不漂移。
- packages/shared/src/contracts/gameCreationApp.ts 同步「读显示 / 写回」口径注释:未知 category 已在 Rust 读侧失败关闭,「非法值 → 派生」只剩内存对象语义。
- apps/ai-game-creator-shell/src/styles.css:5790 [bug medium] :has() 悬停高亮分类值换成现行 7 个分区(ui-interaction/character/scene/audio/document/unclassified/version),删掉永不渲染的 code/art 死选择器,补齐缺失栏目的高亮。
- apps/ai-game-creator-shell/src/styles.css:6873 [bug medium] hover/focus-within/is-selected 由只写 border-color 改为完整 border: 1px solid #d57b51(基规则 border: 0 下 border-color 无效果,选中/焦点态此前完全不可见),box-sizing: border-box 下不改卡片尺寸。
- apps/ai-game-creator-shell/src/styles.css:10151 [bug medium] 去掉高优先级 :focus-within 规则里的 box-shadow: none:它盖掉了 --platform-input-focus-ring 焦点光环,direct-codex 输入区此前没有任何可见焦点(无障碍回归),现在光环生效。
- apps/ai-game-creator-shell/src/styles.css:10198 [bug low] 润色状态文字与操作排改为同一行分列(grid-row: 2 + grid-column: 1 / 2),不再整行独占或与按钮重叠,状态出现/消失不改变输入框高度。
- apps/ai-game-creator-shell/src/styles.css:10206 [maintainability low] 删除永不命中的 .resource-reference-input-at direct-codex 规则(showTriggerButton={!directCodex} 不渲染该按钮),保留说明指向真正的 .project-supervisor-reference-trigger。
- 附带 prettier 对 .design-agent-reasoning summary 单行规则的换行归一(该文件本来未过 prettier --check)。
This commit is contained in:
2026-09-12 19:43:58 +08:00
parent 9835bbec9f
commit 8b85977828
9 changed files with 253 additions and 75 deletions
+58 -46
View File
@@ -5768,31 +5768,45 @@ iframe.preview-frame {
transition: none;
}
/* 缩略卡悬停 / 聚焦时高亮它所在栏目(那一摞)的标题条。
取值必须与 `PROJECT_RESOURCE_CANVAS_SECTIONS`7 个现行分区)逐一对齐:
`code` / `art` 是旧四、五栏目的历史值,总览里不会渲染,写它们等于空规则;
少写一个现行分区,对应栏目就完全没有悬停高亮。 */
.game-resource-book-manager--main:has(
.game-resource-book-thumbnail[data-resource-book-category='code']:hover,
.game-resource-book-thumbnail[data-resource-book-category='code']:focus-visible
.game-resource-book-thumbnail[data-resource-book-category='ui-interaction']:hover,
.game-resource-book-thumbnail[data-resource-book-category='ui-interaction']:focus-visible
)
.game-resource-book-scene-titlebar[data-resource-book-category='code'],
.game-resource-book-scene-titlebar[data-resource-book-category='ui-interaction'],
.game-resource-book-manager--main:has(
.game-resource-book-thumbnail[data-resource-book-category='character']:hover,
.game-resource-book-thumbnail[data-resource-book-category='character']:focus-visible
)
.game-resource-book-scene-titlebar[data-resource-book-category='character'],
.game-resource-book-manager--main:has(
.game-resource-book-thumbnail[data-resource-book-category='scene']:hover,
.game-resource-book-thumbnail[data-resource-book-category='scene']:focus-visible
)
.game-resource-book-scene-titlebar[data-resource-book-category='scene'],
.game-resource-book-manager--main:has(
.game-resource-book-thumbnail[data-resource-book-category='audio']:hover,
.game-resource-book-thumbnail[data-resource-book-category='audio']:focus-visible
)
.game-resource-book-scene-titlebar[data-resource-book-category='audio'],
.game-resource-book-manager--main:has(
.game-resource-book-thumbnail[data-resource-book-category='document']:hover,
.game-resource-book-thumbnail[data-resource-book-category='document']:focus-visible
)
.game-resource-book-scene-titlebar[data-resource-book-category='document'],
.game-resource-book-manager--main:has(
.game-resource-book-thumbnail[data-resource-book-category='unclassified']:hover,
.game-resource-book-thumbnail[data-resource-book-category='unclassified']:focus-visible
)
.game-resource-book-scene-titlebar[data-resource-book-category='unclassified'],
.game-resource-book-manager--main:has(
.game-resource-book-thumbnail[data-resource-book-category='version']:hover,
.game-resource-book-thumbnail[data-resource-book-category='version']:focus-visible
)
.game-resource-book-scene-titlebar[data-resource-book-category='version'],
.game-resource-book-manager--main:has(
.game-resource-book-thumbnail[data-resource-book-category='art']:hover,
.game-resource-book-thumbnail[data-resource-book-category='art']:focus-visible
)
.game-resource-book-scene-titlebar[data-resource-book-category='art'],
.game-resource-book-manager--main:has(
.game-resource-book-thumbnail[data-resource-book-category='audio']:hover,
.game-resource-book-thumbnail[data-resource-book-category='audio']:focus-visible
)
.game-resource-book-scene-titlebar[data-resource-book-category='audio'] {
.game-resource-book-scene-titlebar[data-resource-book-category='version'] {
border-top-color: #cc8060;
border-right-color: #cc8060;
border-bottom-color: #ebd9cf;
@@ -6902,10 +6916,13 @@ iframe.preview-frame {
0 0 0 2px rgb(216 115 66 / 24%);
}
/* 卡片本体是 `border: 0`(下面那条基规则)。`border-color` 单独写没有意义——0 宽的边框
画不出颜色,所以选中 / 悬停 / 聚焦都必须写成完整的 `border`,否则这三个状态在视觉上
完全看不出来。宽度与圆角保持 1px / 12px`box-sizing: border-box` 下不会改变卡片尺寸。 */
.game-resource-card:hover,
.game-resource-card:focus-within,
.game-resource-card.is-selected {
border-color: #d57b51;
border: 1px solid #d57b51;
outline: 0;
box-shadow: 0 8px 22px rgb(195 105 62 / 15%);
}
@@ -10418,13 +10435,18 @@ button.design-workspace-tree__entry:hover,
box-shadow: none;
}
/* direct-codex 输入区去默认灰描边:焦点可见性交给下面那条
`.project-supervisor-composer .resource-reference-input:focus-within`
`--platform-input-focus-ring` 光环)。这条规则比它多一个 `is-direct-codex` 类,
优先级更高,所以**不能写 `box-shadow: none`**——写回去会把那条光环整条盖掉,
而 base `.resource-reference-input-editor` 又是 `outline: 0`,输入区就完全没有
可见焦点了(无障碍回归)。 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.resource-reference-input:focus-within {
border: 0;
outline: none;
box-shadow: none;
}
.game-workbench-chat
@@ -10704,18 +10726,19 @@ button.design-workspace-tree__entry:hover,
line-height: 1.6;
}
/* 陶泥儿输入区的操作排(`@` / AI 润色 / 恢复原文)回到文档流:`grid-column: 1 / -1`
让它独占编辑器下面的一行,`align-items: end` + `justify-content: flex-end` 把它
贴到输入框右下角。之前这里用 `position: absolute; right: 48px; bottom: 10px`
把整排从网格里摘出来浮在输入框中间,AI 润色因此看起来压在文本区里、和下面那排
主操作(`.project-supervisor-composer-controls` 的 `@` / 快速 / 发送)脱节。
/* 陶泥儿输入区的操作排(`AI 润色` / `恢复原文`)回到文档流:网格第二行右侧那一列,
`justify-content: flex-end` 贴住输入框右下角。之前这里用
`position: absolute; right: 48px; bottom: 10px` 把整排从网格里摘出来浮在输入框中间,
AI 润色因此看起来压在文本区里、和下面那排主操作(`.project-supervisor-composer-controls`
的 `@` / 快速 / 发送)脱节。
不设 `min-height`:这一行的高度由 28px 方钮自己撑开,行高一旦被顶起来会连带把
编辑器的 `min-height: 96px` 改掉。 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer
.resource-reference-input-actions {
grid-column: 1 / -1;
grid-row: 2;
grid-column: 2;
position: static;
display: flex;
align-items: end;
@@ -10724,46 +10747,35 @@ button.design-workspace-tree__entry:hover,
height: auto;
}
/* 「润色中… / 润色失败」和应用排同一行:状态文字靠左占剩余宽度,操作排靠右。
单独占一行会把输入框整体顶高 22.8px,把操作排和下面那排主操作推开一个状态行的
距离;共行则状态出现/消失都不改变输入框高度。 */
/* 「润色中… / 润色失败」和应用排**同一行**:状态文字在第二行左侧那一列(占剩余宽度),
操作排在第二行右侧那一列。单独占一行会把输入框整体顶高一个状态行的距离;而给它
`grid-column: 1 / -1` 又会和操作排落进同一个网格单元互相重叠(状态文字长时压到按钮下面)。
共行分列则状态出现/消失都不改变输入框高度,也不会互相压。 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer
.resource-reference-input-status {
grid-column: 1 / -1;
grid-row: 2;
grid-column: 1;
align-self: center;
min-width: 0;
margin-top: 0;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer
.resource-reference-input-at {
position: static !important;
right: auto !important;
bottom: auto !important;
display: grid;
width: 30px;
min-width: 30px;
height: 30px;
min-height: 30px;
padding: 0;
border: 0;
border-radius: 9px;
background: var(--platform-button-primary-fill);
color: var(--platform-button-primary-text);
place-items: center;
}
/* direct-codex 里 `ResourceReferenceInput` 收到的是 `showTriggerButton={!directCodex}`
即输入框内的 `.resource-reference-input-at` 根本不会渲染(`@` 触发钮是下面控制排里的
`.project-supervisor-reference-trigger`)。这里原本有一条针对它的
`position: static !important` / 30px 主色方块规则,永远匹配不到,已删除;
要改 direct-codex 的 `@` 触发钮外观请改 `.project-supervisor-reference-trigger`。 */
.design-agent-reasoning {
margin: 8px 0;
color: var(--text-muted);
font-size: 0.82em;
}
.design-agent-reasoning summary { cursor: pointer; }
.design-agent-reasoning summary {
cursor: pointer;
}
.design-agent-reasoning pre {
margin: 6px 0 0;
white-space: pre-wrap;
@@ -6,7 +6,10 @@ import { resolve } from 'node:path';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';
import { PlatformResourceFilterBar } from './PlatformResourceFilterBar';
import {
PlatformResourceFilterBar,
type PlatformResourceFilterBarProps,
} from './PlatformResourceFilterBar';
import { PlatformSegmentedTabs } from './PlatformSegmentedTabs';
const CATEGORY_ITEMS = [
@@ -15,9 +18,10 @@ const CATEGORY_ITEMS = [
];
/**
* 分段页签子项宽度下限是「不叠字」的唯一判据:grid 的轨道按容器等分、scroll 的
* flex 子项默认可收缩,两者都会把中文标签压到内容宽度以下,标签虽然 nowrap,
* 文字仍会画出自己的盒子压到相邻上。这条规则一旦被删掉就要失败。
* 分段页签子项宽度下限只属于 `layout="scroll"`:那一条有 `overflow-x-auto` 做兜底,
* 宽度下限换来的是「不叠字」。grid 布局没有溢出容器、轨道是 `minmax(0, 1fr)`,同样的
* `min-width: max-content` 只会把内容画到相邻上。这两条声明级断言(jsdom 不计算外部
* 样式表,可见性只能钉在声明与类名上)钉住「下限只挂在 --scroll 修饰类」。
*/
function segmentedTabsStylesheet() {
return readFileSync(
@@ -110,6 +114,50 @@ describe('PlatformResourceFilterBar', () => {
expect(screen.getByRole('button', { name: '全部' })).toBeTruthy();
});
test('标签行是横向滚动容器且不画滚动条', () => {
render(
<PlatformResourceFilterBar
ariaLabel="素材筛选"
categoryItems={CATEGORY_ITEMS}
activeCategoryId="all"
onCategoryChange={() => {}}
tagItems={[{ tag: '像素风', assetCount: 1 }]}
onToggleTag={() => {}}
/>,
);
const row = screen.getByRole('group', { name: '素材筛选标签' });
expect(row.className).toContain('platform-category-chip-scroll');
// `platform-category-chip-scroll` 只提供 `overflow-x: auto`;不配 `scrollbar-hide`
// 它就会成为唯一的「看得见滚动条」的横向滚动容器。
expect(row.className).toContain('scrollbar-hide');
expect(segmentedTabsStylesheet()).toMatch(
/\.scrollbar-hide\s*\{[^}]*scrollbar-width:\s*none/s,
);
});
test('没有 onToggleTag 时标签 chip 不得渲染成可点按钮', () => {
// 类型上 `tagItems` 必须带 `onToggleTag`(联合类型让这个非法状态不可表达);
// 这里刻意 `as unknown as` 绕过类型检查,模拟不带类型检查的调用方,
// 断言运行时兜底是「禁用」而不是「看起来可点、点了没反应」。
const props = {
ariaLabel: '素材筛选',
categoryItems: CATEGORY_ITEMS,
activeCategoryId: 'all',
onCategoryChange: () => {},
tagItems: [{ tag: '像素风', assetCount: 1 }],
activeTags: [],
} as unknown as PlatformResourceFilterBarProps<'all' | 'character'>;
render(<PlatformResourceFilterBar {...props} />);
const chip = screen.getByRole('button', {
name: /像素风/,
}) as HTMLButtonElement;
expect(chip.disabled).toBe(true);
expect(chip.getAttribute('aria-pressed')).toBe('false');
});
test('never lets a category chip shrink below its label width', () => {
const { container } = render(
<PlatformResourceFilterBar
@@ -123,6 +171,7 @@ describe('PlatformResourceFilterBar', () => {
// 分类条是 scroll 布局:容器横向可滚,标签不换行。
const row = container.querySelector('.platform-segmented-tabs');
expect(row).not.toBeNull();
expect(row?.className).toContain('platform-segmented-tabs--scroll');
expect(row?.className).toContain('overflow-x-auto');
for (const button of row?.querySelectorAll('button') ?? []) {
expect(button.className).toContain('whitespace-nowrap');
@@ -130,11 +179,15 @@ describe('PlatformResourceFilterBar', () => {
// 单靠 nowrap 不够:子项必须有不低于内容宽度的下限,否则文字会画出盒子压到相邻项。
expect(segmentedTabsStylesheet()).toMatch(
/\.platform-segmented-tabs--scroll\s*>\s*button\s*\{[^}]*min-width:\s*max-content/s,
);
// 反过来,没有溢出容器的 grid 布局不得吃这条下限(否则内容会压到相邻格上)。
expect(segmentedTabsStylesheet()).not.toMatch(
/\.platform-segmented-tabs\s*>\s*button\s*\{[^}]*min-width:\s*max-content/s,
);
});
test('grid 布局的分段页签同样带宽度下限钩子', () => {
test('grid 布局的分段页签不带 scroll 修饰类,靠轨道等分收口', () => {
render(
<PlatformSegmentedTabs
items={CATEGORY_ITEMS}
@@ -149,5 +202,8 @@ describe('PlatformResourceFilterBar', () => {
.closest('div');
expect(row?.className).toContain('platform-segmented-tabs');
expect(row?.className).toContain('grid-cols-2');
// grid 分支没有溢出容器,宽度下限一旦回到这里就会压到相邻格(本轮 review 的回归点)。
expect(row?.className).not.toContain('platform-segmented-tabs--scroll');
expect(row?.className).not.toContain('overflow-x-auto');
});
});
@@ -21,7 +21,7 @@ export type PlatformResourceFilterSearch = {
inputRef?: Ref<HTMLInputElement>;
};
export type PlatformResourceFilterBarProps<TId extends string = string> = {
type PlatformResourceFilterBarBaseProps<TId extends string = string> = {
/** 控件组无障碍名称,例如「资源筛选」。 */
ariaLabel: string;
/** 搜索行;宿主不需要搜索时省略即可,其余筛选照常渲染。 */
@@ -30,14 +30,29 @@ export type PlatformResourceFilterBarProps<TId extends string = string> = {
categoryItems: readonly PlatformResourceFilterOption<TId>[];
activeCategoryId: TId;
onCategoryChange: (id: TId) => void;
/** 派生标签库;为空时不渲染标签行。 */
tagItems?: readonly PlatformResourceTagOption[];
/** 已选标签(多选叠加)。 */
activeTags?: readonly string[];
onToggleTag?: (tag: string) => void;
className?: string;
};
/**
* `tagItems` 与 `onToggleTag` 是**成对的**:标签 chip 是带 `aria-pressed` 的按钮,
* 只给标签、不给处理函数就会渲染出一排「看起来可点、点了没反应」的控件。
* 这里用联合类型让这个非法状态在编译期就不可表达;`Component` 内部再兜一层
* `disabled`,供不带类型检查的调用方(JS / 动态构造的 props)也拿到诚实的行为。
*/
export type PlatformResourceFilterBarProps<TId extends string = string> =
| (PlatformResourceFilterBarBaseProps<TId> & {
tagItems?: undefined;
activeTags?: readonly string[];
onToggleTag?: undefined;
})
| (PlatformResourceFilterBarBaseProps<TId> & {
/** 派生标签库;为空数组时不渲染标签行。 */
tagItems: readonly PlatformResourceTagOption[];
/** 已选标签(多选叠加)。 */
activeTags?: readonly string[];
onToggleTag: (tag: string) => void;
});
function tagChipClassName(active: boolean) {
return [
'platform-category-chip gap-1.5 px-2.5 text-xs font-bold',
@@ -104,7 +119,7 @@ export function PlatformResourceFilterBar<TId extends string = string>({
/>
{tagOptions.length > 0 ? (
<div
className="platform-category-chip-scroll min-w-0 flex-1"
className="platform-category-chip-scroll scrollbar-hide min-w-0 flex-1"
role="group"
aria-label={`${ariaLabel}标签`}
>
@@ -116,6 +131,9 @@ export function PlatformResourceFilterBar<TId extends string = string>({
type="button"
aria-pressed={active}
className={tagChipClassName(active)}
// 类型上 `tagItems` 必然带 `onToggleTag`;这一层兜底是给不带类型检查的
// 调用方(JS / 动态构造的 props):没有处理函数就不该渲染成可点按钮。
disabled={!onToggleTag}
onClick={() => onToggleTag?.(option.tag)}
>
<Tag className="h-3 w-3" aria-hidden="true" />
@@ -193,7 +193,7 @@ export function PlatformSegmentedTabs<TId extends string = string>({
className={[
'platform-segmented-tabs',
layout === 'scroll'
? 'flex min-w-0 items-center overflow-x-auto scrollbar-hide'
? 'platform-segmented-tabs--scroll flex min-w-0 items-center overflow-x-auto scrollbar-hide'
: 'grid',
PLATFORM_SEGMENTED_TABS_FRAME_CLASS[frame],
PLATFORM_SEGMENTED_TABS_GAP_CLASS[gap],
+7 -4
View File
@@ -519,13 +519,16 @@
display: none;
}
/* 分段页签的子项不得被压到内容宽度以下。中文标签(例如「角色与对象」需要一个
/* 分段页签的**横滚**子项不得被压到内容宽度以下。中文标签(例如「角色与对象」需要一个
`1.5rem 内边距 + 4 个字`的宽度)在窄容器里一旦被压扁,标签虽然 `nowrap`,文字仍会
画出自己的盒子压到相邻项上——现场表现就是「后几项挤成一团、文字互相重叠」。
`min-width: max-content` 给每个子项一个「内容宽度」下限:放得下就照原样排,放不下
就由容器横向滚动兜底,任何情况下都不叠字。grid 与 scroll 两条 layout 都需要它:
grid 的轨道按容器等分,scroll 的 flex 子项默认可收缩,两者都会压到内容宽度以下。 */
.platform-segmented-tabs > button {
就由容器横向滚动兜底`layout="scroll"` 的容器有 `overflow-x-auto`,任何情况下都不叠字。
这里**只作用于 scroll 布局**:grid 布局没有溢出容器,轨道是 `minmax(0, 1fr)`
给子项加 `min-width: max-content` 只会让盒子宽过自己那条轨道、直接画到相邻格上,
正是本规则要防的重叠。grid 侧靠轨道等分 + 宿主的 `truncateLabels` 收口。 */
.platform-segmented-tabs--scroll > button {
min-width: max-content;
}
@@ -1,3 +1,6 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import {
@@ -23,6 +26,7 @@ import {
type GameCreationAppManifest,
normalizeGameCreationAppAssetCategory,
normalizeGameCreationAppAssetTags,
PROJECT_RESOURCE_CANVAS_SECTIONS,
selectGameCreationAppReadyTasks,
} from './gameCreationApp';
@@ -933,7 +937,7 @@ describe('AI 游戏创作 App 共享契约', () => {
* 「读显示」与「写回」是两个口径,只有写回口径等于落盘值。
*
* `gameCreationAppAssetCategory` 会把落盘 `unclassified` 自愈成 kind 派生值(读显示要正确),
* `gameCreationAppAssetPersistedCategory` 只做缺失 / 非法兜底(写回必须原样)。
* `gameCreationAppAssetPersistedCategory` 只做兜底、不套自愈(写回必须原样)。
* 「编辑标签」面板一旦用读显示口径回写,用户只改标签就会静默改分类。
*/
it('写回口径保留落盘 unclassified,读显示口径才自愈', () => {
@@ -941,23 +945,63 @@ describe('AI 游戏创作 App 共享契约', () => {
expect(gameCreationAppAssetCategory(uiAsset)).toBe('ui-interaction');
expect(gameCreationAppAssetPersistedCategory(uiAsset)).toBe('unclassified');
// 落盘值非法或缺失时两个口径都按 kind 派生(与 Rust 反序列化兜底一致)。
expect(
gameCreationAppAssetPersistedCategory({
kind: 'character',
category: 'future-category' as never,
}),
).toBe('character');
// 字段缺失时两个口径都按 kind 派生(与 Rust 反序列化的缺字段兜底一致)。
expect(
gameCreationAppAssetPersistedCategory({
kind: 'character',
category: undefined,
}),
).toBe('character');
// 非法值这一支只服务内存对象:Rust 读侧对未知 category 失败关闭,
// 比本客户端新的值根本读不出 manifest,不会以「派生值」的形式出现在 UI 里。
expect(
gameCreationAppAssetPersistedCategory({
kind: 'character',
category: 'future-category' as never,
}),
).toBe('character');
// 落盘值合法且非 unclassified 时两个口径一致。
expect(
gameCreationAppAssetPersistedCategory({ kind: 'ui', category: 'audio' }),
).toBe('audio');
});
/**
* 分区顺序必须跨语言一致:Rust 侧 doc 明写 `PROJECT_RESOURCE_CANVAS_SECTIONS`
* 「必须与前端 `PROJECT_RESOURCE_CANVAS_SECTIONS` 保持一致」,但 Rust 单测只钉住自己那份的
* 序列化顺序,谁也发现不了另一侧改了顺序。这里解析 Rust 源码里的常量再与 TS 常量对齐,
* 由 `apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts` 解析 Rust
* `EFFECTIVE_CATEGORY_CONTRACT` 的同一手法:两侧各写一份就一定会分叉。
*/
it('资源画布分区顺序与 Rust PROJECT_RESOURCE_CANVAS_SECTIONS 对齐', () => {
const rustSource = readFileSync(
resolve(
process.cwd(),
'server-rs/crates/shared-contracts/src/game_creation_app.rs',
),
'utf8',
);
const start = rustSource.indexOf(
'pub const PROJECT_RESOURCE_CANVAS_SECTIONS',
);
expect(start).toBeGreaterThan(-1);
const body = rustSource.slice(
start,
rustSource.indexOf('];', start) + '];'.length,
);
const rustSections = Array.from(
body.matchAll(/ProjectResourceCanvasSection::(\w+)/gu),
).map((match) =>
// Rust 变体名 → kebab-case 线上值(与 `#[serde(rename_all = "kebab-case")]` 同口径)。
match[1]!.replace(/([a-z0-9])([A-Z])/gu, '$1-$2').toLowerCase(),
);
expect(rustSections).toEqual([...PROJECT_RESOURCE_CANVAS_SECTIONS]);
// 顺带钉住「资产分类轴 + 末尾独立 version」这条分区口径本身没有被改掉。
expect(PROJECT_RESOURCE_CANVAS_SECTIONS).toEqual([
...GAME_CREATION_APP_ASSET_CATEGORIES,
'version',
]);
});
});
@@ -626,7 +626,10 @@ export function gameCreationAppAssetCategoryForKind(
* manifest 资产的最终功能分类。
*
* 优先级:落盘 `category`(用户可在「分类与标签」面板手动设置,是权威值)→ 按 `kind` 派生。
* 历史 manifest 缺少 category、或 category 非法时都退回 kind 派生
* 历史 manifest 缺少 category、或(内存对象上的)category 非法时都退回 kind 派生
* 注意「落盘值非法」这一支已经不可达:Rust 读侧对未知 `category` 失败关闭(见
* `server-rs/crates/shared-contracts/src/game_creation_app.rs` 的
* `GameCreationAppAssetManifestEntryWire`),比本客户端新的值会让整份 manifest 直接读不出来。
*
* **例外(唯一的覆盖窗口)**:落盘值是 `unclassified`,而该资产 `kind` 能派生出明确的
* 非 `unclassified` 分类时,采用派生值。这条规则用于自愈「历史上被系统错误写成
@@ -667,7 +670,9 @@ export function gameCreationAppAssetCategory(
* 静默改分类(真机已发生:同一条 `kind:"ui"` 资产出现 `unclassified` 与 `ui-interaction` 两种落盘值)。
*
* Rust 侧 manifest 反序列化(`game_creation_app.rs` 的 `GameCreationAppAssetManifestEntry`
* `Deserialize`用的是同一口径,因此本函数结果必然等于该资产当前的落盘 `category`。
* `Deserialize`在字段缺失时用同一套 `kind` 派生;未知分类值则在读侧失败关闭(不再退化成
* 派生值),所以从 manifest 读进来的资产本函数结果必然等于它的落盘 `category`。
* 剩下这一支「非法值 → 派生」只服务内存对象(手工构造的资产),不是落盘口径的兜底。
*/
export function gameCreationAppAssetPersistedCategory(
asset: Pick<GameCreationAppAssetManifestEntry, 'kind' | 'category'>,
@@ -49,6 +49,25 @@ describe('AI 游戏创作 App 标签库派生', () => {
expect(buildGameCreationAppAssetTagLibrary([])).toEqual([]);
});
it('sorts asset ids deterministically without a locale-dependent compare', () => {
// `localeCompare` 用运行环境默认 locale:同一份 manifest 在不同机器上可能排出不同顺序,
// 而这里承诺的是「与输入顺序无关的稳定升序」。素材 id 是 ASCIIcode-unit 顺序即可。
const library = buildGameCreationAppAssetTagLibrary([
{ id: 'asset-10', tags: ['像素风'] },
{ id: 'asset-9', tags: ['像素风'] },
{ id: 'asset-100', tags: ['像素风'] },
{ id: 'Asset-2', tags: ['像素风'] },
]);
expect(library).toEqual([
{
tag: '像素风',
assetCount: 4,
assetIds: ['Asset-2', 'asset-10', 'asset-100', 'asset-9'],
},
]);
});
it('requires every selected tag to match and treats an empty selection as no filter', () => {
const tags = ['像素风', '主角'];
@@ -62,4 +81,18 @@ describe('AI 游戏创作 App 标签库派生', () => {
false,
);
});
it('normalizes the selected tags the same way as the asset tags', () => {
// 对称归一化:素材侧 tags 会去空白,已选标签若原样比较,`' 像素风 '` 永远匹配不上。
const tags = [' 像素风 ', '主角'];
expect(assetTagsMatchSelection(tags, [' 像素风 '])).toBe(true);
expect(assetTagsMatchSelection(tags, ['像素风', ' 主角 '])).toBe(true);
// 归一化后为空的选中项等于「没有选中任何标签」→ 不过滤,与空选择同一条语义。
expect(assetTagsMatchSelection(tags, [' '])).toBe(true);
// 归一化不会把「去空白后仍然不同的标签」误判为命中。
expect(assetTagsMatchSelection(tags, ['像素风 '])).toBe(true);
expect(assetTagsMatchSelection(tags, [' 场景 '])).toBe(false);
expect(gameCreationAppAssetMatchesTags({ tags }, [' 像素风 '])).toBe(true);
});
});
@@ -48,7 +48,10 @@ export function buildGameCreationAppAssetTagLibrary(
return Array.from(byTag, ([tag, assetIds]) => ({
tag,
assetCount: assetIds.length,
assetIds: [...assetIds].sort((left, right) => left.localeCompare(right)),
// 确定性 code-unit 升序:`localeCompare` 的默认 locale 随环境变化,会让同一份 manifest
// 在不同机器上排出不同的 `assetIds`。素材 id 是 ASCIIUUID / 数字串 / `asset-1` 之类),
// code-unit 顺序就是稳定升序;标签那一层另有 `zh-CN` 显式口径,两者不要混用。
assetIds: [...assetIds].sort(),
})).sort(
(left, right) =>
right.assetCount - left.assetCount ||
@@ -59,6 +62,9 @@ export function buildGameCreationAppAssetTagLibrary(
/**
* 标签筛选判据:已选标签必须全部命中(AND),空选择视为不过滤。
* 与 `category` 筛选共存时由调用方叠加,本函数只看标签。
*
* 两侧都过 `normalizeGameCreationAppAssetTags`:素材侧 tags 会去空白,已选标签如果原样比较,
* `' 像素风 '` 这种带空白的选中项会永远匹配不上素材上已归一化的 `'像素风'`。
*/
export function assetTagsMatchSelection(
tags: readonly string[] | undefined,
@@ -66,7 +72,8 @@ export function assetTagsMatchSelection(
): boolean {
if (selectedTags.length === 0) return true;
const normalized = new Set(normalizeGameCreationAppAssetTags(tags ?? []));
return selectedTags.every((tag) => normalized.has(tag));
const selected = normalizeGameCreationAppAssetTags(selectedTags);
return selected.every((tag) => normalized.has(tag));
}
/** manifest 资产条目上的标签筛选,口径与 `assetTagsMatchSelection` 一致。 */