Fix inverted cancel guard in cockpit project loader #104
@@ -21,9 +21,15 @@ async function login(page: Page, user: string, password: string) {
|
|||||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 侧栏入口两种形态:常规侧栏按钮(.tsb-tool);窄宽紧凑布局换图标栏
|
||||||
|
// (.icon-only 内 title 以「文档驾驶舱」开头的按钮)——入口都可达。
|
||||||
|
function cockpitEntry(page: Page) {
|
||||||
|
return page.locator('.tsb-tool', { hasText: '文档驾驶舱' })
|
||||||
|
.or(page.locator('.icon-only button[title^="文档驾驶舱"]')).first()
|
||||||
|
}
|
||||||
|
|
||||||
async function loginOwner(page: Page) {
|
async function loginOwner(page: Page) {
|
||||||
await login(page, OWNER, PASSWORD)
|
await login(page, OWNER, PASSWORD)
|
||||||
await expect(page.locator('.tsb-tool', { hasText: '文档驾驶舱' })).toBeVisible({ timeout: 15_000 })
|
|
||||||
// 登录后身份纪元与能力探针仍可能各跳一次;应用在身份变更时会关闭已开
|
// 登录后身份纪元与能力探针仍可能各跳一次;应用在身份变更时会关闭已开
|
||||||
// 工作区——等落定再点入口,避免 overlay 被身份刷新关掉(竞态实测)。
|
// 工作区——等落定再点入口,避免 overlay 被身份刷新关掉(竞态实测)。
|
||||||
await page.waitForLoadState('networkidle').catch(() => {})
|
await page.waitForLoadState('networkidle').catch(() => {})
|
||||||
@@ -31,7 +37,11 @@ async function loginOwner(page: Page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openCockpit(page: Page) {
|
async function openCockpit(page: Page) {
|
||||||
await page.locator('.tsb-tool', { hasText: '文档驾驶舱' }).click()
|
// 窄宽是单窗格布局:登录后默认「对话」窗格,先切到「上下文」窗格。
|
||||||
|
if (!(await cockpitEntry(page).isVisible().catch(() => false))) {
|
||||||
|
await page.getByRole('button', { name: '打开上下文' }).click()
|
||||||
|
}
|
||||||
|
await cockpitEntry(page).click()
|
||||||
await expect(page.locator('.cockpit-project')).toBeVisible({ timeout: 15_000 })
|
await expect(page.locator('.cockpit-project')).toBeVisible({ timeout: 15_000 })
|
||||||
// 默认选中首个项目(种子=审计演示项目)。
|
// 默认选中首个项目(种子=审计演示项目)。
|
||||||
await expect(page.locator('.cockpit-stats')).toBeVisible({ timeout: 15_000 })
|
await expect(page.locator('.cockpit-stats')).toBeVisible({ timeout: 15_000 })
|
||||||
@@ -111,16 +121,17 @@ test.describe.serial('I4 文档驾驶舱组合验收', () => {
|
|||||||
test('4 一致性:API 采纳新版本后,刷新入口与各投影同步', async ({ page }) => {
|
test('4 一致性:API 采纳新版本后,刷新入口与各投影同步', async ({ page }) => {
|
||||||
await loginOwner(page)
|
await loginOwner(page)
|
||||||
await openCockpit(page)
|
await openCockpit(page)
|
||||||
await page.locator('.cockpit-tree-caret').first().click()
|
const branch4 = page.locator('.cockpit-tree-branch', { hasText: '验证主体报告' }).first()
|
||||||
await page.locator('.cockpit-tree-title', { hasText: '设计说明' }).first().click()
|
await branch4.locator('.cockpit-tree-caret').click()
|
||||||
|
await branch4.locator('.cockpit-tree-title', { hasText: '设计说明' }).first().click()
|
||||||
const snapBefore = await page.locator('.cockpit-snap').innerText()
|
const snapBefore = await page.locator('.cockpit-snap').innerText()
|
||||||
// 通过应用自身 API 采纳一个新提案(同源会话;与浏览器渲染解耦)。
|
// 通过应用自身 API 落一个已提交变更(需求账本新增条目,唯一键防幂等跳过;
|
||||||
await page.request.post('/api/commands/rdm.draft.propose', {
|
// 采纳走高危确认卡、服务端不允许客户端伪造 Confirmed,故用低危提交面驱动)。
|
||||||
data: { params: { projectId: (await page.locator('.cockpit-project option:checked').textContent()) || '', sectionId: '', content: '一致性探针内容——不应出现' }, meta: {} },
|
await page.request.post('/api/commands/rdm.reqs.import', {
|
||||||
|
data: { params: { projectId: await page.locator('.cockpit-project').inputValue(),
|
||||||
|
items: [{ kind: 'UR', title: `UR-PROBE-${Date.now()} 一致性探针`, statement: '刷新一致性驱动条目', source: 'e2e-probe' }] } },
|
||||||
failOnStatusCode: false,
|
failOnStatusCode: false,
|
||||||
})
|
})
|
||||||
// 上面缺 sectionId 必然 400:真正的驱动是 e2e.sh 早已留好的第二个提案
|
|
||||||
// (种子阶段 propose 未采纳的「一致性探针」),这里经面板刷新读取。
|
|
||||||
await page.getByRole('button', { name: '刷新' }).click()
|
await page.getByRole('button', { name: '刷新' }).click()
|
||||||
await expect(page.locator('.cockpit-snap')).not.toHaveText(snapBefore)
|
await expect(page.locator('.cockpit-snap')).not.toHaveText(snapBefore)
|
||||||
// 版本块前进:出现新版本条目。
|
// 版本块前进:出现新版本条目。
|
||||||
|
|||||||
@@ -36,7 +36,14 @@ function useNodeDetail(pid: string, sel?: CockpitNodeRef) {
|
|||||||
setDetail(null); setErr('')
|
setDetail(null); setErr('')
|
||||||
if (!sel) return
|
if (!sel) return
|
||||||
api.rdmCommand<any>('rdm.cockpit.node', { projectId: pid, kind: sel.kind, id: sel.id })
|
api.rdmCommand<any>('rdm.cockpit.node', { projectId: pid, kind: sel.kind, id: sel.id })
|
||||||
.then(d => { if (g === gen.current) setDetail(d) })
|
.then(d => {
|
||||||
|
if (g !== gen.current) return
|
||||||
|
// 后端空列表可能投影为 null(Go nil slice);统一归一,消费端按数组使用。
|
||||||
|
for (const k of ['sources', 'history', 'impacts', 'conflicts'] as const) {
|
||||||
|
if (d?.[k] && !Array.isArray(d[k].items)) d[k].items = []
|
||||||
|
}
|
||||||
|
setDetail(d)
|
||||||
|
})
|
||||||
.catch(e => { if (g === gen.current) setErr(e.message) })
|
.catch(e => { if (g === gen.current) setErr(e.message) })
|
||||||
return () => { gen.current++ }
|
return () => { gen.current++ }
|
||||||
}, [pid, sel?.kind, sel?.id])
|
}, [pid, sel?.kind, sel?.id])
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ import {
|
|||||||
type CockpitTreeNode, type CockpitTreePage,
|
type CockpitTreeNode, type CockpitTreePage,
|
||||||
type Density, type PanelTabId, type ViewMode,
|
type Density, type PanelTabId, type ViewMode,
|
||||||
} from '../documentCockpit'
|
} from '../documentCockpit'
|
||||||
import { CockpitPanel, CockpitReader } from './CockpitReader'
|
import { CockpitPanel, CockpitReader, VersionBlock } from './CockpitReader'
|
||||||
|
|
||||||
interface ProjectView { id: string; name: string; canAudit?: boolean }
|
interface ProjectView { projectId: string; name: string; canAudit?: boolean }
|
||||||
|
|
||||||
function CockpitErrorState({ raw }: { raw: string }) {
|
function CockpitErrorState({ raw }: { raw: string }) {
|
||||||
const c = classifyRdmError(raw)
|
const c = classifyRdmError(raw)
|
||||||
@@ -66,10 +66,12 @@ export function DocumentCockpit() {
|
|||||||
setProjects(null); setPid(''); setOverview(null); setSelected(null); setFilter(''); setError('')
|
setProjects(null); setPid(''); setOverview(null); setSelected(null); setFilter(''); setError('')
|
||||||
api.rdmCommand<{ projects?: ProjectView[] } | ProjectView[]>('rdm.project.list')
|
api.rdmCommand<{ projects?: ProjectView[] } | ProjectView[]>('rdm.project.list')
|
||||||
.then(list => {
|
.then(list => {
|
||||||
if (!c) return
|
if (c) return
|
||||||
// rdm.project.list 返回 { projects: [...] };容忍裸数组形态。
|
// rdm.project.list 返回 { projects: [...] };容忍裸数组形态。
|
||||||
const rows = Array.isArray(list) ? list : (list?.projects ?? [])
|
const rows = Array.isArray(list) ? list : (list?.projects ?? [])
|
||||||
setProjects(rows)
|
setProjects(rows)
|
||||||
|
// 设计 §6.2:进入驾驶舱默认选中第一个可见项目,驱动概览首屏。
|
||||||
|
if (rows.length > 0) setPid(prev => prev || rows[0].projectId)
|
||||||
})
|
})
|
||||||
.catch(e => { if (!c) setError(e.message) })
|
.catch(e => { if (!c) setError(e.message) })
|
||||||
return () => { c = true }
|
return () => { c = true }
|
||||||
@@ -128,7 +130,7 @@ export function DocumentCockpit() {
|
|||||||
<div className="cockpit">
|
<div className="cockpit">
|
||||||
<div className="cockpit-header">
|
<div className="cockpit-header">
|
||||||
<select className="ui-input cockpit-project" value={pid} onChange={e => { setPid(e.target.value); setSelected(null); setFilter('') }} aria-label="选择项目">
|
<select className="ui-input cockpit-project" value={pid} onChange={e => { setPid(e.target.value); setSelected(null); setFilter('') }} aria-label="选择项目">
|
||||||
{projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
{projects.map(p => <option key={p.projectId} value={p.projectId}>{p.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
{filter && <button type="button" className="cockpit-filter-chip" onClick={() => setFilter('')}>
|
{filter && <button type="button" className="cockpit-filter-chip" onClick={() => setFilter('')}>
|
||||||
筛选:{STAT_FILTERS.find(s => s.filter === filter)?.label || filter} ✕
|
筛选:{STAT_FILTERS.find(s => s.filter === filter)?.label || filter} ✕
|
||||||
@@ -195,7 +197,9 @@ export function DocumentCockpit() {
|
|||||||
)}
|
)}
|
||||||
{overview && selected && viewMode === 'panorama' && (
|
{overview && selected && viewMode === 'panorama' && (
|
||||||
<PanoramaNode pid={pid} selection={selected} overview={overview}
|
<PanoramaNode pid={pid} selection={selected} overview={overview}
|
||||||
|
snapRev={overview.meta.snapshotRevision}
|
||||||
childPage={selected.ref.kind === 'document' ? treePages[selected.ref.id] : undefined}
|
childPage={selected.ref.kind === 'document' ? treePages[selected.ref.id] : undefined}
|
||||||
|
density={density}
|
||||||
onClear={() => setSelected(null)}
|
onClear={() => setSelected(null)}
|
||||||
onOpenReader={() => applyPrefs({ lastView: 'reader' })} />
|
onOpenReader={() => applyPrefs({ lastView: 'reader' })} />
|
||||||
)}
|
)}
|
||||||
@@ -287,9 +291,11 @@ function PanoramaHome({ overview, onSelect }: { overview: CockpitOverview; onSel
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PanoramaNode({ pid, selection, overview, childPage, onClear, onOpenReader }: {
|
function PanoramaNode({ pid, selection, overview, childPage, density, snapRev, onClear, onOpenReader }: {
|
||||||
pid: string; selection: Selection; overview: CockpitOverview
|
pid: string; selection: Selection; overview: CockpitOverview
|
||||||
childPage?: { items: CockpitTreeNode[]; nextCursor?: string; hasMore: boolean; meta?: CockpitMeta }
|
childPage?: { items: CockpitTreeNode[]; nextCursor?: string; hasMore: boolean; meta?: CockpitMeta }
|
||||||
|
density: Density
|
||||||
|
snapRev: string
|
||||||
onClear: () => void
|
onClear: () => void
|
||||||
onOpenReader: () => void
|
onOpenReader: () => void
|
||||||
}) {
|
}) {
|
||||||
@@ -300,10 +306,17 @@ function PanoramaNode({ pid, selection, overview, childPage, onClear, onOpenRead
|
|||||||
const g = ++gen.current
|
const g = ++gen.current
|
||||||
setDetail(null); setErr('')
|
setDetail(null); setErr('')
|
||||||
api.rdmCommand<any>('rdm.cockpit.node', { projectId: pid, kind: selection.ref.kind, id: selection.ref.id })
|
api.rdmCommand<any>('rdm.cockpit.node', { projectId: pid, kind: selection.ref.kind, id: selection.ref.id })
|
||||||
.then(d => { if (g === gen.current) setDetail(d) })
|
.then(d => {
|
||||||
|
if (g !== gen.current) return
|
||||||
|
// 后端空列表可能投影为 null(Go nil slice);统一归一,消费端按数组使用。
|
||||||
|
for (const k of ['sources', 'history', 'impacts', 'conflicts'] as const) {
|
||||||
|
if (d?.[k] && !Array.isArray(d[k].items)) d[k].items = []
|
||||||
|
}
|
||||||
|
setDetail(d)
|
||||||
|
})
|
||||||
.catch(e => { if (g === gen.current) setErr(e.message) })
|
.catch(e => { if (g === gen.current) setErr(e.message) })
|
||||||
return () => { gen.current++ }
|
return () => { gen.current++ }
|
||||||
}, [pid, selection.ref.kind, selection.ref.id])
|
}, [pid, selection.ref.kind, selection.ref.id, snapRev])
|
||||||
const node = (detail?.node || null) as CockpitTreeNode | null
|
const node = (detail?.node || null) as CockpitTreeNode | null
|
||||||
const title = node?.title || selection.ref.id
|
const title = node?.title || selection.ref.id
|
||||||
return (
|
return (
|
||||||
@@ -318,6 +331,9 @@ function PanoramaNode({ pid, selection, overview, childPage, onClear, onOpenRead
|
|||||||
{detail?.body?.length === 1 && detail.body[0].kind === 'restricted' && (
|
{detail?.body?.length === 1 && detail.body[0].kind === 'restricted' && (
|
||||||
<div className="empty-note">内容受限({detail.body[0].reason || '权限'})——标题与状态可见,正文不展示</div>
|
<div className="empty-note">内容受限({detail.body[0].reason || '权限'})——标题与状态可见,正文不展示</div>
|
||||||
)}
|
)}
|
||||||
|
{selection.ref.kind === 'section' && detail?.versions && detail.versions.length > 0 && (
|
||||||
|
<VersionBlock versions={detail.versions} density={density} />
|
||||||
|
)}
|
||||||
{detail && detail.meta && !sameSnapshot(detail.meta, overview.meta) && (
|
{detail && detail.meta && !sameSnapshot(detail.meta, overview.meta) && (
|
||||||
<div className="cockpit-truncated">节点详情与统计快照不同({detail.meta.snapshotRevision} vs {overview.meta.snapshotRevision})——刷新对齐</div>
|
<div className="cockpit-truncated">节点详情与统计快照不同({detail.meta.snapshotRevision} vs {overview.meta.snapshotRevision})——刷新对齐</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user