Преглед изворни кода

fix(cockpit): 补齐真实三入口刷新与同轮退出回归

ChenJZ пре 8 часа
родитељ
комит
ac5419cd05

+ 2 - 2
src/modules/cockpit/components/VideoMonitorPanel.vue

@@ -37,11 +37,11 @@ import VideoPlayer from './VideoPlayer.vue'
 const video = useVideoStore(), ui = useUiStore(), detail = useDetailStore(), enhancement = useEnhancementDevicesStore()
 const { state, playerOptions } = storeToRefs(video)
 const labels = { idle: '未播放', starting: '正在建立视频会话', connecting: '等待视频画面', playing: '正在播放', failed: '播放失败' }
-watch(() => ui.mode, () => { void video.close() })
+watch(() => ui.mode, () => { void video.close() }, { flush: 'sync' })
 watch(() => [state.value.ownerSource, detail.selection.deviceCode, enhancement.selected?.code], ([ownerSource, detailCode, enhancementCode]) => {
     const currentCode = ownerSource === 'enhancement' ? enhancementCode : detailCode
     if (state.value.open && currentCode !== state.value.device?.deviceCode) void video.close()
-})
+}, { flush: 'sync' })
 let trigger
 watch(() => state.value.open, async (open) => {
     if (!open) { if (trigger?.isConnected) trigger.focus?.(); return }

+ 7 - 0
src/modules/cockpit/stores/videoStore.js

@@ -2,6 +2,8 @@ import { shallowRef } from 'vue'
 import { defineStore } from 'pinia'
 import { createVideoApi, VideoRequestError } from '../services/videoApi.js'
 import { createVideoLiveSession } from '../services/videoLiveSession.js'
+import { useDetailStore } from './detailStore.js'
+import { useEnhancementDevicesStore } from './enhancementDevicesStore.js'
 
 export const useVideoStore = defineStore('cockpitVideo', () => {
     const state = shallowRef({ open: false, device: null, ownerSource: 'detail', phase: 'idle', media: null, error: '', cleanupError: '' })
@@ -23,6 +25,11 @@ export const useVideoStore = defineStore('cockpitVideo', () => {
     }
     async function open(device, ownerSource = 'detail') {
         if (!['detail', 'enhancement'].includes(ownerSource)) throw new Error('监控入口来源不合法')
+        const selection = useDetailStore().selection
+        const selectedCode = ownerSource === 'enhancement' ? useEnhancementDevicesStore().selected?.code
+            : selection.level === 'equipment' ? selection.deviceCode : ''
+        // A detached/late entry cannot take ownership from a still-valid viewer.
+        if (!selectedCode || selectedCode !== device?.deviceCode) return
         if (state.value.open && state.value.device?.deviceCode === device?.deviceCode) { publish({ ownerSource }); return }
         publish({ open: true, device, ownerSource, error: '' })
         if (device?.typeCode !== '20' || device.bound !== true || device.capabilities?.live !== true) {

+ 236 - 75
tests/cockpit-video/hosts.mjs

@@ -1,15 +1,16 @@
 import assert from 'node:assert/strict'
 import test from 'node:test'
-import { createApp, h, nextTick } from 'vue'
+import { createApp, nextTick } from 'vue'
 import { createPinia, setActivePinia } from 'pinia'
 import CockpitRoot from '../../src/pages/cockpit/index.vue'
-import PipeEnhancement from '../../src/modules/cockpit/components/PipeEnhancement.vue'
-import VideoMonitorPanel from '../../src/modules/cockpit/components/VideoMonitorPanel.vue'
-import { useEnhancementDevicesStore } from '../../src/modules/cockpit/stores/enhancementDevicesStore.js'
 import { loadCockpitConfig, resetCockpitConfigForTest } from '../../src/modules/cockpit/services/cockpitConfig.js'
 import { useVideoStore } from '../../src/modules/cockpit/stores/videoStore.js'
 
-const settle = async () => { for (let i = 0; i < 8; i++) { await new Promise(setImmediate); await nextTick() } }
+const settle = async () => { for (let i = 0; i < 8; i++) { await new Promise((resolve) => setTimeout(resolve, 0)); await nextTick() } }
+async function until(predicate, message) {
+    for (let i = 0; i < 20; i++) { if (predicate()) return; await settle() }
+    assert.ok(predicate(), message)
+}
 const envelope = (data, status = 200) => Response.json({ code: 200, data }, { status })
 const cameraCode = 'CAM/03 空格'
 const coordinate = { state: 'INVALID', renderable: false, reasons: ['MISSING_LONGITUDE'], longitude: null, latitude: null }
@@ -23,11 +24,11 @@ const aggregate = { deviceCode: cameraCode, aggregateState: 'READY',
     warnings: { state: 'EMPTY', data: [] }, alarms: { state: 'EMPTY', data: [] }, workOrders: { state: 'EMPTY', data: null },
     timelines: { state: 'EMPTY', data: null }, latestImage: { state: 'EMPTY', data: null } }
 const emptyEndpoints = new Set(['/overview/perception', '/overview/pipe-network', '/overview/warning-study', '/overview/alarm-disposal',
-    '/monitoring/overview', '/monitoring/situation', '/events/current'])
+    '/monitoring/overview', '/monitoring/situation', '/events/current', '/infrastructure/operating-information', '/infrastructure/base-archives'])
 
 function installGpu() {
     const viewers = []
-    let visible = true, time = 0
+    let visible = true, time = 0, pipeHit = null
     const frame = () => { for (const viewer of viewers) for (const callback of [...viewer.frames]) callback() }
     const projection = () => visible ? { x: 800, y: 450, visible: true, iconSize: 20 } : null
     const gpu = {
@@ -37,7 +38,8 @@ function installGpu() {
             const viewer = { canvas, frames, points: [], devices: [],
                 camera: { positionWC: { x: 0, y: 0, z: 6371000 }, directionWC: { x: 0, y: 0, z: -1 }, upWC: { x: 0, y: 1, z: 0 } },
                 scene: { canvas, globe: { ellipsoid: {} }, requestRender() {},
-                postRender: { addEventListener(callback) { frames.add(callback); return () => frames.delete(callback) } } } }
+                postRender: { addEventListener(callback) { frames.add(callback); return () => frames.delete(callback) },
+                    removeEventListener(callback) { frames.delete(callback) } } } }
             viewers.push(viewer); return viewer
         },
         destroyViewer(viewer) { viewer.frames.clear(); viewer.canvas.remove(); viewers.splice(viewers.indexOf(viewer), 1) },
@@ -62,35 +64,65 @@ function installGpu() {
         projectDeviceMarkers: (viewer) => viewer.devices.map((row) => ({ ...row, ...projection() })),
         projectOverlapSymbols: () => [], readPointMarkers: (viewer) => viewer.points,
         readAssociatedDevices: (viewer) => viewer.devices, readDeviceEffects: () => [], readOverlapTargets: () => [],
-        highlightEntities: () => 1, syncPipeLayer: async () => ({ pipes: 0, components: 0, skipped: 0 }), pickPipeObjectAt: () => null,
+        highlightEntities: () => 1, syncPipeLayer: async () => ({ pipes: 0, components: 0, skipped: 0 }), pickPipeObjectAt: () => pipeHit,
     }
     for (const name of ['applyViewModeLayers', 'applyMotionResolution', 'applyDeviceEffects', 'applyDeviceReactions',
         'clearDeviceLead', 'syncDeviceLead', 'setOverlapPresentation', 'clearOverlapNudge', 'noteFrameInterval', 'tickAdaptiveResolution']) gpu[name] = () => {}
     globalThis.__VIDEO_GPU__ = gpu
-    return { frame, viewers, setVisible(value) { visible = value; frame() } }
+    return { frame, viewers, setVisible(value) { visible = value; frame() },
+        pickPipe(code = 'PIPE-1') {
+            pipeHit = { objectKind: 'pipe', code }
+            for (const type of ['pointerdown', 'pointerup']) {
+                const event = new Event(type, { bubbles: true })
+                Object.assign(event, { button: 0, pointerId: 1, clientX: 800, clientY: 450 })
+                viewers[0].canvas.dispatchEvent(event)
+            }
+            pipeHit = null
+        } }
 }
 
-async function mountRoot({ positioned = false } = {}) {
+async function mountRoot({ positioned = false, underground = false } = {}) {
     const gpu = installGpu()
+    globalThis.__VIDEO_THREE_OPTIONS__ = {
+        rendererFactory: ({ canvas }) => ({ domElement: canvas, setPixelRatio() {}, setSize() {}, render() {}, dispose() {} }),
+        iconResolver: async () => null, loadTexture: () => null,
+        now: () => 1000, scheduleFrame: () => {},
+    }
     const location = positioned ? { state: 'VALID', renderable: true, reasons: [] } : coordinate
     const selectedPoint = { ...point, pointCode: positioned ? 'P-FLOAT' : 'P-FIXED',
         longitude: positioned ? 110.4 : null, latitude: positioned ? 28.4 : null, coordinateValidation: location }
     const selectedCamera = { ...camera, longitude: selectedPoint.longitude, latitude: selectedPoint.latitude, coordinateValidation: location }
     const requests = [], unexpected = [], errors = []
+    const server = { snapshotGate: null, pointGate: null, pointFails: false, cameraPresent: true }
     const fetchImpl = async (address, options = {}) => {
         const url = new URL(address), path = url.pathname.replace('/api/cockpit/v1', '')
         const body = options.body ? JSON.parse(options.body) : null
         requests.push({ url, path, body, options })
         if (url.origin !== 'http://fixture.invalid') { unexpected.push(url.href); throw new Error('Offline origin rejected') }
         assert.equal(new Headers(options.headers).get('authorization'), null)
-        if (path === '/map-snapshot') return envelope({ page: 1, pageSize: 100, snapshotAt: '2026-11-11T00:00:00Z',
-            batchUpdatedAt: '2026-11-11T00:00:00Z', total: 1, complete: true, monitoringPoints: [selectedPoint], unassociatedEquipment: [] })
+        if (path === '/map-snapshot') {
+            if (server.snapshotGate) await server.snapshotGate
+            return envelope({ page: 1, pageSize: 100, snapshotAt: '2026-11-11T00:00:00Z',
+                batchUpdatedAt: '2026-11-11T00:00:00Z', total: 1, complete: true,
+                monitoringPoints: [{ ...selectedPoint, deviceCodes: server.cameraPresent ? [cameraCode] : [] }], unassociatedEquipment: [] })
+        }
         if (path === '/monitoring/distribution') return envelope({ monitoringPoints: [selectedPoint], unassociatedEquipment: [],
             snapshotAt: '2026-11-11T00:00:00Z', pagination: { page: 1, pageSize: 100, total: 1, totalPages: 1, hasMore: false } })
-        if (path === `/monitoring-points/${selectedPoint.pointCode}`) return envelope({ monitoringPointCode: selectedPoint.pointCode,
-            monitoringPointName: point.pointName, pointStatus: 'NORMAL', coordinateValidation: location,
-            deviceStatistics: { totalDeviceCount: 1, normalDeviceCount: 1 }, devices: [selectedCamera] })
-        if (path.startsWith('/equipments/') && path.endsWith('/detail-aggregate')) return envelope(aggregate)
+        if (path === `/monitoring-points/${selectedPoint.pointCode}`) {
+            if (server.pointGate) await server.pointGate
+            if (server.pointFails) return envelope({}, 503)
+            return envelope({ monitoringPointCode: selectedPoint.pointCode,
+                monitoringPointName: point.pointName, pointStatus: 'NORMAL', coordinateValidation: location,
+                deviceStatistics: { totalDeviceCount: server.cameraPresent ? 1 : 0, normalDeviceCount: server.cameraPresent ? 1 : 0 },
+                devices: server.cameraPresent ? [selectedCamera] : [] })
+        }
+        if (path === '/infrastructure/geometry') return envelope({ type: 'FeatureCollection', features: underground ? [{
+            type: 'Feature', id: 'PIPE-1', properties: { code: 'PIPE-1', name: '地下测试管段', industry: 'DRAINAGE',
+                diameter: 300, material: '钢', coordinateValidation: { renderable: true } },
+            geometry: { type: 'LineString', coordinates: [[110.3998, 28.4], [110.4008, 28.4]] },
+        }] : [] })
+        if (path === '/device-facility-relations') return envelope({ rows: [] })
+        if (path.startsWith('/equipments/') && path.endsWith('/detail-aggregate')) return envelope({ ...aggregate, archive: { state: 'SUCCESS', data: selectedCamera } })
         if (path.startsWith('/equipments/') && path.endsWith('/collection-records')) return envelope({ deviceCode: cameraCode,
             state: 'EMPTY', recordView: { columns: [], rows: [] } })
         if (path.startsWith('/equipments/') && path.endsWith('/freshness')) return envelope({})
@@ -102,7 +134,7 @@ async function mountRoot({ positioned = false } = {}) {
         }
         if (path === '/video/sessions/session-host/live') {
             assert.deepEqual(body, { sessionToken: 'owner-host' })
-            return envelope({ sessionId: 'session-host', deviceCode: cameraCode, mode: 'LIVE', status: 'ACTIVE',
+            return envelope({ sessionId: 'session-host', mode: 'LIVE', status: 'ACTIVE',
                 media: { native: true, url: 'https://media.invalid/live.mp4' } })
         }
         if (path === '/video/sessions/session-host/stop' || path === '/video/sessions/session-host/status') {
@@ -122,24 +154,37 @@ async function mountRoot({ positioned = false } = {}) {
     const app = createApp(CockpitRoot); app.use(pinia)
     app.config.errorHandler = (error) => { errors.push(error) }
     app.mount(host); await settle()
-    return { host, requests, unexpected, errors, video, gpu, selectedPoint, async dispose() {
-        app.unmount(); await video.close(); host.remove(); delete globalThis.__COCKPIT_CONFIG__; await settle()
+    return { host, requests, unexpected, errors, video, gpu, selectedPoint, server, async dispose() {
+        app.unmount(); await video.close(); host.remove(); delete globalThis.__COCKPIT_CONFIG__; delete globalThis.__VIDEO_THREE_OPTIONS__; await settle()
         globalThis.fetch = async () => { throw new Error('Uninjected network request in offline video test') }
+        assert.deepEqual(errors, [], '包含根卸载期间的组件错误')
+        assert.deepEqual(unexpected, [], '包含卸载期间的未配置请求')
     } }
 }
 
+async function openMainDetail(page) {
+    const mode = [...page.host.querySelectorAll('button')].find((button) => button.textContent.trim() === '运行监测')
+    assert.ok(mode); mode.click(); await settle()
+    const ledger = page.host.querySelector(`[data-ledger-card="${page.selectedPoint.pointCode}"]`)
+    assert.ok(ledger, '使用真实监测点台账卡片')
+    ledger.click(); await settle()
+    const card = page.host.querySelector('.cockpit-shell__detail [data-device-card][data-device-code="CAM/03 空格"]')
+    assert.ok(card); card.click(); await settle(); page.gpu.frame(); await settle()
+    const detail = page.host.querySelector('.cockpit-shell__detail')
+    detail.querySelector('[data-equipment-tab="metrics"]').click(); await settle()
+    return detail
+}
+async function openMainMonitor(page) {
+    const detail = await openMainDetail(page)
+    detail.querySelector('[data-video-open]').click(); await settle()
+    return document.querySelector('[data-video-monitor]')
+}
+
 // 真root→实际Shell固定态→既有点位设备卡→Latest TAB→监控;不直接调用视频store.open。
 for (const positioned of [false, true]) test(`${positioned ? '浮动' : '无地图位置固定'}:真实台账设备详情打开唯一根监控面板`, async () => {
     const page = await mountRoot({ positioned })
     try {
-        const mode = [...page.host.querySelectorAll('button')].find((button) => button.textContent.trim() === '运行监测')
-        assert.ok(mode); mode.click(); await settle()
-        const ledger = page.host.querySelector(`[data-ledger-card="${page.selectedPoint.pointCode}"]`)
-        assert.ok(ledger, '使用真实监测点台账卡片')
-        ledger.click(); await settle()
-        const card = page.host.querySelector('[data-device-card][data-device-code="CAM/03 空格"]')
-        assert.ok(card); card.click(); await settle(); page.gpu.frame(); await settle()
-        const detail = page.host.querySelector('.cockpit-shell__detail')
+        const detail = await openMainDetail(page)
         assert.equal(detail.getAttribute('data-anchor-state'), positioned ? 'visible' : 'fixed')
         assert.equal(Boolean(detail.querySelector('[data-point-no-location]')), !positioned)
         detail.querySelector('[data-equipment-tab="metrics"]').click(); await settle()
@@ -160,58 +205,174 @@ for (const positioned of [false, true]) test(`${positioned ? '浮动' : '无地
     } finally { await page.dispose() }
 })
 
-// 地下管线宿主:真实 PipeEnhancement→其内设备详情→Latest TAB→监控;只替换物理渲染器。
-async function mountUnderground() {
-    const requests = [], unexpected = [], errors = []
-    const config = { dataSource: 'real', api: { baseUrl: 'http://fixture.invalid/api/cockpit/v1' } }
-    const fetchImpl = async (address, options = {}) => {
-        const url = new URL(address), path = url.pathname.replace('/api/cockpit/v1', '')
-        const body = options.body ? JSON.parse(options.body) : null
-        requests.push({ url, path, body, options })
-        if (url.origin !== 'http://fixture.invalid') { unexpected.push(url.href); throw new Error('Offline origin rejected') }
-        if (path === '/video/device') return envelope(device)
-        if (path === '/video/sessions/prepare') return envelope({ ...device, sessionId: 'session-under', sessionToken: 'owner-under', mode: 'LIVE', status: 'PREPARED' }, 201)
-        if (path === '/video/sessions/session-under/live') return envelope({ sessionId: 'session-under', deviceCode: cameraCode, mode: 'LIVE', status: 'ACTIVE', media: { native: true, url: 'https://media.invalid/under.mp4' } })
-        if (path === '/video/sessions/session-under/stop' || path === '/video/sessions/session-under/status') return envelope({ sessionId: 'session-under', status: 'STOPPED' })
-        unexpected.push(path); throw new Error('Unconfigured offline request: ' + path)
+test('已关闭的设备详情入口同轮迟到点击不得创建观看', async () => {
+    const page = await mountRoot()
+    try {
+        const detail = await openMainDetail(page)
+        const monitor = detail.querySelector('[data-video-open]')
+        detail.querySelector('[data-detail-close]').click()
+        monitor.click(); await settle()
+        assert.equal(Boolean(document.querySelector('[data-video-monitor]')), false)
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/prepare')).length, 0, '失效来源不能先prepare再补关')
+        assert.deepEqual(page.errors, [])
+        assert.deepEqual(page.unexpected, [])
+    } finally { await page.dispose() }
+})
+
+test('一级模式同轮离开再返回仍释放旧观看,返回须手动恢复', async () => {
+    for (const underground of [false, true]) {
+        const page = await mountRoot({ positioned: underground, underground })
+        try {
+            const mode = (text) => [...page.host.querySelectorAll('button')].find((button) => button.textContent.trim() === text)
+            mode('运行监测').click(); await settle()
+            if (underground) await openUnderground(page)
+            else await openMainMonitor(page)
+            mode('总体概览').click()
+            mode('运行监测').click()
+            await settle()
+            assert.equal(Boolean(document.querySelector('[data-video-monitor]')), false, '切模式必须释放旧观看,即使同轮返回')
+            assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1)
+            assert.equal(page.requests.filter((request) => request.path.endsWith('/prepare')).length, 1)
+            assert.deepEqual(page.errors, [])
+            assert.deepEqual(page.unexpected, [])
+        } finally { await page.dispose() }
     }
-    globalThis.fetch = fetchImpl
-    const pinia = createPinia(); setActivePinia(pinia)
-    const video = useVideoStore(); video.configure({ config, fetchImpl })
-    const enhancement = useEnhancementDevicesStore()
-    await enhancement.open({ code: cameraCode, name: camera.deviceName, placementText: '地下清单' },
-        { fetchDetail: async () => ({ state: 'success', data: aggregate }) })
-    const plan = { visible: true, radiusM: 50, disclaimer: '显示示意', connectionNote: '仅供导引',
-        object: { title: '重点管段 P-1', code: 'PIPE-1', sourceKind: 'pipe', renderKind: 'pipe', colorKey: 'UNKNOWN', diameterM: 0.3, path: [[110.4, 28.4], [110.4008, 28.4006], [110.4016, 28.4012]] },
-        objects: [], devices: [], deviceList: [], coverage: null, deviceCoverage: null }
-    const fakeRenderer = () => ({ setPixelRatio() {}, setSize() {}, render() {}, dispose() {},
-        domElement: document.createElement('canvas') })
-    const host = document.createElement('main'); document.body.appendChild(host)
-    const app = createApp({ render: () => h('div', [
-        h(PipeEnhancement, { plan, deviceState: enhancement, rendererOptions: { rendererFactory: fakeRenderer } }),
-        h(VideoMonitorPanel)]) })
-    app.config.errorHandler = (error) => { errors.push(error) }
-    app.use(pinia).mount(host); await settle()
-    return { host, requests, unexpected, errors, video, enhancement, async dispose() {
-        app.unmount(); await video.close(); host.remove(); await settle()
-        globalThis.fetch = async () => { throw new Error('Uninjected network request in offline video test') }
-    } }
+})
+
+// 真Root/Shell:地图物理拾取→实际管线计划→地下设备列表点击→Latest TAB→唯一根面板。
+async function openUnderground(page, { monitor: open = true } = {}) {
+    await until(() => page.gpu.viewers.length && page.host.querySelector('[data-pipe-legend-count="1"]'), '等待真实管线几何进入图例后再点选')
+    page.gpu.pickPipe(); await settle()
+    const underground = page.host.querySelector('[data-pipe-enhancement]')
+    assert.ok(underground, JSON.stringify({ unexpected: page.unexpected, errors: page.errors.map((e) => e.message) }))
+    assert.ok(underground.querySelector('[data-pipe-enhancement-viewport] canvas'), '真实 mountPipeEnhancement 挂载物理渲染替身')
+    assert.equal(underground.querySelector('[data-equipment-tab="metrics"]'), null, '必须先从地下列表选择设备')
+    const card = underground.querySelector('[data-device-list] [data-device-code="CAM/03 空格"]')
+    assert.ok(card, underground.textContent)
+    card.click(); await settle()
+    const latest = underground.querySelector('[data-equipment-tab="metrics"]')
+    assert.ok(latest, underground.textContent)
+    latest.click(); await settle()
+    const monitor = underground.querySelector('[data-video-open]')
+    assert.ok(monitor); assert.equal(monitor.disabled, false)
+    if (!open) return monitor
+    monitor.click(); await settle()
+    return document.querySelector('[data-video-monitor]')
 }
 
-test('地下管线详情:真实设备详情打开唯一根监控面板且来源为 enhancement', async () => {
-    const page = await mountUnderground()
+test('三宿主同设备刷新保持同一媒体;浮动锚点暂隐不释放观看', async () => {
+    for (const host of ['fixed', 'floating', 'underground']) {
+        const page = await mountRoot({ positioned: host !== 'fixed', underground: host === 'underground' })
+        try {
+            const dialog = host === 'underground' ? await openUnderground(page) : await openMainMonitor(page)
+            const media = dialog.querySelector('video')
+            media.dispatchEvent(new Event('playing')); await settle()
+            let releaseSnapshot, releasePoint
+            page.server.snapshotGate = new Promise((resolve) => { releaseSnapshot = resolve })
+            page.server.pointGate = new Promise((resolve) => { releasePoint = resolve })
+            const before = page.requests.filter((request) => request.path === '/map-snapshot').length
+            const beforeDetails = page.requests.filter((request) => request.path.endsWith('/detail-aggregate')).length
+            page.host.querySelector('[data-map-refresh]').click(); await settle()
+            assert.equal(page.requests.filter((request) => request.path === '/map-snapshot').length, before + 1)
+            assert.ok(document.querySelector('[data-video-monitor] video') === media, `${host}: 摘要刷新等待期保留媒体`)
+            releaseSnapshot(); page.server.snapshotGate = null; await settle()
+            assert.ok(document.querySelector('[data-video-monitor] video') === media, `${host}: 设备清单等待期保留媒体`)
+            releasePoint(); page.server.pointGate = null; await settle()
+            await until(() => page.requests.filter((request) => request.path.endsWith('/detail-aggregate')).length > beforeDetails,
+                `${host}: 确认真实详情重新取数,而非只等待空刷新`)
+            assert.ok(document.querySelector('[data-video-monitor] video') === media, `${host}: 新数据不能重挂播放器`)
+            if (host === 'floating') {
+                page.gpu.setVisible(false); await settle()
+                assert.notEqual(page.host.querySelector('.cockpit-shell__detail').getAttribute('data-anchor-state'), 'visible')
+                assert.ok(document.querySelector('[data-video-monitor] video') === media)
+                page.gpu.setVisible(true); await settle()
+                assert.equal(page.host.querySelector('.cockpit-shell__detail').getAttribute('data-anchor-state'), 'visible')
+            }
+            assert.ok(dialog.textContent.includes('正在播放'))
+            assert.equal(page.requests.filter((request) => request.path.endsWith('/prepare')).length, 1)
+            assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 0)
+            assert.deepEqual(page.errors, [])
+            assert.deepEqual(page.unexpected, [])
+        } finally { await page.dispose() }
+    }
+})
+
+test('同码合法换来源复用媒体,非所属详情关闭不误停', async () => {
+    const page = await mountRoot({ positioned: true, underground: true })
     try {
-        const detail = page.host.querySelector('[data-enhancement-device-detail]')
-        assert.ok(detail, JSON.stringify({ text: page.host.textContent, unexpected: page.unexpected, errors: page.errors.map((error) => error.message) }))
-        detail.querySelector('[data-equipment-tab="metrics"]').click(); await settle()
-        const monitor = detail.querySelector('[data-video-open]')
-        assert.ok(monitor); monitor.click(); await settle()
-        assert.equal(page.video.state.ownerSource, 'enhancement')
-        const panels = document.querySelectorAll('[data-video-monitor]')
-        assert.equal(panels.length, 1)
-        assert.ok(panels[0].querySelector('video'), JSON.stringify({ text: panels[0].textContent, unexpected: page.unexpected }))
-        panels[0].querySelector('[aria-label="关闭监控"]').click(); await settle()
+        const dialog = await openMainMonitor(page)
+        const media = dialog.querySelector('video')
+        await openUnderground(page)
+        assert.ok(document.querySelector('[data-video-monitor] video') === media)
+        page.host.querySelector('.cockpit-shell__detail [data-detail-close]').click(); await settle()
+        assert.ok(document.querySelector('[data-video-monitor] video') === media, '地下接管后关闭主详情不误停')
+        await openMainMonitor(page)
+        assert.ok(document.querySelector('[data-video-monitor] video') === media)
+        page.host.querySelector('[data-pipe-enhancement-close]').click(); await settle()
+        assert.ok(document.querySelector('[data-video-monitor] video') === media, '主详情接管后关闭地下不误停')
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/prepare')).length, 1)
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 0)
+        page.host.querySelector('.cockpit-shell__detail [data-detail-close]').click(); await settle()
+        assert.equal(Boolean(document.querySelector('[data-video-monitor]')), false)
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1)
+        assert.deepEqual(page.errors, [])
+        assert.deepEqual(page.unexpected, [])
+    } finally { await page.dispose() }
+})
+
+test('地下刷新失败保留观看,完整快照确认设备移除才关闭且返回不自动重开', async () => {
+    const page = await mountRoot({ positioned: true, underground: true })
+    try {
+        const dialog = await openUnderground(page)
+        const media = dialog.querySelector('video')
+        page.server.pointFails = true
+        page.host.querySelector('[data-map-refresh]').click(); await settle()
+        assert.ok(document.querySelector('[data-video-monitor] video') === media, '失败不证明设备失效')
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 0)
+        page.server.pointFails = false
+        page.server.cameraPresent = false
+        page.host.querySelector('[data-map-refresh]').click(); await settle()
+        assert.equal(Boolean(document.querySelector('[data-video-monitor]')), false, '完整数据确认移除需释放观看')
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1)
+        page.server.cameraPresent = true
+        page.host.querySelector('[data-map-refresh]').click(); await settle()
+        assert.ok(page.host.querySelector('[data-pipe-enhancement] [data-device-code="CAM/03 空格"]'))
+        assert.equal(Boolean(document.querySelector('[data-video-monitor]')), false)
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/prepare')).length, 1)
+        assert.deepEqual(page.errors, [])
+        assert.deepEqual(page.unexpected, [])
+    } finally { await page.dispose() }
+})
+
+test('失效的地下同码入口不能抢走主详情正在观看的资源', async () => {
+    const page = await mountRoot({ positioned: true, underground: true })
+    try {
+        const dialog = await openMainMonitor(page)
+        const media = dialog.querySelector('video')
+        const staleEntry = await openUnderground(page, { monitor: false })
+        page.host.querySelector('[data-pipe-enhancement-close]').click()
+        staleEntry.click(); await settle()
+        assert.equal(Boolean(document.querySelector('[data-video-monitor]')), true, '失效来源不应关闭合法来源的观看')
+        assert.equal(document.querySelector('[data-video-monitor] video'), media)
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/prepare')).length, 1)
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 0)
+        assert.deepEqual(page.errors, [])
+        assert.deepEqual(page.unexpected, [])
+    } finally { await page.dispose() }
+})
+
+test('地下管线详情:真实Root列表点设备经Latest TAB打开唯一监控面板', async () => {
+    const page = await mountRoot({ positioned: true, underground: true })
+    try {
+        const dialog = await openUnderground(page)
+        assert.equal(document.querySelectorAll('[data-video-monitor]').length, 1)
+        assert.ok(dialog.textContent.includes(cameraCode))
+        assert.ok(dialog.querySelector('video'))
+        dialog.querySelector('video').dispatchEvent(new Event('playing')); await settle()
+        assert.ok(dialog.textContent.includes('正在播放'))
+        page.host.querySelector('[data-pipe-enhancement-close]').click(); await settle()
         assert.equal(document.querySelector('[data-video-monitor]'), null)
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/prepare')).length, 1)
         assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1)
         assert.deepEqual(page.unexpected, [])
         assert.deepEqual(page.errors, [])

+ 13 - 0
tests/cockpit-video/journey.mjs

@@ -82,6 +82,19 @@ test('closing the originating main detail releases video even if another host re
     } finally { await page.dispose() }
 })
 
+test('selection invalidation and same-code restoration in one turn still ends the old viewing session', async () => {
+    const page = await mountJourney()
+    try {
+        await openMonitor(page)
+        page.detail.closeDetail()
+        const restored = page.detail.openDevice(code)
+        await restored; await settle()
+        assert.equal(Boolean(document.querySelector('[data-video-monitor]')), false, 'restoring selection must not resurrect its previous viewer')
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/prepare')).length, 1)
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1)
+    } finally { await page.dispose() }
+})
+
 test('the underground host owns its own viewing source and a main-detail close does not stop it', async () => {
     const page = await mountJourney({ entryOnly: true })
     try {

+ 14 - 1
tests/cockpit-video/run.mjs

@@ -29,6 +29,11 @@ if (process.argv.includes('--hosts')) {
         return { x: 0, y: 0, left: 0, top: 0, width, height, right: width, bottom: height }
     }
 }
+// LinkeDOM exposes a read-only select.value; Vue expects the browser's writable contract.
+const selectPrototype = Object.getPrototypeOf(document.createElement('select'))
+const selectValue = Object.getOwnPropertyDescriptor(selectPrototype, 'value')
+Object.defineProperty(selectPrototype, 'value', { configurable: true, get: selectValue.get,
+    set(value) { for (const option of this.options) option.selected = option.value === String(value) } })
 const videoPrototype = Object.getPrototypeOf(document.createElement('video'))
 videoPrototype.play = () => Promise.resolve()
 videoPrototype.pause = () => {}
@@ -40,9 +45,17 @@ const base = '668bdfc0e6313b9e75ae67524dc8ac7771326774'
 const sourceRef = process.argv.find((value) => value.startsWith('--source-ref='))?.slice('--source-ref='.length)
     || (baselineOnly ? base : '')
 if (sourceRef && !/^[a-f0-9]{7,40}$/.test(sourceRef)) throw new Error('Expected a fixed Git source revision')
+// Read a fixed baseline plus explicit owned files; never write baseline bytes over WIP.
+const workingSources = new Set(process.argv.filter((value) => value.startsWith('--working-source='))
+    .map((value) => value.slice('--working-source='.length)))
+if (workingSources.size && !sourceRef) throw new Error('--working-source requires --source-ref')
+for (const name of workingSources) {
+    if (!/^src\/[\w/.-]+$/.test(name) || name.includes('..')) throw new Error('Expected a repository source path')
+}
+if (sourceRef) console.log('Source snapshot:', sourceRef, 'working sources:', [...workingSources])
 async function sourceAt(path) {
     const name = relative(process.cwd(), path).replaceAll('\\', '/')
-    if (!sourceRef || !name.startsWith('src/')) return readFile(path, 'utf8')
+    if (!sourceRef || !name.startsWith('src/') || workingSources.has(name)) return readFile(path, 'utf8')
     return execFileSync('git', ['show', `${sourceRef}:${name}`], { encoding: 'utf8' })
 }
 const output = resolve(`.scratch/cockpit-video-monitoring/test-output/${suite}${baselineOnly ? '-baseline' : ''}.mjs`)