import assert from 'node:assert/strict' import { planPipeEnhancement } from '../../../src/modules/cockpit/domain/pipeEnhancement.js' import { mountPipeEnhancement } from '../../../src/modules/cockpit/services/threePipeEnhancement.js' import { mountMap, unmountMap } from '../../../src/modules/cockpit/services/mapService.js' import { attachSceneFocus } from '../../../src/modules/cockpit/services/sceneInputFocus.js' let failures = 0 let passed = 0 function check(name, fn) { try { fn(); passed += 1; console.log(`PASS ${name}`) } catch (error) { failures += 1; console.error(`FAIL ${name}: ${error.message}`) } } class Element extends EventTarget { constructor(doc) { super(); this.ownerDocument = doc; this.attributes = new Map(); this.dataset = {} this.clientWidth = 900; this.clientHeight = 500; this.children = []; this.parentNode = null } getAttribute(name) { return this.attributes.get(name) ?? null } setAttribute(name, value) { this.attributes.set(name, String(value)) } removeAttribute(name) { this.attributes.delete(name) } focus() { this.ownerDocument.activeElement = this; this.dispatchEvent(new Event('focusin')) } contains(node) { return node === this || this.children.some((child) => child.contains(node)) } appendChild(node) { node.parentNode = this; this.children.push(node) } removeChild(node) { this.children.splice(this.children.indexOf(node), 1); node.parentNode = null } } function fixture() { const doc = { activeElement: null, createElement() { return new Element(doc) } } const host = new Element(doc) let scene let camera const rendererFactory = ({ canvas }) => ({ domElement: canvas, setPixelRatio() {}, setSize() {}, render(value, view) { scene = value; camera = view; camera.updateMatrixWorld() }, dispose() {} }) return { doc, host, rendererFactory, scene: () => scene, camera: () => camera } } function selected(row, component = false) { const item = { code: 'P', diameter: 400, positions: [[110.1, 28.4], [110.101, 28.401]], position: [110.1, 28.4], kind: 'valve', ...row } return planPipeEnhancement({ selected: { kind: component ? 'component' : 'pipe', code: 'P' }, visiblePipes: [item], visibleComponents: [item] }) } function event(target, type, extras = {}) { const value = new Event(type, { cancelable: true, bubbles: true }) Object.assign(value, extras) target.dispatchEvent(value) return value } for (const [industry, networkLevel, expected] of [ ['GAS', undefined, 'ff3b52'], ['WATER_SUPPLY', undefined, '168bff'], ['DRAINAGE', 0, '29e36c'], ['DRAINAGE', 1, '00e5ff'], ['DRAINAGE', ' 1 ', '00e5ff'], ['DRAINAGE', null, '29e36c'], ['DRAINAGE', 7, 'aab7c4'], ['MANHOLE', undefined, 'b36bff'], ['unknown', undefined, 'aab7c4'], ]) { check(`${industry}/${networkLevel}: actual Three mesh uses #${expected}`, () => { const f = fixture() const viewer = mountPipeEnhancement(f.host, selected({ industry, networkLevel }), f) try { const root = f.scene().children.find((node) => node.name === 'pipe-enhancement:P') let mesh root.traverse((node) => { if (node.name === 'pipe-wall') mesh ||= node }) assert.ok(mesh, '管壁材质必须存在') assert.equal(mesh.material.color.getHexString(), expected) } finally { viewer.destroy() } }) } check('雨水设施构件同样使用青色', () => { const f = fixture() const viewer = mountPipeEnhancement(f.host, selected({ industry: 'DRAINAGE', networkLevel: 1 }, true), f) try { const root = f.scene().children.find((node) => node.name === 'pipe-enhancement:P') let mesh root.traverse((node) => { if (node.isMesh && node.parent.userData?.sourceKind === 'component') mesh ||= node }) assert.ok(mesh) assert.equal(mesh.material.color.getHexString(), '00e5ff') } finally { viewer.destroy() } }) check('局部视口右键取消浏览器菜单并获得焦点,销毁后移除监听', () => { const f = fixture() const viewer = mountPipeEnhancement(f.host, selected({ industry: 'WATER_SUPPLY' }), f) try { event(f.host, 'pointerdown', { button: 2 }) assert.equal(f.doc.activeElement, f.host) assert.equal(event(f.host, 'contextmenu').defaultPrevented, true) } finally { viewer.destroy() } assert.equal(event(f.host, 'contextmenu').defaultPrevented, false) assert.equal(f.host.getAttribute('tabindex'), null) }) check('局部缩放取消页面滚动,销毁后恢复默认事件', () => { const f = fixture() const viewer = mountPipeEnhancement(f.host, selected({ industry: 'GAS' }), f) const canvas = f.host.children[0] assert.equal(event(canvas, 'wheel', { deltaY: 100 }).defaultPrevented, true) viewer.destroy() assert.equal(event(canvas, 'wheel', { deltaY: 100 }).defaultPrevented, false) }) check('主图宿主统一抑制右键菜单并聚焦,卸载后恢复', () => { const f = fixture() mountMap(f.host) try { event(f.host, 'pointerdown', { button: 2 }) assert.equal(f.doc.activeElement, f.host) assert.equal(event(f.host, 'contextmenu').defaultPrevented, true) } finally { unmountMap() } assert.equal(event(f.host, 'contextmenu').defaultPrevented, false) assert.equal(f.host.getAttribute('tabindex'), null) }) check('左键拖动平移,朝向不变,场景随鼠标移动', () => { const f = fixture() const viewer = mountPipeEnhancement(f.host, selected({ industry: 'GAS' }), f) try { const canvas = f.host.children[0] const camera = f.camera() const beforeRotation = camera.quaternion.clone() const beforePosition = camera.position.clone() const point = camera.position.clone().set(0, 0, 0) const beforeScreen = point.clone().project(camera) event(canvas, 'pointerdown', { button: 0, pointerId: 1, clientX: 100, clientY: 100 }) event(canvas, 'pointermove', { pointerId: 1, clientX: 160, clientY: 130 }) const afterScreen = point.clone().project(camera) assert.ok(camera.quaternion.angleTo(beforeRotation) < 1e-7, '左键不能改变相机朝向') assert.ok(camera.position.distanceTo(beforePosition) > 0, '左键必须平移') assert.ok(Math.abs((afterScreen.x - beforeScreen.x) * f.host.clientWidth / 2 - 60) < 0.01) assert.ok(Math.abs(-(afterScreen.y - beforeScreen.y) * f.host.clientHeight / 2 - 30) < 0.01) } finally { viewer.destroy() } }) check('右键绕中心旋转而不缩放,松手后停止', () => { const f = fixture() const viewer = mountPipeEnhancement(f.host, selected({ industry: 'GAS' }), f) try { const canvas = f.host.children[0] const camera = f.camera() const distance = camera.position.length() const rotation = camera.quaternion.clone() event(canvas, 'pointerdown', { button: 2, pointerId: 2, clientX: 100, clientY: 100 }) event(canvas, 'pointermove', { pointerId: 2, clientX: 160, clientY: 140 }) assert.ok(camera.quaternion.angleTo(rotation) > 0.01) assert.ok(Math.abs(camera.position.length() - distance) < 1e-7, '旋转不能改变半径') event(canvas, 'pointerup', { pointerId: 2 }) const stopped = camera.position.clone() event(canvas, 'pointermove', { pointerId: 2, clientX: 200, clientY: 200 }) assert.ok(camera.position.equals(stopped)) } finally { viewer.destroy() } }) check('滚轮向上拉近、向下拉远,缩放不改变朝向', () => { const f = fixture() const viewer = mountPipeEnhancement(f.host, selected({ industry: 'GAS' }), f) try { const canvas = f.host.children[0] const camera = f.camera() const distance = camera.position.length() const rotation = camera.quaternion.clone() event(canvas, 'wheel', { deltaY: -100 }) assert.ok(camera.position.length() < distance) assert.ok(camera.quaternion.angleTo(rotation) < 1e-7) const near = camera.position.length() event(canvas, 'wheel', { deltaY: 100 }) assert.ok(camera.position.length() > near) } finally { viewer.destroy() } }) check('非当前指针和取消后的移动不能继续拖拽', () => { const f = fixture() const viewer = mountPipeEnhancement(f.host, selected({ industry: 'GAS' }), f) try { const canvas = f.host.children[0] event(canvas, 'pointerdown', { button: 2, pointerId: 4, clientX: 100, clientY: 100 }) const initial = f.camera().position.clone() event(canvas, 'pointermove', { pointerId: 5, clientX: 150, clientY: 150 }) assert.ok(f.camera().position.equals(initial)) event(canvas, 'pointercancel', { pointerId: 4 }) event(canvas, 'pointermove', { pointerId: 4, clientX: 180, clientY: 180 }) assert.ok(f.camera().position.equals(initial)) } finally { viewer.destroy() } }) check('聚焦仅影响场景,输入框和场景外保留默认菜单,焦点离开可退出', () => { const f = fixture() f.host.setAttribute('tabindex', '3') const detach = attachSceneFocus(f.host) try { event(f.host, 'pointerdown', { button: 0 }) assert.equal(f.host.getAttribute('data-scene-focused'), 'true') const input = new Element(f.doc) input.closest = () => input const menu = new Event('contextmenu', { cancelable: true }) Object.defineProperty(menu, 'target', { value: input }) f.host.dispatchEvent(menu) assert.equal(menu.defaultPrevented, false) const outside = new Element(f.doc) assert.equal(event(outside, 'contextmenu').defaultPrevented, false) event(f.host, 'focusout', { relatedTarget: outside }) assert.equal(f.host.getAttribute('data-scene-focused'), null) } finally { detach() } assert.equal(f.host.getAttribute('tabindex'), '3') }) check('面板尺寸变化重设画布,相机比例正确,销毁后停止观察和渲染', () => { const original = globalThis.ResizeObserver let callback let disconnected = false globalThis.ResizeObserver = class { constructor(fn) { callback = fn } observe() {} disconnect() { disconnected = true } } const f = fixture() let renders = 0 const factory = f.rendererFactory f.rendererFactory = (options) => { const renderer = factory(options) const render = renderer.render renderer.render = (...args) => { renders += 1; render(...args) } return renderer } let viewer try { viewer = mountPipeEnhancement(f.host, selected({ industry: 'GAS' }), f) f.host.clientWidth = 1000 f.host.clientHeight = 400 callback() assert.equal(f.camera().aspect, 2.5) viewer.destroy() assert.equal(disconnected, true) const previous = renders callback() assert.equal(renders, previous) } finally { viewer?.destroy() if (original === undefined) delete globalThis.ResizeObserver else globalThis.ResizeObserver = original } }) console.log(`T23 visual/input: ${passed} passed, ${failures} failed`) if (failures) process.exitCode = 1