Prechádzať zdrojové kódy

fix(cockpit): 阻止已取消的视频启动并核实测试接线

ChenJZ 1 deň pred
rodič
commit
02f33d6

+ 10 - 3
docs/cockpit-video-monitoring-acceptance.md

@@ -42,8 +42,8 @@
 
 ## 离线验证(2026-11-11,审查前)
 
-- 前端:[入口旅程](<../tests/cockpit-video/journey.mjs>) **13/13**、[播放器边界](<../tests/cockpit-video/player.mjs>) **10/10**,共23项全通过。实际Vue SFC编译/Pinia store/LinkeDOM按钮事件走公开详情→最新指标→监控,不用私有函数或源码关键字代替入口。媒体播放与Peer是外部替身。
-- [测试运行器](<../tests/cockpit-video/run.mjs>)的 `--baseline-only` 用开始时前端基点加本票插入运行同一13项:全通过。既有其它在制源文件不混入纯本票验证;工作树验证亦通过。既有mapService/panelBlocks重复键警告未在本票扩大修复。
+- 前端:[入口旅程](<../tests/cockpit-video/journey.mjs>) **13/13**、[播放器边界](<../tests/cockpit-video/player.mjs>) **10/10**、[公开会话竞态](<../tests/cockpit-video/session.mjs>) **2/2**,共25项全通过。实际Vue SFC编译/Pinia store/LinkeDOM按钮事件走公开详情→最新指标→监控,不用私有函数或源码关键字代替入口。媒体播放与Peer是外部替身。
+- [测试运行器](<../tests/cockpit-video/run.mjs>)的 `--baseline-only` 用开始时前端基点与已提交详情入口快照运行同一13项:全通过,不在夹具中重写产品接线。既有其它在制源文件不混入纯本票验证;工作树验证亦通过。既有mapService/panelBlocks重复键警告未在本票扩大修复。
 - 后端:[新HTTP真实安全链](<E:/pipe-ner-server/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitVideoControllerTest.java>)60项、01旧HTTP36项与会话31项,**127项全通过**;真实SecurityConfig/JWT/方法权限/新旧业务模块,DB/Redis/媒体仅边界替身。
 - 原静态匿名规则测试因“只能有一条GET放行”与新视频例外冲突而RED;以7项实际HTTP鉴权行为覆盖旧GET/POST/default/WS路径后替换,不简单修改源码计数或禁用过滤器。只证明WS鉴权路径,不声称真实socket握手。
 - 完整后端管网reactor已运行:zksy-system 256项有2失败/1错误,zksy-admin 811项有17失败,总1067项含**20项非视频失败/错误**,与01记录的类别一致。本票不改这些模块,未另跑原始基点严格归因;`maven.test.failure.ignore=true`仅用于收齐结果,退出0不代表全绿。
@@ -55,11 +55,18 @@
 node tests/cockpit-video/run.mjs
 node tests/cockpit-video/run.mjs --baseline-only
 node tests/cockpit-video/player.mjs
+node tests/cockpit-video/session.mjs
 # 在 E:/pipe-ner-server 根目录
 JAVA_TOOL_OPTIONS=-Dfile.encoding=UTF-8 mvn -o -pl pipe-network-service/zksy-admin -am -Dtest=CockpitVideoControllerTest,VideoDeviceCompatibilityTest,VideoSessionCompatibilityTest -Dsurefire.failIfNoSpecifiedTests=false test
 JAVA_TOOL_OPTIONS=-Dfile.encoding=UTF-8 mvn -o -pl pipe-network-service/zksy-admin -am -Dmaven.test.failure.ignore=true test
 ```
 
+## 双轴审查(按用户确认的两仓基点)
+
+- Standards:首轮硬违规0,低优先建议2;已移除baseline测试中重复的产品接线片段,改读已提交详情组件快照;心跳测试标题已缩窄到实际播放后归属维护/错误清理,不夸大覆盖。
+- Spec:首轮确定阻断1——`start`在`await stop()`之后捕获代次,可能越过同轮close或新设备打开。已恢复停止前捕获代次,并以公开会话`start(A)/start(B)`、`start(A)/stop()`驱动两个回归,RED→GREEN;其余没有确证02缺项或越界。
+- 后续03–07的多宿主强化、五次重连、历史和物理云台验证不追认为本票已完成;真实网络与播放器编解码继续保持待验。
+
 ## 当前收尾状态
 
-前后端实现及离线回归已完成,双轴审查和独立提交进行中;本记录不宣称真实视频交付。由于项目规则已跳过应用build/debug、浏览器调试/截图、服务启停、真实数据库迁移和设备操作。所有实际验证均限定在用户已授权的离线编译/测试范围。
+前后端实现及离线回归已完成,审查阻断与两项测试建议均已修复,正在最后读回;已独立提交前端cafb1652、后端01a66fad,未推送。代次修复另作中文独立提交。本记录不宣称真实视频交付。由于项目规则已跳过应用build/debug、浏览器调试/截图、服务启停、真实数据库迁移和设备操作。所有实际验证均限定在用户已授权的离线编译/测试范围。

+ 3 - 3
src/modules/cockpit/services/videoLiveSession.js

@@ -46,12 +46,12 @@ export function createVideoLiveSession(api, update, timers = globalThis) {
     }
     function ready() { timers.clearTimeout(readyTimeout); if (owned && !disposed) update({ phase: 'playing' }) }
     async function start(deviceCode) {
-        await stop()
-        if (disposed) return
+        const stopping = stop(), version = generation
+        await stopping
+        if (disposed || version !== generation) return
         if (pending || uncertain || releases.size) {
             update({ phase: 'failed', error: '上次播放或清理结果尚未确认,暂不能重复建流' }); return
         }
-        const version = generation
         update({ phase: 'starting', media: null, error: '', cleanupError: '' })
         pending = true
         let startExpired = false

+ 1 - 1
tests/cockpit-video/journey.mjs

@@ -87,7 +87,7 @@ class TestClock {
     }
 }
 
-test('watching heartbeat preserves ownership but never establishes media readiness; failure releases the viewer', async () => {
+test('watching heartbeat preserves its session ownership and failure releases the viewer', async () => {
     const clock = new TestClock(); let wrong = false
     const page = await mountJourney({ sessionOptions: { timers: clock }, heartbeat: () => envelope({ sessionId: wrong ? 'other' : 'session-a', status: 'ACTIVE' }) })
     try {

+ 2 - 8
tests/cockpit-video/run.mjs

@@ -32,14 +32,8 @@ const ownSources = new Set([
 async function sourceAt(path) {
     const name = relative(process.cwd(), path).replaceAll('\\', '/')
     if (!baselineOnly || !name.startsWith('src/') || ownSources.has(name)) return readFile(path, 'utf8')
-    let source = execFileSync('git', ['show', `${base}:${name}`], { encoding: 'utf8' })
-    if (name === 'src/modules/cockpit/components/EquipmentDetailSections.vue') {
-        const anchor = '        <div class="equipment-tab__body" role="tabpanel" data-equipment-tab-body>'
-        if (source.split(anchor).length !== 2) throw new Error('Video entry insertion anchor must be unique')
-        source = source.replace(anchor, `${anchor}\n            <VideoMonitorEntry v-if="activeTab === 'metrics'" :device-code="deviceCode" />`)
-            .replace("import SectionStateTag from './SectionStateTag.vue'", "import VideoMonitorEntry from './VideoMonitorEntry.vue'\nimport SectionStateTag from './SectionStateTag.vue'")
-    }
-    return source
+    const revision = name === 'src/modules/cockpit/components/EquipmentDetailSections.vue' ? 'HEAD' : base
+    return execFileSync('git', ['show', `${revision}:${name}`], { encoding: 'utf8' })
 }
 const output = resolve(`.scratch/cockpit-video-monitoring/test-output/journey${baselineOnly ? '-baseline' : ''}.mjs`)
 await mkdir(resolve('.scratch/cockpit-video-monitoring/test-output'), { recursive: true })

+ 32 - 0
tests/cockpit-video/session.mjs

@@ -0,0 +1,32 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { createVideoLiveSession } from '../../src/modules/cockpit/services/videoLiveSession.js'
+
+const data = (code) => ({ deviceCode: code, sessionId: `session-${code}`, sessionToken: `owner-${code}`, mode: 'LIVE', media: { native: true, url: `https://media.invalid/${code}.mp4` } })
+
+test('simultaneous open commands never create a video for a superseded device', async () => {
+    const requests = [], updates = []
+    const live = createVideoLiveSession({
+        start: async (code) => { requests.push(code); return data(code) },
+        heartbeat: async () => {}, stop: async () => {},
+    }, (patch) => updates.push(patch))
+    try {
+        await Promise.all([live.start('A'), live.start('B')])
+        assert.deepEqual(requests, ['B'])
+        assert.equal(updates.some((patch) => patch.media?.url.endsWith('/A.mp4')), false)
+    } finally { await live.dispose() }
+})
+
+test('closing in the same turn cancels an open before it can start server media', async () => {
+    const requests = []
+    const live = createVideoLiveSession({
+        start: async (code) => { requests.push(code); return data(code) },
+        heartbeat: async () => {}, stop: async () => {},
+    }, () => {})
+    try {
+        const opening = live.start('A')
+        await live.stop()
+        await opening
+        assert.deepEqual(requests, [])
+    } finally { await live.dispose() }
+})