删除资源画布左侧的栏目大纲导航:切栏目改由总览卡片、所有资源入口与下一页承担

- index.tsx:整条删除 `ResourceBookOutlineNav` 组件(`.game-resource-outline` / `aria-label="资源栏目大纲"` / 栏目图标 + 栏目文字 + 未读红点)以及栏目页与「所有资源」页两处挂载点;`ResourceBookAllResourcesPage` 不再接收只服务导航的 `unreadCategories` / `onOpen`。
- index.tsx:一并清掉只服务这条导航的「未读红点」状态链(`unreadResourceCategoryState`、`resourceCategorySnapshotRef`、`resourceIdsByCategory`、`viewedResourceCategory` 与对应 effect),不留死代码、不留墓碑注释。
- styles.css:删除全部 `.game-resource-outline*` 规则(悬浮 Dock、常态/悬停态、图标列宽、`.game-resource-outline-label`、`.game-resource-outline-unread`,共 106 行)。
- 保留未动:分区栏目本身、栏目标题栏、资源卡、总览缩略卡片、第 0 张「所有资源」入口、「所有资源」汇总页、搜索、总览、快速编辑与选中工具条。
- 测试:切栏目 helper(appSurface/project-development、appSurface/home、resourceRename、resourceVersionSwitch)改为走「资源总览」缩略卡片(需要时先点「收起资源」回总览),不再点导航;栏目顺序断言改钉在总览缩略卡片的 `aria-label` 序列上(第 0 张「所有资源」+ 固定七栏目),断言内容未放宽。
- 测试:按「四不写」删除只守这条导航的断言——大纲常态可读性的 CSS 声明用例、大纲条目图标/文字的 DOM 用例(该用例保留其中与搜索浮层相关的断言并改名)、未读红点用例(该用例保留"只登记游戏代码的项目照常分页并落在待归类"的部分并改名)、空项目与「所有资源」页里的导航存在性断言。
- 测试:新增反向守卫 + 切栏目链路用例(`has no outline nav and keeps every section reachable through the remaining entries`):断言 `资源栏目大纲` 标签、`.game-resource-outline` 类名与对应 `<nav>` 都不再渲染,并逐条走通总览缩略卡片(含空栏目)→ 下一页 → 标题栏「资源总览」→ 「所有资源」入口往返。
- 测试:`preserves independent art viewports across sort and workbench mode switches` 保留全部原断言,只去掉排序模式切换后那些"点当前栏目"的空操作往返(旧导航点在同栏目上是提前返回,改 helper 后变成真实往返,会在 jsdom 里触发重新 fit)。
This commit is contained in:
2026-09-11 13:54:12 +08:00
parent d1a3702062
commit 29b99f213d
7 changed files with 209 additions and 478 deletions
-106
View File
@@ -6416,112 +6416,6 @@ iframe.preview-frame {
height: 100%;
}
.game-resource-outline {
position: absolute;
top: 50%;
left: 12px;
z-index: 4;
display: flex;
flex-direction: column;
gap: 4px;
width: max-content;
max-width: 180px;
max-height: calc(100% - 24px);
padding: 6px;
overflow: auto;
/* 常态就给完整尺寸和 Dock 底板:只留 11px 文字、去掉图标和底色时,用户认不出
这是栏目入口。悬停/键盘聚焦仍在同一套 token 上加强(见下方 :hover 规则)。 */
border: 1px solid var(--platform-subpanel-border);
border-radius: 14px;
background: var(--platform-nav-fill);
box-shadow: 0 6px 18px rgb(112 70 52 / 12%);
opacity: 1;
transform: translateY(-50%);
transform-origin: left center;
transition:
border-color 160ms ease,
opacity 160ms ease,
box-shadow 160ms ease;
backdrop-filter: blur(6px);
}
.game-resource-outline:hover,
.game-resource-outline:has(> button:focus-visible) {
border-color: var(--platform-nav-active-border);
background: var(--platform-nav-fill);
box-shadow: var(--platform-nav-active-shadow);
opacity: 1;
transform: translateY(-50%);
backdrop-filter: blur(10px);
}
.game-resource-outline button {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
align-items: center;
gap: 6px;
min-height: 34px;
padding: 0 9px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--platform-nav-item-text);
cursor: pointer;
text-align: left;
transition:
background-color 160ms ease,
border-color 160ms ease;
}
/* 图标与栏目文字常态就在位(上面两条规则),悬停/键盘聚焦只加强 Dock 底板与当前项高亮。 */
.game-resource-outline button svg {
width: 14px;
min-width: 0;
opacity: 1;
transform: scale(1);
transition:
opacity 120ms ease,
transform 160ms ease;
}
.game-resource-outline button:hover,
.game-resource-outline button:focus-visible {
background: var(--platform-nav-item-hover-fill);
outline: 0;
}
.game-resource-outline button.is-active {
border-color: var(--platform-nav-active-border);
background: var(--platform-nav-active-fill);
color: var(--platform-nav-item-text-active);
box-shadow: var(--platform-nav-active-shadow);
}
.game-resource-outline button strong {
overflow: hidden;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-resource-outline button .game-resource-outline-label {
position: relative;
overflow: visible;
}
.game-resource-outline-unread {
position: absolute;
top: -2px;
right: -6px;
width: 6px;
height: 6px;
border: 1px solid var(--platform-nav-fill);
border-radius: 50%;
background: #e5484d;
box-shadow: 0 0 0 1px rgb(229 72 77 / 18%);
pointer-events: none;
}
.game-resource-page {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
@@ -165,7 +165,6 @@ import {
initialResourceBookState,
isResourceBookAllTarget,
RESOURCE_BOOK_ALL_TARGET,
type ResourceBookCategory,
resourceBookReducer,
type ResourceBookState,
type ResourceBookTarget,
@@ -1042,81 +1041,21 @@ function ResourceBookTitleBar({
);
}
/**
* 资源栏目大纲。栏目页与「所有资源」页共用同一条入口带:两页都能横向切到任一栏目,
* 只是「所有资源」页在最前面多一颗指向自己的入口。
*/
function ResourceBookOutlineNav({
categories,
activeCategory,
unreadCategories,
onOpen,
includeAllTarget = false,
}: {
categories: readonly ResourceBookCategory[];
activeCategory: ResourceBookTarget | null;
unreadCategories: ReadonlySet<ResourceBookCategory>;
onOpen: (category: ResourceBookTarget) => void;
includeAllTarget?: boolean;
}) {
const items: ResourceBookTarget[] = includeAllTarget
? [RESOURCE_BOOK_ALL_TARGET, ...categories]
: [...categories];
return (
<nav className="game-resource-outline" aria-label="资源栏目大纲">
{items.map((category) => {
const Icon = categoryIcons[category];
// 「所有资源」是汇总入口,不属于任何栏目,因此没有「有新资源」一说。
const hasUnreadResources =
!isResourceBookAllTarget(category) && unreadCategories.has(category);
return (
<button
key={category}
type="button"
aria-label={`${categoryLabels[category]}${
hasUnreadResources ? ',有新资源' : ''
}`}
aria-current={category === activeCategory ? 'page' : undefined}
className={category === activeCategory ? 'is-active' : undefined}
onClick={() => onOpen(category)}
>
<Icon size={14} aria-hidden="true" />
<strong className="game-resource-outline-label">
{categoryLabels[category]}
{hasUnreadResources ? (
<span
className="game-resource-outline-unread"
aria-hidden="true"
/>
) : null}
</strong>
</button>
);
})}
</nav>
);
}
/**
* 「所有资源」页:把每个非空栏目的资源卡摊在同一张滚动网格里。
*
* 复用既有分区机制的件事:栏目大纲(可横向切栏目)、栏目标题栏(图标 + 文案 + N 项
* 计数徽标)、以及栏目页同一颗资源卡片渲染器;差别只是这里一次铺开全部栏目,
* 卡片按格子排布而不是画布坐标。网格里的卡片是只读的(不接拖拽),
* 因为它们的坐标不属于任何栏目画布。
* 复用既有分区机制的件事:栏目标题栏(图标 + 文案 + N 项计数徽标)、以及栏目页同一颗
* 资源卡片渲染器;差别只是这里一次铺开全部栏目,卡片按格子排布而不是画布坐标。网格里的
* 卡片是只读的(不接拖拽),因为它们的坐标不属于任何栏目画布。
*/
function ResourceBookAllResourcesPage({
sections,
totalCount,
unreadCategories,
onOpen,
onCollapse,
renderCard,
}: {
sections: ResourceBookAllResourcesSection[];
totalCount: number;
unreadCategories: ReadonlySet<ResourceBookCategory>;
onOpen: (category: ResourceBookTarget) => void;
onCollapse: () => void;
renderCard: (
resource: ProjectResource,
@@ -1132,19 +1071,11 @@ function ResourceBookAllResourcesPage({
options?: { draggable?: boolean },
) => ReactNode;
}) {
const outlineCategories = sections.map((section) => section.category);
return (
<div
className="game-resource-page-shell"
data-resource-book-all-page="true"
>
<ResourceBookOutlineNav
categories={outlineCategories}
activeCategory={RESOURCE_BOOK_ALL_TARGET}
unreadCategories={unreadCategories}
onOpen={onOpen}
includeAllTarget
/>
<section
className="game-resource-page game-resource-all-page"
aria-label={categoryLabels[RESOURCE_BOOK_ALL_TARGET]}
@@ -1431,15 +1362,6 @@ export default function ProjectDevelopmentView({
);
const [activeResourceCategory, setActiveResourceCategory] =
useState<ResourceCategory | null>(null);
const [unreadResourceCategoryState, setUnreadResourceCategoryState] =
useState<{
scopeKey: string;
categories: Set<ResourceCategory>;
}>(() => ({ scopeKey: '', categories: new Set() }));
const resourceCategorySnapshotRef = useRef<{
scopeKey: string;
resourceIdsByCategory: Map<ResourceCategory, Set<string>>;
} | null>(null);
const [selectedResourceIds, setSelectedResourceIds] = useState<string[]>([]);
const [quickEditSourceLayer, setQuickEditSourceLayer] =
useState<CanvasLayer | null>(null);
@@ -2173,8 +2095,8 @@ export default function ProjectDevelopmentView({
}, [resources, selectedResourceId]);
const normalizedSearch = searchText.trim().toLowerCase();
/**
* 画布可见资源只由搜索框收窄:功能分类筛选条画布标签筛选已按用户要求移除,
* 分区栏目本身(`categoryOrder` / 栏目大纲 / 栏目标题栏)不受影响。
* 画布可见资源只由搜索框收窄:功能分类筛选条画布标签筛选与左侧栏目大纲导航
* 已按用户要求移除,分区栏目本身(`categoryOrder` / 栏目标题栏 / 总览缩略卡片)不受影响。
*/
const visibleResources = useMemo(
() =>
@@ -2220,20 +2142,6 @@ export default function ProjectDevelopmentView({
}),
[visibleResourcesByCategory],
);
const resourceIdsByCategory = useMemo(
() =>
new Map(
categoryOrder.map((category) => [
category,
new Set(
(projectResourcesByCategory.get(category) ?? []).map(
(resource) => resource.id,
),
),
]),
),
[projectResourcesByCategory],
);
const resourceCategoryScopeKey = useMemo(
() => JSON.stringify([projectPath, manifest.projectId]),
[manifest.projectId, projectPath],
@@ -2387,63 +2295,6 @@ export default function ProjectDevelopmentView({
resourceSearchRef.current?.focus();
}, [resourceSearchOpen]);
const viewedResourceCategory =
mode === 'resources' ? activePageCategory : null;
useEffect(() => {
const previousSnapshot = resourceCategorySnapshotRef.current;
if (
!previousSnapshot ||
previousSnapshot.scopeKey !== resourceCategoryScopeKey
) {
resourceCategorySnapshotRef.current = {
scopeKey: resourceCategoryScopeKey,
resourceIdsByCategory,
};
setUnreadResourceCategoryState({
scopeKey: resourceCategoryScopeKey,
categories: new Set(),
});
return;
}
const categoriesWithNewResources = categoryOrder.filter((category) => {
if (category === viewedResourceCategory) {
return false;
}
const previousIds =
previousSnapshot.resourceIdsByCategory.get(category) ?? new Set();
return Array.from(resourceIdsByCategory.get(category) ?? []).some(
(resourceId) => !previousIds.has(resourceId),
);
});
resourceCategorySnapshotRef.current = {
scopeKey: resourceCategoryScopeKey,
resourceIdsByCategory,
};
setUnreadResourceCategoryState((current) => {
const categories =
current.scopeKey === resourceCategoryScopeKey
? new Set(current.categories)
: new Set<ResourceCategory>();
let changed = current.scopeKey !== resourceCategoryScopeKey;
if (viewedResourceCategory && categories.delete(viewedResourceCategory)) {
changed = true;
}
categoriesWithNewResources.forEach((category) => {
if (!categories.has(category)) {
categories.add(category);
changed = true;
}
});
return changed
? { scopeKey: resourceCategoryScopeKey, categories }
: current;
});
}, [resourceCategoryScopeKey, resourceIdsByCategory, viewedResourceCategory]);
const unreadResourceCategories =
unreadResourceCategoryState.scopeKey === resourceCategoryScopeKey
? unreadResourceCategoryState.categories
: new Set<ResourceCategory>();
const nextPageCategory =
activePageCategory && resourcePageCategories.length > 1
? resourcePageCategories[
@@ -6190,12 +6041,6 @@ export default function ProjectDevelopmentView({
栏目页的分页画布。 */}
{resourceBookOpensAllResources ? null : activePageCategory ? (
<div className="game-resource-page-shell">
<ResourceBookOutlineNav
categories={resourcePageCategories}
activeCategory={activePageCategory}
unreadCategories={unreadResourceCategories}
onOpen={openResourceBookChild}
/>
<section
className="game-resource-page"
aria-label={categoryLabels[activePageCategory]}
@@ -6328,8 +6173,6 @@ export default function ProjectDevelopmentView({
<ResourceBookAllResourcesPage
sections={resourceBookAllResourcesSections}
totalCount={canvasResources.length}
unreadCategories={unreadResourceCategories}
onOpen={openResourceBookChild}
onCollapse={returnToResourceBookMain}
renderCard={renderResourceBookCard}
/>
@@ -101,10 +101,16 @@ async function openResourceBookCategory(label: string) {
: 'version',
};
const category = categoryByLabel[label];
const outline = await screen.findByLabelText('资源栏目大纲');
fireEvent.click(
within(outline).getByRole('button', { name: new RegExp(label) }),
);
// 左侧栏目大纲导航已按用户要求删除:切栏目走「资源总览」的栏目缩略卡片。
if (document.querySelector('[data-resource-book-view="child"]')) {
fireEvent.click(await screen.findByRole('button', { name: '收起资源' }));
await waitFor(() =>
expect(
document.querySelector('[data-resource-book-view="main"]'),
).not.toBeNull(),
);
}
fireEvent.click(await screen.findByRole('button', { name: `打开${label}` }));
await waitFor(() => {
const manager = document.querySelector('[data-resource-book-view="child"]');
expect(manager).not.toBeNull();
@@ -168,10 +168,16 @@ async function openResourceBookCategory(label: string) {
: 'version',
};
const category = categoryByLabel[label];
const outline = await screen.findByLabelText('资源栏目大纲');
fireEvent.click(
within(outline).getByRole('button', { name: new RegExp(label) }),
);
// 左侧栏目大纲导航已按用户要求删除:切栏目走「资源总览」的栏目缩略卡片。
if (document.querySelector('[data-resource-book-view="child"]')) {
fireEvent.click(await screen.findByRole('button', { name: '收起资源' }));
await waitFor(() =>
expect(
document.querySelector('[data-resource-book-view="main"]'),
).not.toBeNull(),
);
}
fireEvent.click(await screen.findByRole('button', { name: `打开${label}` }));
await waitFor(() => {
const manager = document.querySelector('[data-resource-book-view="child"]');
expect(manager).not.toBeNull();
@@ -419,10 +425,9 @@ export function registerProjectWorkbenchFoundationTests() {
const dependencyViewport = readViewport(artCanvas);
expect(dependencyViewport).toBeTruthy();
// 切换排序模式时栏目页保持挂载:这里换的是「按依赖 / 按类型」各自的 viewport
// 不需要(也不再有)切栏目动作——旧的栏目大纲入口点在同栏目上是提前返回的空操作。
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
const outline = screen.getByLabelText('资源栏目大纲');
fireEvent.click(within(outline).getByRole('button', { name: /文档/ }));
await openResourceBookCategory('角色与对象');
const typeCanvas = await screen.findByRole('region', {
name: '角色与对象',
});
@@ -436,7 +441,6 @@ export function registerProjectWorkbenchFoundationTests() {
expect(typeViewport).not.toBe(dependencyViewport);
fireEvent.click(screen.getByRole('button', { name: '按依赖' }));
await openResourceBookCategory('角色与对象');
const restoredDependencyCanvas = await screen.findByRole('region', {
name: '角色与对象',
});
@@ -445,7 +449,6 @@ export function registerProjectWorkbenchFoundationTests() {
);
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
await openResourceBookCategory('角色与对象');
const restoredTypeCanvas = await screen.findByRole('region', {
name: '角色与对象',
});
@@ -456,7 +459,6 @@ export function registerProjectWorkbenchFoundationTests() {
fireEvent.click(screen.getByRole('tab', { name: '运行' }));
expect(screen.getByRole('region', { name: '运行表现层' })).not.toBeNull();
fireEvent.click(screen.getByRole('tab', { name: '资源管理' }));
await openResourceBookCategory('角色与对象');
const restoredArtCanvas = await screen.findByRole('region', {
name: '角色与对象',
});
@@ -734,24 +736,25 @@ export function registerProjectWorkbenchFoundationTests() {
}
window.dispatchEvent(new Event('resize'));
const outline = screen.getByLabelText('资源栏目大纲');
// 左侧栏目大纲导航已删除:栏目顺序改由「资源总览」缩略卡片承载(第 0 张是「所有资源」)。
expect(
Array.from(outline.querySelectorAll('strong')).map(
(item) => item.textContent,
Array.from(
document.querySelectorAll('.game-resource-book-thumbnail'),
(thumbnail) => thumbnail.getAttribute('aria-label'),
),
).toEqual([
'UI 交互',
'角色与对象',
'场景与环境',
'音频',
'文档',
'待归类',
'项目版本',
'打开所有资源',
'打开UI 交互',
'打开角色与对象',
'打开场景与环境',
'打开音频',
'打开文档',
'打开待归类',
'打开项目版本',
]);
expect(graphResourceIds).toContain('asset:section-code');
expect(queryResourceSelectButton('section.js')).not.toBeNull();
expect(screen.queryByRole('region', { name: '游戏代码' })).toBeNull();
expect(outline.querySelectorAll('small')).toHaveLength(0);
expect(screen.getByRole('region', { name: '角色与对象' })).not.toBeNull();
expect(
screen.getByTestId('resource-dependency-overlay-character'),
@@ -780,9 +783,6 @@ export function registerProjectWorkbenchFoundationTests() {
document.querySelector('.game-resource-book-transition-layer'),
).toBeNull();
expect(
within(outline).queryByRole('button', { name: /游戏代码/ }),
).toBeNull();
expect(screen.queryByRole('region', { name: '游戏代码' })).toBeNull();
const dispatchViewWheel = (deltaY = 160) => {
const scene = document.querySelector<HTMLElement>(
@@ -968,9 +968,7 @@ export function registerProjectWorkbenchFoundationTests() {
clientX: 100,
clientY: 50,
});
fireEvent.click(
within(outline).getByRole('button', { name: /角色与对象/ }),
);
fireEvent.click(screen.getByRole('button', { name: /^下一页/ }));
expect(cardReleasePointerCapture).toHaveBeenCalledWith(91);
fireEvent.pointerUp(dragCard!, {
pointerId: 91,
@@ -1030,9 +1028,7 @@ export function registerProjectWorkbenchFoundationTests() {
screen.queryByTestId('resource-dependency-overlay-character'),
).toBeNull();
fireEvent.click(
within(outline).getByRole('button', { name: /角色与对象/ }),
);
await openResourceBookCategory('角色与对象');
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
const typeCanvas = screen.getByLabelText('资源类型视图') as HTMLDivElement;
expect(
@@ -1250,46 +1246,17 @@ export function registerProjectWorkbenchFoundationTests() {
expect(styleNumber(notices, 'top')).toBeGreaterThanOrEqual(titlebarHeight);
});
it('keeps the outline dock readable without hover', () => {
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
const outline = styleRuleBody(styles, '\\.game-resource-outline');
// 常态必须有 Dock 底板且不再整体降透明度/缩小,否则用户认不出这是栏目入口。
expect(styleNumber(outline, 'opacity')).toBe(1);
expect(outline).not.toMatch(/scale\(/u);
expect(outline).toMatch(/background:\s*var\(--platform-nav-fill\)/u);
expect(outline).toMatch(
/border:\s*1px solid var\(--platform-subpanel-border\)/u,
);
// 栏目图标常态就展开:常态把 svg 收成 width: 0、只剩 11px 文字是"认不出"的根因。
expect(styleRuleBody(styles, '\\.game-resource-outline button')).toMatch(
/grid-template-columns:\s*18px minmax\(0, 1fr\)/u,
);
const icon = styleRuleBody(styles, '\\.game-resource-outline button svg');
expect(styleNumber(icon, 'width')).toBe(14);
expect(styleNumber(icon, 'opacity')).toBe(1);
expect(
styleNumber(
styleRuleBody(styles, '\\.game-resource-outline button strong'),
'font-size',
),
).toBe(12);
});
it('renders every outline entry with its icon and section label', async () => {
it('renders the resource search overlay as a sibling of the book scene', async () => {
const manifest = createGameCreationAppManifest(
'workbench-outline-dock',
'栏目大纲可读性测试',
'workbench-search-overlay',
'资源搜索浮层测试',
);
manifest.assets = [
{
id: 'outline-art',
id: 'search-overlay-art',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/outline-art.png',
localPath: 'assets/search-overlay-art.png',
source: { kind: 'generated', taskId: 'art-asset-plan' },
},
];
@@ -1316,7 +1283,7 @@ export function registerProjectWorkbenchFoundationTests() {
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-outline-dock',
projectPath: '/tmp/workbench-search-overlay',
manifest,
attachments: [],
recentRunStatus: null,
@@ -1327,36 +1294,6 @@ export function registerProjectWorkbenchFoundationTests() {
}),
);
const outline = await screen.findByLabelText('资源栏目大纲');
const entries = Array.from(outline.querySelectorAll('button'));
expect(entries.length).toBeGreaterThan(0);
// 分区轴固定 7 栏:6 类资产功能分类 + 末尾独立的「项目版本」。
expect(
entries.map((button) => button.querySelector('strong')?.textContent),
).toEqual([
'UI 交互',
'角色与对象',
'场景与环境',
'音频',
'文档',
'待归类',
'项目版本',
]);
// 每条栏目都是"图标 + 栏目文字"的完整入口,不是只有一行小字。
expect(
entries.every(
(button) =>
button.querySelector('svg') !== null &&
(button.querySelector('strong')?.textContent ?? '').trim().length > 0,
),
).toBe(true);
await openResourceBookCategory('角色与对象');
expect(
within(outline)
.getByRole('button', { name: '角色与对象' })
.getAttribute('aria-current'),
).toBe('page');
// 搜索浮层与栏目标题栏分属两条排布带,这不是 CSS 声明能表达的:画本场景是管理区里
// 铺满整块的绝对定位层,搜索框只要落在场景内部,就会跟着场景走、和栏目标题栏共享
// 同一条水平带。所以这里钉住渲染出来的 DOM 归属关系:浮层是管理区的直接子节点,
@@ -1947,7 +1884,7 @@ export function registerProjectWorkbenchFoundationTests() {
);
});
it('marks newly added resources on inactive section tabs until the user opens them', async () => {
it('keeps a code-only project paged into the catch-all section', async () => {
const manifest = createGameCreationAppManifest(
'workbench-resource-unread',
'资源未读标识测试',
@@ -2009,83 +1946,10 @@ export function registerProjectWorkbenchFoundationTests() {
const rendered = render(
React.createElement(ProjectDevelopmentView, viewProps),
);
const outline = await screen.findByLabelText('资源栏目大纲');
expect(
within(outline).queryByRole('button', { name: /有新资源/ }),
).toBeNull();
expect(
within(outline)
.getByRole('button', { name: '角色与对象' })
.getAttribute('aria-current'),
).toBe('page');
const manifestWithCurrentArt = {
...manifest,
assets: [
...manifest.assets,
{
id: 'unread-art-current',
kind: 'character' as const,
mediaType: 'image/png',
localPath: 'assets/current.png',
source: { kind: 'generated' as const },
},
],
};
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...viewProps,
manifest: manifestWithCurrentArt,
}),
// 默认停留在顺序里第一个非空栏目,也就是首个栏目资产所在的「角色与对象」。
await waitFor(() =>
expect(screen.getByRole('region', { name: '角色与对象' })).not.toBeNull(),
);
expect(
within(outline).queryByRole('button', { name: /有新资源/ }),
).toBeNull();
const manifestWithAudio = {
...manifestWithCurrentArt,
assets: [
...manifestWithCurrentArt.assets,
{
id: 'unread-audio-new',
kind: 'background-music' as const,
mediaType: 'audio/mpeg',
localPath: 'assets/new.mp3',
source: { kind: 'generated' as const },
},
],
};
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...viewProps,
manifest: manifestWithAudio,
}),
);
const unreadAudioTab = await within(outline).findByRole('button', {
name: '音频,有新资源',
});
expect(
unreadAudioTab.querySelector('.game-resource-outline-unread'),
).not.toBeNull();
expect(
within(outline).queryByRole('button', {
name: '角色与对象,有新资源',
}),
).toBeNull();
fireEvent.click(unreadAudioTab);
await waitFor(() => {
expect(
within(outline)
.getByRole('button', { name: '音频' })
.getAttribute('aria-current'),
).toBe('page');
expect(
within(outline).queryByRole('button', { name: /有新资源/ }),
).toBeNull();
});
const nextProjectManifest = createGameCreationAppManifest(
'workbench-resource-unread-next',
@@ -2110,7 +1974,6 @@ export function registerProjectWorkbenchFoundationTests() {
);
await waitFor(() => {
// 只登记游戏代码的项目不再被分区轴排除:画布照常分页,卡片落在「待归类」栏目。
expect(screen.queryByLabelText('资源栏目大纲')).not.toBeNull();
expect(screen.queryAllByText('暂无已登记资源')).toHaveLength(0);
expect(
screen
@@ -2123,6 +1986,121 @@ export function registerProjectWorkbenchFoundationTests() {
});
});
it('has no outline nav and keeps every section reachable through the remaining entries', async () => {
const manifest = createGameCreationAppManifest(
'workbench-no-outline-nav',
'无栏目大纲导航测试',
);
manifest.assets = [
{
id: 'nav-document',
kind: 'design-document',
mediaType: 'text/markdown',
localPath: 'memory/nav-document.md',
source: { kind: 'generated', taskId: 'design-foundation' },
},
{
id: 'nav-art',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/nav-art.png',
source: { kind: 'generated', taskId: 'art-asset-plan' },
},
];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-no-outline-nav',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await waitFor(() =>
expect(
document.querySelector('.game-resource-book-thumbnail'),
).not.toBeNull(),
);
// 反向守卫:左侧栏目大纲导航整条不再渲染(标签、类名、nav 角色都不出现)。
expect(screen.queryByLabelText('资源栏目大纲')).toBeNull();
expect(document.querySelector('.game-resource-outline')).toBeNull();
expect(document.querySelector('nav[aria-label="资源栏目大纲"]')).toBeNull();
// 链路一:总览缩略卡片 → 任意栏目(含空栏目)。
fireEvent.click(screen.getByRole('button', { name: '打开项目版本' }));
await waitFor(() =>
expect(
document.querySelector(
'.game-resource-book-scene-titlebar.is-active[data-resource-book-category="version"]',
),
).not.toBeNull(),
);
expect(screen.getByRole('region', { name: '项目版本' })).not.toBeNull();
// 链路二:页内「下一页」→ 顺序里的下一个栏目(项目版本之后回到 UI 交互)。
expect(
screen.getByRole('button', { name: /下一页\s*UI 交互/ }),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: /^下一页/ }));
await waitFor(() =>
expect(
document.querySelector(
'.game-resource-book-scene-titlebar.is-active[data-resource-book-category="ui-interaction"]',
),
).not.toBeNull(),
);
expect(screen.getByRole('region', { name: 'UI 交互' })).not.toBeNull();
// 链路三:栏目标题栏的「资源总览」→ 回到总览。
fireEvent.click(screen.getByRole('button', { name: '收起资源' }));
await waitFor(() =>
expect(
document.querySelector('[data-resource-book-view="main"]'),
).not.toBeNull(),
);
// 链路四:「所有资源」入口 → 汇总页 → 再回总览,全程没有栏目大纲导航。
fireEvent.click(screen.getByRole('button', { name: '打开所有资源' }));
await waitFor(() =>
expect(
document.querySelector('[data-resource-book-all-host="true"]'),
).not.toBeNull(),
);
expect(screen.queryByLabelText('资源栏目大纲')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '收起资源' }));
await waitFor(() =>
expect(
document.querySelector('[data-resource-book-view="main"]'),
).not.toBeNull(),
);
expect(screen.queryByLabelText('资源栏目大纲')).toBeNull();
});
it('refreshes resource pages when a same-count manifest update changes category', async () => {
const manifest = createGameCreationAppManifest(
'workbench-imported-image-canvas',
@@ -2185,10 +2163,6 @@ export function registerProjectWorkbenchFoundationTests() {
const rendered = render(
React.createElement(ProjectDevelopmentView, viewProps),
);
const outline = await screen.findByLabelText('资源栏目大纲');
expect(
within(outline).getByRole('button', { name: '待归类' }),
).not.toBeNull();
const refreshedManifest = {
...manifest,
@@ -2213,7 +2187,7 @@ export function registerProjectWorkbenchFoundationTests() {
}),
);
fireEvent.click(within(outline).getByRole('button', { name: /UI 交互/ }));
await openResourceBookCategory('UI 交互');
expect(
await screen.findByRole('button', {
name: '选中资源:UI 交互 local-imported-image.png',
@@ -2277,7 +2251,6 @@ export function registerProjectWorkbenchFoundationTests() {
);
const assertOverview = () => {
expect(screen.queryByLabelText('资源栏目大纲')).toBeNull();
expect(
screen
.getByLabelText(/资源(?:依赖|类型)视图/)
@@ -2873,11 +2846,7 @@ export function registerProjectWorkbenchFoundationTests() {
);
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
fireEvent.click(
within(screen.getByLabelText('资源栏目大纲')).getByRole('button', {
name: /项目版本/,
}),
);
await openResourceBookCategory('项目版本');
const rootVersionButton = screen.getByRole('button', {
name: /选中资源:项目版本 版本 1/,
});
@@ -2895,11 +2864,7 @@ export function registerProjectWorkbenchFoundationTests() {
expect(childVersionButton.getAttribute('aria-pressed')).toBe('true');
expect(screen.queryByRole('toolbar')).toBeNull();
expect(screen.queryByText('asset:asset-removed')).toBeNull();
fireEvent.click(
within(screen.getByLabelText('资源栏目大纲')).getByRole('button', {
name: /角色与对象/,
}),
);
await openResourceBookCategory('角色与对象');
const playerCard = getResourceSelectButton('player.png').closest(
'.game-resource-card',
);
@@ -8924,11 +8889,6 @@ export function registerProjectAgentStatusTests() {
}
// 汇总页自己的标题栏也用同一份全量计数。
expect(readCountBadge('.game-resource-all-page')).toBe(allResourcesCount);
// 汇总页复用栏目大纲:第 0 项是「所有资源」,并标成当前页。
const allOutline = screen.getByLabelText('资源栏目大纲');
expect(
allOutline.querySelector('[aria-current="page"]')?.textContent?.trim(),
).toBe('所有资源');
});
/**
@@ -10,7 +10,7 @@ import {
React,
render,
screen,
within,
waitFor,
} from './appSurface/harness';
const PROJECT_PATH = '/tmp/workbench-code-only-project';
@@ -100,14 +100,22 @@ describe('只登记游戏代码的项目分区', () => {
installResourceCardIntersectionObserver();
renderWorkbench(createCodeOnlyManifest());
const outline = await screen.findByLabelText('资源栏目大纲');
expect(
within(outline).getByRole('button', { name: /待归类/ }),
).not.toBeNull();
// 左侧栏目大纲导航已删除:栏目清单改由「资源总览」缩略卡片承载。
await waitFor(() =>
expect(
document.querySelector('.game-resource-book-thumbnail'),
).not.toBeNull(),
);
const thumbnailLabels = Array.from(
document.querySelectorAll('.game-resource-book-thumbnail'),
(thumbnail) => thumbnail.getAttribute('aria-label'),
);
expect(thumbnailLabels).toContain('打开待归类');
// 项目版本仍然是末尾独立栏目,代码栏目不再存在。
expect(
within(outline).getByRole('button', { name: /项目版本/ }),
).not.toBeNull();
expect(thumbnailLabels).toContain('打开项目版本');
expect(thumbnailLabels.some((label) => label?.includes('游戏代码'))).toBe(
false,
);
// 默认停留在顺序里第一个非空栏目,也就是代码资产所在的「待归类」。
expect(
@@ -80,8 +80,18 @@ function createManifestWithHero(localPath: string): GameCreationAppManifest {
async function openHeroCard(name = 'hero.png') {
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
const outline = await screen.findByLabelText('资源栏目大纲');
fireEvent.click(within(outline).getByRole('button', { name: /角色与对象/ }));
// 左侧栏目大纲导航已按用户要求删除:切栏目走「资源总览」的栏目缩略卡片。
if (document.querySelector('[data-resource-book-view="child"]')) {
fireEvent.click(await screen.findByRole('button', { name: '收起资源' }));
await waitFor(() =>
expect(
document.querySelector('[data-resource-book-view="main"]'),
).not.toBeNull(),
);
}
fireEvent.click(
await screen.findByRole('button', { name: '打开角色与对象' }),
);
// 资源画本先切到子画布再渲染卡片,等它落到 character 栏目再找卡。
await waitFor(() => {
const manager = document.querySelector('[data-resource-book-view="child"]');
@@ -149,8 +149,18 @@ function renderWorkbench(
async function openArtCategory() {
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
const outline = await screen.findByLabelText('资源栏目大纲');
fireEvent.click(within(outline).getByRole('button', { name: /角色与对象/ }));
// 左侧栏目大纲导航已按用户要求删除:切栏目走「资源总览」的栏目缩略卡片。
if (document.querySelector('[data-resource-book-view="child"]')) {
fireEvent.click(await screen.findByRole('button', { name: '收起资源' }));
await waitFor(() =>
expect(
document.querySelector('[data-resource-book-view="main"]'),
).not.toBeNull(),
);
}
fireEvent.click(
await screen.findByRole('button', { name: '打开角色与对象' }),
);
await waitFor(() => {
expect(
document.querySelector('[data-resource-book-view="child"]'),