t23-visual-input.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. import assert from 'node:assert/strict'
  2. import { planPipeEnhancement } from '../../../src/modules/cockpit/domain/pipeEnhancement.js'
  3. import { mountPipeEnhancement } from '../../../src/modules/cockpit/services/threePipeEnhancement.js'
  4. import { mountMap, unmountMap } from '../../../src/modules/cockpit/services/mapService.js'
  5. import { attachSceneFocus } from '../../../src/modules/cockpit/services/sceneInputFocus.js'
  6. let failures = 0
  7. let passed = 0
  8. function check(name, fn) {
  9. try { fn(); passed += 1; console.log(`PASS ${name}`) }
  10. catch (error) { failures += 1; console.error(`FAIL ${name}: ${error.message}`) }
  11. }
  12. class Element extends EventTarget {
  13. constructor(doc) {
  14. super(); this.ownerDocument = doc; this.attributes = new Map(); this.dataset = {}
  15. this.clientWidth = 900; this.clientHeight = 500; this.children = []; this.parentNode = null
  16. }
  17. getAttribute(name) { return this.attributes.get(name) ?? null }
  18. setAttribute(name, value) { this.attributes.set(name, String(value)) }
  19. removeAttribute(name) { this.attributes.delete(name) }
  20. focus() { this.ownerDocument.activeElement = this; this.dispatchEvent(new Event('focusin')) }
  21. contains(node) { return node === this || this.children.some((child) => child.contains(node)) }
  22. appendChild(node) { node.parentNode = this; this.children.push(node) }
  23. removeChild(node) { this.children.splice(this.children.indexOf(node), 1); node.parentNode = null }
  24. }
  25. function fixture() {
  26. const doc = { activeElement: null, createElement() { return new Element(doc) } }
  27. const host = new Element(doc)
  28. let scene
  29. let camera
  30. const rendererFactory = ({ canvas }) => ({ domElement: canvas, setPixelRatio() {}, setSize() {},
  31. render(value, view) { scene = value; camera = view; camera.updateMatrixWorld() }, dispose() {} })
  32. return { doc, host, rendererFactory, scene: () => scene, camera: () => camera }
  33. }
  34. function selected(row, component = false) {
  35. const item = { code: 'P', diameter: 400, positions: [[110.1, 28.4], [110.101, 28.401]], position: [110.1, 28.4], kind: 'valve', ...row }
  36. return planPipeEnhancement({ selected: { kind: component ? 'component' : 'pipe', code: 'P' }, visiblePipes: [item], visibleComponents: [item] })
  37. }
  38. function event(target, type, extras = {}) {
  39. const value = new Event(type, { cancelable: true, bubbles: true })
  40. Object.assign(value, extras)
  41. target.dispatchEvent(value)
  42. return value
  43. }
  44. for (const [industry, networkLevel, expected] of [
  45. ['GAS', undefined, 'ff3b52'], ['WATER_SUPPLY', undefined, '168bff'],
  46. ['DRAINAGE', 0, '29e36c'], ['DRAINAGE', 1, '00e5ff'],
  47. ['DRAINAGE', ' 1 ', '00e5ff'], ['DRAINAGE', null, '29e36c'],
  48. ['DRAINAGE', 7, 'aab7c4'], ['MANHOLE', undefined, 'b36bff'], ['unknown', undefined, 'aab7c4'],
  49. ]) {
  50. check(`${industry}/${networkLevel}: actual Three mesh uses #${expected}`, () => {
  51. const f = fixture()
  52. const viewer = mountPipeEnhancement(f.host, selected({ industry, networkLevel }), f)
  53. try {
  54. const root = f.scene().children.find((node) => node.name === 'pipe-enhancement:P')
  55. let mesh
  56. root.traverse((node) => { if (node.name === 'pipe-wall') mesh ||= node })
  57. assert.ok(mesh, '管壁材质必须存在')
  58. assert.equal(mesh.material.color.getHexString(), expected)
  59. } finally { viewer.destroy() }
  60. })
  61. }
  62. check('雨水设施构件同样使用青色', () => {
  63. const f = fixture()
  64. const viewer = mountPipeEnhancement(f.host, selected({ industry: 'DRAINAGE', networkLevel: 1 }, true), f)
  65. try {
  66. const root = f.scene().children.find((node) => node.name === 'pipe-enhancement:P')
  67. let mesh
  68. root.traverse((node) => { if (node.isMesh && node.parent.userData?.sourceKind === 'component') mesh ||= node })
  69. assert.ok(mesh)
  70. assert.equal(mesh.material.color.getHexString(), '00e5ff')
  71. } finally { viewer.destroy() }
  72. })
  73. check('局部视口右键取消浏览器菜单并获得焦点,销毁后移除监听', () => {
  74. const f = fixture()
  75. const viewer = mountPipeEnhancement(f.host, selected({ industry: 'WATER_SUPPLY' }), f)
  76. try {
  77. event(f.host, 'pointerdown', { button: 2 })
  78. assert.equal(f.doc.activeElement, f.host)
  79. assert.equal(event(f.host, 'contextmenu').defaultPrevented, true)
  80. } finally { viewer.destroy() }
  81. assert.equal(event(f.host, 'contextmenu').defaultPrevented, false)
  82. assert.equal(f.host.getAttribute('tabindex'), null)
  83. })
  84. check('局部缩放取消页面滚动,销毁后恢复默认事件', () => {
  85. const f = fixture()
  86. const viewer = mountPipeEnhancement(f.host, selected({ industry: 'GAS' }), f)
  87. const canvas = f.host.children[0]
  88. assert.equal(event(canvas, 'wheel', { deltaY: 100 }).defaultPrevented, true)
  89. viewer.destroy()
  90. assert.equal(event(canvas, 'wheel', { deltaY: 100 }).defaultPrevented, false)
  91. })
  92. check('主图宿主统一抑制右键菜单并聚焦,卸载后恢复', () => {
  93. const f = fixture()
  94. mountMap(f.host)
  95. try {
  96. event(f.host, 'pointerdown', { button: 2 })
  97. assert.equal(f.doc.activeElement, f.host)
  98. assert.equal(event(f.host, 'contextmenu').defaultPrevented, true)
  99. } finally { unmountMap() }
  100. assert.equal(event(f.host, 'contextmenu').defaultPrevented, false)
  101. assert.equal(f.host.getAttribute('tabindex'), null)
  102. })
  103. check('左键拖动平移,朝向不变,场景随鼠标移动', () => {
  104. const f = fixture()
  105. const viewer = mountPipeEnhancement(f.host, selected({ industry: 'GAS' }), f)
  106. try {
  107. const canvas = f.host.children[0]
  108. const camera = f.camera()
  109. const beforeRotation = camera.quaternion.clone()
  110. const beforePosition = camera.position.clone()
  111. const point = camera.position.clone().set(0, 0, 0)
  112. const beforeScreen = point.clone().project(camera)
  113. event(canvas, 'pointerdown', { button: 0, pointerId: 1, clientX: 100, clientY: 100 })
  114. event(canvas, 'pointermove', { pointerId: 1, clientX: 160, clientY: 130 })
  115. const afterScreen = point.clone().project(camera)
  116. assert.ok(camera.quaternion.angleTo(beforeRotation) < 1e-7, '左键不能改变相机朝向')
  117. assert.ok(camera.position.distanceTo(beforePosition) > 0, '左键必须平移')
  118. assert.ok(Math.abs((afterScreen.x - beforeScreen.x) * f.host.clientWidth / 2 - 60) < 0.01)
  119. assert.ok(Math.abs(-(afterScreen.y - beforeScreen.y) * f.host.clientHeight / 2 - 30) < 0.01)
  120. } finally { viewer.destroy() }
  121. })
  122. check('右键绕中心旋转而不缩放,松手后停止', () => {
  123. const f = fixture()
  124. const viewer = mountPipeEnhancement(f.host, selected({ industry: 'GAS' }), f)
  125. try {
  126. const canvas = f.host.children[0]
  127. const camera = f.camera()
  128. const distance = camera.position.length()
  129. const rotation = camera.quaternion.clone()
  130. event(canvas, 'pointerdown', { button: 2, pointerId: 2, clientX: 100, clientY: 100 })
  131. event(canvas, 'pointermove', { pointerId: 2, clientX: 160, clientY: 140 })
  132. assert.ok(camera.quaternion.angleTo(rotation) > 0.01)
  133. assert.ok(Math.abs(camera.position.length() - distance) < 1e-7, '旋转不能改变半径')
  134. event(canvas, 'pointerup', { pointerId: 2 })
  135. const stopped = camera.position.clone()
  136. event(canvas, 'pointermove', { pointerId: 2, clientX: 200, clientY: 200 })
  137. assert.ok(camera.position.equals(stopped))
  138. } finally { viewer.destroy() }
  139. })
  140. check('滚轮向上拉近、向下拉远,缩放不改变朝向', () => {
  141. const f = fixture()
  142. const viewer = mountPipeEnhancement(f.host, selected({ industry: 'GAS' }), f)
  143. try {
  144. const canvas = f.host.children[0]
  145. const camera = f.camera()
  146. const distance = camera.position.length()
  147. const rotation = camera.quaternion.clone()
  148. event(canvas, 'wheel', { deltaY: -100 })
  149. assert.ok(camera.position.length() < distance)
  150. assert.ok(camera.quaternion.angleTo(rotation) < 1e-7)
  151. const near = camera.position.length()
  152. event(canvas, 'wheel', { deltaY: 100 })
  153. assert.ok(camera.position.length() > near)
  154. } finally { viewer.destroy() }
  155. })
  156. check('非当前指针和取消后的移动不能继续拖拽', () => {
  157. const f = fixture()
  158. const viewer = mountPipeEnhancement(f.host, selected({ industry: 'GAS' }), f)
  159. try {
  160. const canvas = f.host.children[0]
  161. event(canvas, 'pointerdown', { button: 2, pointerId: 4, clientX: 100, clientY: 100 })
  162. const initial = f.camera().position.clone()
  163. event(canvas, 'pointermove', { pointerId: 5, clientX: 150, clientY: 150 })
  164. assert.ok(f.camera().position.equals(initial))
  165. event(canvas, 'pointercancel', { pointerId: 4 })
  166. event(canvas, 'pointermove', { pointerId: 4, clientX: 180, clientY: 180 })
  167. assert.ok(f.camera().position.equals(initial))
  168. } finally { viewer.destroy() }
  169. })
  170. check('聚焦仅影响场景,输入框和场景外保留默认菜单,焦点离开可退出', () => {
  171. const f = fixture()
  172. f.host.setAttribute('tabindex', '3')
  173. const detach = attachSceneFocus(f.host)
  174. try {
  175. event(f.host, 'pointerdown', { button: 0 })
  176. assert.equal(f.host.getAttribute('data-scene-focused'), 'true')
  177. const input = new Element(f.doc)
  178. input.closest = () => input
  179. const menu = new Event('contextmenu', { cancelable: true })
  180. Object.defineProperty(menu, 'target', { value: input })
  181. f.host.dispatchEvent(menu)
  182. assert.equal(menu.defaultPrevented, false)
  183. const outside = new Element(f.doc)
  184. assert.equal(event(outside, 'contextmenu').defaultPrevented, false)
  185. event(f.host, 'focusout', { relatedTarget: outside })
  186. assert.equal(f.host.getAttribute('data-scene-focused'), null)
  187. } finally { detach() }
  188. assert.equal(f.host.getAttribute('tabindex'), '3')
  189. })
  190. check('面板尺寸变化重设画布,相机比例正确,销毁后停止观察和渲染', () => {
  191. const original = globalThis.ResizeObserver
  192. let callback
  193. let disconnected = false
  194. globalThis.ResizeObserver = class {
  195. constructor(fn) { callback = fn }
  196. observe() {}
  197. disconnect() { disconnected = true }
  198. }
  199. const f = fixture()
  200. let renders = 0
  201. const factory = f.rendererFactory
  202. f.rendererFactory = (options) => {
  203. const renderer = factory(options)
  204. const render = renderer.render
  205. renderer.render = (...args) => { renders += 1; render(...args) }
  206. return renderer
  207. }
  208. let viewer
  209. try {
  210. viewer = mountPipeEnhancement(f.host, selected({ industry: 'GAS' }), f)
  211. f.host.clientWidth = 1000
  212. f.host.clientHeight = 400
  213. callback()
  214. assert.equal(f.camera().aspect, 2.5)
  215. viewer.destroy()
  216. assert.equal(disconnected, true)
  217. const previous = renders
  218. callback()
  219. assert.equal(renders, previous)
  220. } finally {
  221. viewer?.destroy()
  222. if (original === undefined) delete globalThis.ResizeObserver
  223. else globalThis.ResizeObserver = original
  224. }
  225. })
  226. console.log(`T23 visual/input: ${passed} passed, ${failures} failed`)
  227. if (failures) process.exitCode = 1