diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index 816b44d4c..dac251ef5 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -1130,7 +1130,7 @@ describe('GameLineagePage', () => { // 父作品不在可见集合里:只标注「原作品已不可用」,不回显父作品内容。 expect(screen.getByText('原作品已不可用')).toBeTruthy(); // 任一公开节点都能进入对应详情页。 - fireEvent.click(screen.getByRole('button', { name: /作品《母版》/u })); + fireEvent.click(screen.getByRole('button', { name: '打开《母版》详情' })); expect(onOpenDetail).toHaveBeenCalledWith('game-root'); }); @@ -1219,7 +1219,7 @@ describe('GameLineagePage', () => { ); expect( - await screen.findByRole('button', { name: /作品《母版》/u }), + await screen.findByRole('button', { name: '打开《母版》详情' }), ).toBeTruthy(); expect( screen.getByText('这部作品还没有衍生作品,欢迎从它开始共创。'), @@ -1977,6 +1977,14 @@ describe('GameDetailPage 共创主题入口', () => { }); describe('GameLineagePage 可视化与交互', () => { + /** 节点卡片上的主行动作按钮(打开详情)与它的卡片容器。 */ + const openButton = (title: string) => + screen.getByRole('button', { name: `打开《${title}》详情` }); + const cardOf = async (title: string) => + ( + await screen.findByRole('button', { name: `打开《${title}》详情` }) + ).closest('.game-lineage-node') as HTMLElement; + const treeWithSibling = { rootGameId: 'game-root', root: createLineageNode({ @@ -2014,7 +2022,7 @@ describe('GameLineagePage 可视化与交互', () => { truncated: false, }; - it('节点是真实 DOM 卡片:点击进详情、Enter 等价、当前作品有标记且父在上子在下', async () => { + it('节点是真实 DOM 卡片:主体点击=选中、标题/打开作品=进详情、当前作品有标记且父在上子在下', async () => { getGameLineageMock.mockResolvedValueOnce(treeWithSibling); const onOpenDetail = vi.fn(); render( @@ -2026,10 +2034,8 @@ describe('GameLineagePage 可视化与交互', () => { />, ); - const leafCard = await screen.findByRole('button', { - name: /作品《叶节点》/u, - }); - const rootCard = screen.getByRole('button', { name: /作品《母版》/u }); + const leafCard = await cardOf('叶节点'); + const rootCard = await cardOf('母版'); // 当前作品标记 expect(leafCard.getAttribute('aria-current')).toBe('true'); expect(rootCard.getAttribute('aria-current')).toBeNull(); @@ -2041,15 +2047,26 @@ describe('GameLineagePage 可视化与交互', () => { // 正交 elbow 连线 const paths = document.querySelectorAll('path.game-lineage-edge'); expect(paths.length).toBeGreaterThan(0); - // 正交 elbow:竖直段 + 水平段 + 圆角(不再是三次贝塞尔弧)。 const firstEdgePath = paths[0]!.getAttribute('d') ?? ''; expect(firstEdgePath).toContain(' V '); expect(firstEdgePath).not.toContain(' C '); - // 键盘 Enter 与点击等价 - fireEvent.keyDown(leafCard, { key: 'Enter' }); + + // 主行动作是**真实 button**:键盘(Tab + Enter)与点击都能打开详情。 + const leafOpen = openButton('叶节点'); + expect(leafOpen.tagName).toBe('BUTTON'); + leafOpen.focus(); + expect(document.activeElement).toBe(leafOpen); + fireEvent.click(leafOpen); expect(onOpenDetail).toHaveBeenCalledWith('game-leaf'); - fireEvent.click(rootCard); - expect(onOpenDetail).toHaveBeenCalledWith('game-root'); + fireEvent.click(within(leafCard).getByRole('button', { name: '打开作品' })); + expect(onOpenDetail).toHaveBeenCalledWith('game-leaf'); + + // 卡片主体点击 = 选中(不是打开详情),再点一次取消。 + fireEvent.click(leafCard); + expect(onOpenDetail).toHaveBeenCalledTimes(2); + expect(leafCard.className).toContain('game-lineage-node--pinned'); + fireEvent.click(leafCard); + expect(leafCard.className).not.toContain('game-lineage-node--pinned'); }); it('hover 一个节点高亮祖先路径与子树,其余节点降透明;移开后恢复', async () => { @@ -2062,12 +2079,10 @@ describe('GameLineagePage 可视化与交互', () => { />, ); - const midCard = await screen.findByRole('button', { - name: /作品《中间代》/u, - }); - const rootCard = screen.getByRole('button', { name: /作品《母版》/u }); - const leafCard = screen.getByRole('button', { name: /作品《叶节点》/u }); - const otherCard = screen.getByRole('button', { name: /作品《旁支》/u }); + const midCard = await cardOf('中间代'); + const rootCard = await cardOf('母版'); + const leafCard = await cardOf('叶节点'); + const otherCard = await cardOf('旁支'); fireEvent.mouseEnter(midCard); // 祖先(母版)+ 自身 + 子树(叶节点)都不降透明,旁支降透明。 @@ -2100,7 +2115,7 @@ describe('GameLineagePage 可视化与交互', () => { />, ); - await screen.findByRole('button', { name: /作品《母版》/u }); + await screen.findByRole('button', { name: '打开《母版》详情' }); // 初次适配在 effect 里落地:先等它生效(jsdom 无布局尺寸 → min(scaleX, scaleY) 远小于 1, // 因此命中可读下限 60%)。 await waitFor(() => @@ -2127,7 +2142,7 @@ describe('GameLineagePage 可视化与交互', () => { />, ); - await screen.findByRole('button', { name: /作品《母版》/u }); + await screen.findByRole('button', { name: '打开《母版》详情' }); const axisLayer = document.querySelector('.game-lineage-canvas__axis'); expect(axisLayer).not.toBeNull(); expect(axisLayer?.querySelectorAll('.game-lineage-axis').length).toBe(3); @@ -2176,17 +2191,18 @@ describe('GameLineagePage 可视化与交互', () => { ); await screen.findByText('有封面'); - // 只有带 coverObjectKey 的那个节点渲染 GameCover(复用既有封面组件与换签逻辑)。 + // 封面是卡片主视觉:**每个节点都有封面位**;没有 coverObjectKey 的节点走 GameCover + // 自带的「底色 + 图标」降级,所以只有带 key 的那个节点出现真实图片。 expect( document.querySelectorAll('.game-lineage-node .game-cover'), - ).toHaveLength(1); + ).toHaveLength(2); expect(document.querySelectorAll('img.game-cover__image')).toHaveLength(1); - // 没有封面的节点不渲染空封面块。 - const withoutCover = screen.getByRole('button', { name: /《没封面》/u }); - expect(withoutCover.querySelector('.game-cover')).toBeNull(); + const withoutCover = await cardOf('没封面'); + expect(withoutCover.querySelector('.game-cover')).not.toBeNull(); + expect(withoutCover.querySelector('img.game-cover__image')).toBeNull(); }); - it('锁定路径后祖先链持续强化并给出面包屑;取消锁定后复原', async () => { + it('选中路径后祖先链持续强化并给出面包屑;取消选中后复原', async () => { getGameLineageMock.mockResolvedValueOnce(treeWithSibling); render( { />, ); - const leafCard = await screen.findByRole('button', { - name: /作品《叶节点》/u, - }); + const leafCard = await cardOf('叶节点'); fireEvent.click( - within(leafCard).getByRole('button', { name: '锁定 叶节点 的路径' }), + within(leafCard).getByRole('button', { name: '选中 叶节点 的路径' }), ); // 面包屑:母版 → 中间代 → 叶节点,祖先移出视口也能读。 - const breadcrumb = screen.getByLabelText('已锁定的路径'); + const breadcrumb = screen.getByLabelText('已选中的路径'); expect(breadcrumb.textContent).toContain('母版'); expect(breadcrumb.textContent).toContain('中间代'); expect(breadcrumb.textContent).toContain('叶节点'); // 祖先链与目标持续强化(不依赖 hover),旁支仍是被压暗的一方。 - expect( - screen.getByRole('button', { name: /作品《母版》/u }).className, - ).toContain('game-lineage-node--path'); - expect( - screen.getByRole('button', { name: /作品《中间代》/u }).className, - ).toContain('game-lineage-node--path'); + expect((await cardOf('母版')).className).toContain( + 'game-lineage-node--path', + ); + expect((await cardOf('中间代')).className).toContain( + 'game-lineage-node--path', + ); expect(leafCard.className).toContain('game-lineage-node--pinned'); + expect((await cardOf('旁支')).className).toContain( + 'game-lineage-node--dimmed', + ); + // 祖先链的连线也进入强化态(这正是「共创路径」的可视表达)。 expect( - screen.getByRole('button', { name: /作品《旁支》/u }).className, - ).toContain('game-lineage-node--dimmed'); + document.querySelectorAll('path.game-lineage-edge--active').length, + ).toBeGreaterThan(0); fireEvent.click( - within(leafCard).getByRole('button', { name: '取消锁定 叶节点 的路径' }), + within(leafCard).getByRole('button', { name: '取消选中 叶节点 的路径' }), + ); + expect(screen.queryByLabelText('已选中的路径')).toBeNull(); + expect((await cardOf('旁支')).className).not.toContain( + 'game-lineage-node--dimmed', ); - expect(screen.queryByLabelText('已锁定的路径')).toBeNull(); - expect( - screen.getByRole('button', { name: /作品《旁支》/u }).className, - ).not.toContain('game-lineage-node--dimmed'); }); it('窄屏用紧凑卡片:少一行信息、卡片标记为 compact', async () => { @@ -2240,9 +2258,7 @@ describe('GameLineagePage 可视化与交互', () => { />, ); - const leafCard = await screen.findByRole('button', { - name: /作品《叶节点》/u, - }); + const leafCard = await cardOf('叶节点'); expect(leafCard.className).toContain('game-lineage-node--compact'); // 紧凑模式不渲染作者行,但标题/Fork/锁定仍在(可访问性不降级)。 expect(within(leafCard).queryByText('作者 作者 game-leaf')).toBeNull(); @@ -2250,7 +2266,7 @@ describe('GameLineagePage 可视化与交互', () => { within(leafCard).getByRole('button', { name: 'Fork 叶节点' }), ).toBeTruthy(); expect( - within(leafCard).getByRole('button', { name: '锁定 叶节点 的路径' }), + within(leafCard).getByRole('button', { name: '选中 叶节点 的路径' }), ).toBeTruthy(); }); @@ -2264,7 +2280,7 @@ describe('GameLineagePage 可视化与交互', () => { />, ); - await screen.findByRole('button', { name: /作品《母版》/u }); + await screen.findByRole('button', { name: '打开《母版》详情' }); const zoomLabel = () => document.querySelector('.game-lineage-zoom')?.textContent ?? ''; // 初次适配是 effect 里跑的:先等它落定(jsdom 无布局尺寸 → 命中可读下限 60%)。 @@ -2289,9 +2305,7 @@ describe('GameLineagePage 可视化与交互', () => { />, ); - const leafCard = await screen.findByRole('button', { - name: /作品《叶节点》/u, - }); + const leafCard = await cardOf('叶节点'); const forkButton = within(leafCard).getByRole('button', { name: 'Fork 叶节点', }); diff --git a/src/components/game-distribution/GameLineagePage.tsx b/src/components/game-distribution/GameLineagePage.tsx index 01f8d67be..0adceabb1 100644 --- a/src/components/game-distribution/GameLineagePage.tsx +++ b/src/components/game-distribution/GameLineagePage.tsx @@ -103,14 +103,10 @@ function LineageTreeNodeCard({ const accessibleName = isStub ? `原作品已不可用 · ${generationAxisLabel(generation)}` : `${generationAxisLabel(generation)}作品《${displayTitle}》· ${authorLabel}`; - // 父作品不可用的占位节点:可聚焦(读屏能听到它),但**不可点进详情**(那会 404), - // 也没有 Fork 动作(取件接口同样会拒绝)。 - const isInteractive = !isStub; - /** - * 封面:族谱节点只带 `coverObjectKey`(服务端补齐中),没有完整游戏行。 - * `GameCover` 只读 title / coverColor / icon / coverObjectKey 四个字段,这里用与 - * `game_payload` 相同的兜底色与图标补上,其余字段不参与渲染——不做逐节点详情请求。 - */ + // 父作品不可用的占位节点只能聚焦(不可点进详情,也没有 Fork 动作)。 + // 封面:族谱节点只带 `coverObjectKey`,没有完整游戏行;`GameCover` 只读 + // title / coverColor / icon / coverObjectKey 四个字段,这里用与 `game_payload` 相同的 + // 兜底色与图标补上,其余字段不参与渲染——不做逐节点详情请求。 const coverGame = { title: displayTitle, coverColor: '#F3E4D0', @@ -139,86 +135,112 @@ function LineageTreeNodeCard({ top: layoutNode.y, width: layoutNode.width, minHeight: layoutNode.height, + // 默认态就按「顶级分支」着色:不必等 hover 也能看出哪几颗属于同一个共创分支。 + ['--lineage-branch' as string]: `var(--lineage-branch-${layoutNode.branchIndex % 6})`, }} data-generation={generation} - role={isInteractive ? 'button' : undefined} - tabIndex={0} + data-branch={layoutNode.branchIndex} + // 卡片主体点击 = **选中**(持续高亮祖先链 + 子树);打开详情在标题/「打开作品」按钮上。 + tabIndex={isStub ? 0 : undefined} aria-label={accessibleName} aria-current={isAnchor ? 'true' : undefined} aria-disabled={isStub ? 'true' : undefined} - onClick={ - isInteractive ? () => onOpenDetail(gameId) : () => onActivate(gameId) - } - onKeyDown={(event) => { - if (event.key !== 'Enter' && event.key !== ' ') return; - event.preventDefault(); - if (isInteractive) onOpenDetail(gameId); - else onActivate(gameId); + onClick={() => { + if (isStub) onActivate(gameId); + else onTogglePin(isPinned ? '' : gameId); }} onMouseEnter={() => onActivate(gameId)} onMouseLeave={onDeactivate} onFocus={() => onActivate(gameId)} onBlur={onDeactivate} > - - {layoutNode.coverObjectKey ? ( - - ) : null} - - - - {generationAxisLabel(generation)} - - {isAnchor ? ( - 当前作品 - ) : null} + {isStub ? ( + // 占位节点没有可打开的作品,也不展示被删作品的标题与作者名:只保留可聚焦的降级说明。 + + + {generationAxisLabel(generation)} {displayTitle} - {/* 树节点只留「代际 + 标题 + 作者 + Fork」:游玩数与「与原版对比」在详情页已有。 */} - {compact ? null : ( - - {authorLabel} + + {authorLabel} + + + ) : ( + <> + - - - ) : null} + + + + + + + )} ); } @@ -670,7 +692,7 @@ export function GameLineagePage({ {pinnedChain.length > 0 ? (
{pinnedChain.map((crumb, index) => ( !entry.parentGameId || !byId.has(entry.parentGameId), ); - // 第 3 步:后序分配槽位。 + // 第 3 步:后序分配槽位;同时记录每棵顶级子树的编号(用于默认态分支着色)。 const slotByGameId = new Map(); + const branchByGameId = new Map(); let nextSlot = 0; - const visit = (entry: (typeof entries)[number]): number => { + let nextBranch = 0; + const visit = ( + entry: (typeof entries)[number], + branchIndex: number, + ): number => { + branchByGameId.set(entry.gameId, branchIndex); const children = childrenByParent.get(entry.gameId) ?? []; if (children.length === 0) { const slot = nextSlot; @@ -186,7 +201,7 @@ export function buildLineageTreeLayout( let min = Number.POSITIVE_INFINITY; let max = Number.NEGATIVE_INFINITY; for (const child of children) { - const childSlot = visit(child); + const childSlot = visit(child, branchIndex); min = Math.min(min, childSlot); max = Math.max(max, childSlot); } @@ -195,7 +210,9 @@ export function buildLineageTreeLayout( return slot; }; for (const root of forestRoots) { - if (!slotByGameId.has(root.gameId)) visit(root); + if (slotByGameId.has(root.gameId)) continue; + visit(root, nextBranch); + nextBranch += 1; } const generations = [ @@ -205,6 +222,11 @@ export function buildLineageTreeLayout( generations.map((generation, index) => [generation, index] as const), ); const pitch = nodeWidth + columnGap; + // 只在真正用到的槽位区间上铺像素:把左侧空槽位裁掉,内容因此更紧凑, + // 「整棵树居中」时右侧也不会留下大片空白。 + const usedSlots = [...slotByGameId.values()]; + const minSlot = usedSlots.length > 0 ? Math.min(...usedSlots) : 0; + const maxSlot = usedSlots.length > 0 ? Math.max(...usedSlots) : 0; const layoutNodes: LineageLayoutNode[] = entries.map((entry) => { const slot = slotByGameId.get(entry.gameId) ?? 0; @@ -216,11 +238,12 @@ export function buildLineageTreeLayout( authorName: entry.authorName, coverObjectKey: entry.coverObjectKey, generation: entry.generation, - x: roundTo(axisWidth + slot * pitch), + x: roundTo(axisWidth + (slot - minSlot) * pitch), y: roundTo(row * (nodeHeight + rowGap)), width: nodeWidth, height: nodeHeight, isStub: entry.node === null, + branchIndex: branchByGameId.get(entry.gameId) ?? 0, }; }); @@ -265,6 +288,7 @@ export function buildLineageTreeLayout( from, to, path, + branchIndex: parent.branchIndex, }); } @@ -275,13 +299,12 @@ export function buildLineageTreeLayout( count: layoutNodes.filter((node) => node.generation === generation).length, })); - const maxSlot = - slotByGameId.size > 0 ? Math.max(...slotByGameId.values()) : 0; + const maxSlotCount = slotByGameId.size > 0 ? maxSlot - minSlot + 1 : 0; return { nodes: layoutNodes, edges, axis, - width: roundTo(axisWidth + (maxSlot + 1) * pitch), + width: roundTo(axisWidth + maxSlotCount * pitch), height: roundTo(generations.length * (nodeHeight + rowGap)), }; }