Files
apes-sidebar/tests/tasks-graph-layout.spec.ts
T
Menghuan1918 f584b67813 feat(tasks): 图节点改为双段式卡片 + 折叠聚合 + 窄栏可读性
图模式(主显示模式)换成新的卡片语言,树模式保持不变:

- 卡片 = 上段(类型徽章 + 相位徽标 + 名称两行 + mono 元信息 + 团队任务行)
  + 下段小条(状态点 + 状态词 + 主 Agent 同款合并活动 + 完成后的折叠 chevron)。
  类型徽章用页面自己的词汇(主代理 / 子代理 / 成员 / 工作流 / ✓N 已完成)。
- 分组色(层级):按树深度取 4 档令牌色阶(color-mix 混 --dsw-alias-state-business-primary),
  作用于左描边与上段极淡底色;相位徽标走 ink 色族,两套分组不撞色。
  相位虚线框保留但改用同色极淡描边,卡片徽标承担「这是哪个阶段」。
- 执行态:小条 `data-running` + 从左到右扫光(1.5s,末 1/3 停顿,
  prefers-reduced-motion 关闭);合并活动行来自宿主 chat 词条,缺失时回退插件文案。
- 折叠:已完成节点的 chevron 把该节点「收进已完成聚合」(模型新增 foldedIds,
  与全局 folded 走同一组守卫:只在已结算叶子、非当前会话、非团队成员时生效);
  聚合节点点击 = 全部展开(清空手动集 + 关闭全局折叠);控制条仍只管全局规则。
- 布局与窄栏(用户反馈 a–f):卡片高度预算按新行重算(徽章 + 两行标题 + 元信息 +
  小条 = 103px,任务行 +18),宽度 176 → 190;fit 按内容包围盒双轴居中、
  FIT_MAX_SCALE 1.15 → 1.30;缩放范围 0.4–2.0 并归一化 wheel deltaMode;
  平移加边界夹取(至少 72px 内容留在视口内);空画布双击重新适配并恢复自动 fit;
  连线改「垂直出线 stub + 贝塞尔」并加粗到 1.5px。

验证:typecheck / lint 通过;pnpm test 1253 passed / 9 skipped(含新增双段结构、
data-running、chevron → 聚合、聚合 → 全部展开、工作流相位徽标 5 例)。

(cherry picked from commit aee571e50beb5dc979995f0841cffd0a575f2173)
2026-09-27 22:18:35 +08:00

151 lines
6.5 KiB
TypeScript

/**
* Unit tests for the layered graph layout: parent centering, depth rows, the
* sibling-band WRAP that keeps a narrow sidebar readable (and the row
* reservation that stops a wrapped band from landing on a sibling's
* children), the PER-SHAPE card height (the live rows and the shared-task row
* a card renders are reserved, so a card never renders outside its box), and
* the width-aware solver.
*/
import { describe, expect, it } from 'vitest'
import {
GRAPH_NODE_H,
GRAPH_NODE_W,
GRAPH_PAD,
GRAPH_ROW_STRIDE,
GRAPH_TASK_H,
bandColsFor,
layoutTasksGraph,
layoutTasksGraphForWidth,
} from '../src/client/tasks-graph-layout.ts'
import type { TasksAgentNode, TasksNode, TasksNodeTask } from '../src/client/tasks-model.ts'
/** One shared task as the card's task line shows it. */
function task(id: string): TasksNodeTask {
return { id, subject: id, status: 'in_progress', ready: true }
}
/** An agent node. `live` gives it a text-only tail; `extra` shapes the card
* further (a settled state, a tool call, owned shared tasks). */
function agent(
id: string,
parentId?: string,
live = false,
extra: Partial<TasksAgentNode> = {},
): TasksAgentNode {
return {
kind: 'agent', id, ...(parentId === undefined ? {} : { parentId }),
depth: parentId === undefined ? 0 : 1,
label: id, state: 'running', activity: 'running', current: false,
...(live ? { live: { running: true, text: 'x' } } : {}),
...extra,
}
}
/** One running node's live view: a merged activity row (the bar's text). */
const ACTIVITY = {
running: true,
summary: { counts: [{ kind: 'read' as const, count: 1 }], running: 'read' as const, runningDetail: 'x' },
}
/** The row index of one laid-out node. */
function layoutRow(layout: ReturnType<typeof layoutTasksGraph>, id: string): number {
const box = layout.boxes.get(id)
if (box === undefined) throw new Error(`no box for ${id}`)
return (box.y - GRAPH_PAD) / GRAPH_ROW_STRIDE
}
describe('layout: card heights', () => {
it('reserves the state bar for every node kind, and the task row only for team members', () => {
const nodes: TasksNode[] = [
agent('root'),
agent('busy', 'root', false, { live: ACTIVITY }),
agent('owner', 'root', false, { tasks: [task('t1')] }),
]
const layout = layoutTasksGraph(nodes)
// The merged activity lives in the bar, which every card renders: a
// running node and a settled one reserve exactly the same base height.
expect(layout.boxes.get('root')?.h).toBe(GRAPH_NODE_H)
expect(layout.boxes.get('busy')?.h).toBe(GRAPH_NODE_H)
expect(layout.boxes.get('owner')?.h).toBe(GRAPH_NODE_H + GRAPH_TASK_H)
})
it('keeps every card shape inside one row stride', () => {
// The stride is the row pitch: a card taller than it would overlap the
// next row (and its phase frame).
const tallest = GRAPH_NODE_H + GRAPH_TASK_H
expect(GRAPH_ROW_STRIDE).toBeGreaterThanOrEqual(tallest)
const nodes: TasksNode[] = [
agent('root'),
agent('busiest', 'root', false, { live: { ...ACTIVITY, text: 'y' }, tasks: [task('t1')] }),
]
const layout = layoutTasksGraph(nodes)
expect(layout.boxes.get('busiest')?.h).toBe(tallest)
})
it('gives every node a box even with a defensive orphan', () => {
const nodes: TasksNode[] = [agent('root'), agent('ghost', 'missing')]
const layout = layoutTasksGraph(nodes)
expect(layout.boxes.get('root')).toBeDefined()
expect(layout.boxes.get('ghost')).toBeDefined()
})
it('wraps a wide sibling band into rows of maxBandCols', () => {
const nodes: TasksNode[] = [agent('root')]
for (let index = 0; index < 5; index += 1) nodes.push(agent(`c${index}`, 'root'))
const layout = layoutTasksGraph(nodes, { maxBandCols: 2 })
// Three wrapped rows of 2/2/1, never five on one line.
expect([layoutRow(layout, 'c0'), layoutRow(layout, 'c1')]).toEqual([1, 1])
expect([layoutRow(layout, 'c2'), layoutRow(layout, 'c3')]).toEqual([2, 2])
expect(layoutRow(layout, 'c4')).toBe(3)
// The canvas stays as narrow as one wrapped band.
expect(layout.width).toBeLessThanOrEqual(2 * GRAPH_NODE_W + 28 + 2 * GRAPH_PAD)
})
it('never lets a wrapped band land on a sibling subtree row', () => {
// root → [a (with a child), b, c, d] wrapped at 2 columns: the second
// band must start BELOW a's child, not on the same row as it.
const nodes: TasksNode[] = [
agent('root'), agent('a', 'root'), agent('b', 'root'),
agent('c', 'root'), agent('d', 'root'), agent('a1', 'a'),
]
const layout = layoutTasksGraph(nodes, { maxBandCols: 2 })
expect(layoutRow(layout, 'c')).toBeGreaterThan(layoutRow(layout, 'a1'))
expect(layoutRow(layout, 'd')).toBe(layoutRow(layout, 'c'))
})
})
describe('bandColsFor / layoutTasksGraphForWidth', () => {
it('derives the column count from the container width', () => {
// The budget is measured at the READABILITY FLOOR, not at 1:1: the fit may
// scale the canvas down to 0.78 and stay readable, so a 360px panel keeps
// two 176px cards per row (scaled ~0.86) instead of collapsing into a
// one-card-per-row ladder. 720px fits four at the floor (the cap), and a
// panel too narrow for even two columns falls back to one.
expect(bandColsFor(360)).toBe(2)
expect(bandColsFor(720)).toBe(4)
expect(bandColsFor(120)).toBe(1)
})
it('prefers a flat band when the container is wide', () => {
const nodes: TasksNode[] = [agent('root')]
for (let index = 0; index < 4; index += 1) nodes.push(agent(`c${index}`, 'root'))
const wide = layoutTasksGraphForWidth(nodes, 720)
// Four 176px cards fit one band inside the floor budget at 720px, so the
// whole sibling set stays on one row.
expect(['c0', 'c1', 'c2', 'c3'].map(id => layoutRow(wide, id))).toEqual([1, 1, 1, 1])
expect(wide.width).toBeLessThanOrEqual(720 / 0.78)
})
it('wraps instead of overflowing wildly when the container is narrow', () => {
const nodes: TasksNode[] = [agent('root')]
for (let index = 0; index < 6; index += 1) nodes.push(agent(`c${index}`, 'root'))
// 360px (the narrowest native-sidebar panel) is where the anti-ladder
// rule matters most: two cards per band, scaled to fit.
const narrow = layoutTasksGraphForWidth(nodes, 360)
// Never wider than the tolerant budget (a small pan is allowed, a 3x
// overflow is not), and never a one-child-per-row ladder.
expect(narrow.width).toBeLessThanOrEqual(((360 - 24) / 0.78) * 1.2)
expect(layoutRow(narrow, 'c1')).toBe(layoutRow(narrow, 'c0'))
})
})