用户实机反馈:单屏窗口化窗口按 F11 全屏后标题栏不消失,退回窗口化反而没标题栏,
表现与预期完全相反。
根因不在标题栏,而在 Console 侧的窗口模式管理:entry 没记录窗口是以什么模式建的,
layout() 每次排布都拿「整机是否单屏」的 single 去覆盖每个已存在窗口的几何与模式
(!single 时会 win.setKiosk(true) + 铺满),而页面 URL 里的 windowed=1 是建窗时定死的、
loadPage 只在页码变化时才重载 —— 于是窗口的真实模式与页面认知脱钩:
窗口化窗口被改成 kiosk 全屏而 URL 仍带 windowed=1(全屏却画标题栏),反向亦然。
修复:建立并保证「窗口几何/模式 ↔ URL 的 windowed 必须永远一致」
- entry 记 { win, page, single, manual, reloadTimer }
- createWindow(..., manual=false);openOn() 传 manual=true(手动开的窗口不被排布改模式)
- layout() 的 existing 分支:target = manual ? entry.single : single,applyGeometry 与
loadPage 都用同一个 target,且 modeChanged || page 变了都要重载 URL
- 删掉 isKioskWindow()(用 isFullScreen() 反推会把「窗口化窗口被 F11 全屏」误报成 kiosk),
换成 modeOf(entry)
- F11 通信时序:enter/leave-full-screen 回调里不再回读 win.isFullScreen()(切换瞬间可能读到
旧值,页面会收到相反状态),改为下发事件本身的确定值;F11 直接下发算出来的 next
回归测试 console/main/displays.test.js(8 例,assertModeConsistent 四处自洽)。
反向对照:修复前的 displays.js 跑同一份测试是 1 pass / 7 fail。
Console 语法检查 18 个文件、node --test 28 例全过。
336 lines
10 KiB
JavaScript
336 lines
10 KiB
JavaScript
'use strict'
|
|
|
|
const assert = require('node:assert/strict')
|
|
const Module = require('node:module')
|
|
const test = require('node:test')
|
|
const { EventEmitter } = require('node:events')
|
|
|
|
// displays.js 顶层 require('electron'),而开发机上的 electron 包没有下载 dist,直接 require 会抛。
|
|
// 这里在加载它之前把 electron 换成桩:本文件只验证「窗口模式 ↔ URL 里的 windowed」这条不变式,
|
|
// 不需要真窗口,也跑不了真的窗口级全屏(那只能在 Windows 实机上验)。
|
|
class FakeWebContents extends EventEmitter {
|
|
constructor() {
|
|
super()
|
|
this.url = ''
|
|
this.sent = []
|
|
this.loadCount = 0
|
|
}
|
|
|
|
send(channel, payload) {
|
|
this.sent.push([channel, payload])
|
|
}
|
|
|
|
loadURL(url) {
|
|
this.url = url
|
|
this.loadCount += 1
|
|
this.emit('did-finish-load')
|
|
return Promise.resolve()
|
|
}
|
|
|
|
getURL() {
|
|
return this.url
|
|
}
|
|
|
|
reload() {
|
|
this.loadCount += 1
|
|
this.emit('did-finish-load')
|
|
}
|
|
}
|
|
|
|
let created = []
|
|
|
|
class FakeBrowserWindow extends EventEmitter {
|
|
constructor(options) {
|
|
super()
|
|
this.options = options
|
|
this.webContents = new FakeWebContents()
|
|
this.destroyed = false
|
|
this.kiosk = options.kiosk === true
|
|
this.fullScreen = false
|
|
// 模拟真实 Electron 在切换瞬间回读到的旧值:设了它 isFullScreen() 就返回这个
|
|
this.staleFullScreen = undefined
|
|
this.bounds = { x: options.x, y: options.y, width: options.width, height: options.height }
|
|
this.shown = false
|
|
created.push(this)
|
|
}
|
|
|
|
// Electron 的 BrowserWindow.loadURL 是 webContents.loadURL 的快捷方式,displays.js 用的是前者
|
|
loadURL(url) {
|
|
return this.webContents.loadURL(url)
|
|
}
|
|
|
|
isDestroyed() {
|
|
return this.destroyed
|
|
}
|
|
|
|
destroy() {
|
|
this.destroyed = true
|
|
this.emit('closed')
|
|
}
|
|
|
|
isFullScreen() {
|
|
return this.staleFullScreen === undefined ? this.fullScreen : this.staleFullScreen
|
|
}
|
|
|
|
setFullScreen(value) {
|
|
this.fullScreen = value
|
|
}
|
|
|
|
isKiosk() {
|
|
return this.kiosk
|
|
}
|
|
|
|
setKiosk(value) {
|
|
this.kiosk = value
|
|
}
|
|
|
|
getBounds() {
|
|
return this.bounds
|
|
}
|
|
|
|
setBounds(bounds) {
|
|
this.bounds = bounds
|
|
}
|
|
|
|
setIgnoreMouseEvents(value) {
|
|
this.ignoreMouse = value
|
|
}
|
|
|
|
show() {
|
|
this.shown = true
|
|
}
|
|
|
|
focus() {}
|
|
|
|
isMinimized() {
|
|
return false
|
|
}
|
|
|
|
restore() {}
|
|
}
|
|
|
|
const fakeScreen = {
|
|
displays: [],
|
|
primaryId: 1,
|
|
getAllDisplays() {
|
|
return this.displays
|
|
},
|
|
getPrimaryDisplay() {
|
|
return this.displays.find((item) => item.id === this.primaryId) || this.displays[0]
|
|
},
|
|
on() {},
|
|
removeListener() {}
|
|
}
|
|
|
|
const electronStub = { BrowserWindow: FakeBrowserWindow, screen: fakeScreen }
|
|
const originalLoad = Module._load
|
|
Module._load = function (request, parent, isMain) {
|
|
if (request === 'electron') return electronStub
|
|
return originalLoad.call(this, request, parent, isMain)
|
|
}
|
|
const { DisplayManager } = require('./displays')
|
|
Module._load = originalLoad
|
|
|
|
const WIDTH = 1920
|
|
const HEIGHT = 1080
|
|
|
|
function fakeDisplay(id, { internal = false } = {}) {
|
|
return { id, label: `显示器 ${id}`, internal, bounds: { x: 0, y: 0, width: WIDTH, height: HEIGHT }, scaleFactor: 1 }
|
|
}
|
|
|
|
function makeManager(t, { selection = 'all', kiosk = true } = {}) {
|
|
fakeScreen.displays = []
|
|
fakeScreen.primaryId = 1
|
|
created = []
|
|
const log = { lines: [], push(_scope, text) { this.lines.push(text) } }
|
|
const manager = new DisplayManager({
|
|
values: {
|
|
display: {
|
|
selection,
|
|
displays: [],
|
|
pages: ['standings', 'medals'],
|
|
pageParam: 'page',
|
|
kiosk,
|
|
ignoreMouse: true,
|
|
reloadEveryHours: 0,
|
|
backgroundColor: '#000000'
|
|
}
|
|
},
|
|
server: { host: '0.0.0.0', port: 19540 },
|
|
log
|
|
})
|
|
t.after(() => manager.closeAll())
|
|
return manager
|
|
}
|
|
|
|
/**
|
|
* 唯一不变式:窗口的几何/模式、页面 URL 里的 windowed 参数必须一致。
|
|
* 这条断言在每个用例里都跑,任何一种脱钩都会在这里炸出来。
|
|
*/
|
|
function assertModeConsistent(manager, displayId, expectedSingle) {
|
|
const entry = manager.windows.get(displayId)
|
|
assert.ok(entry, `display=${displayId} 上应该有窗口`)
|
|
|
|
assert.equal(entry.single, expectedSingle, `entry.single 应为 ${expectedSingle}`)
|
|
assert.equal(entry.win.isKiosk(), expectedSingle === false, 'kiosk 标记应与 entry.single 一致')
|
|
|
|
const url = new URL(entry.win.webContents.getURL())
|
|
assert.equal(url.pathname, '/display')
|
|
assert.equal(
|
|
url.searchParams.get('windowed'),
|
|
expectedSingle ? '1' : null,
|
|
`URL 的 windowed 应与 entry.single 一致:${url.href}`
|
|
)
|
|
|
|
const bounds = entry.win.getBounds()
|
|
assert.equal(bounds.width, expectedSingle ? Math.round(WIDTH * 0.8) : WIDTH, '窗口宽度应与模式一致')
|
|
assert.equal(bounds.height, expectedSingle ? Math.round(HEIGHT * 0.8) : HEIGHT, '窗口高度应与模式一致')
|
|
|
|
return entry
|
|
}
|
|
|
|
test('单屏排布:窗口化窗口的 URL 带 windowed=1', (t) => {
|
|
const manager = makeManager(t)
|
|
fakeScreen.displays = [fakeDisplay(1, { internal: true })]
|
|
|
|
manager.enable()
|
|
|
|
const entry = assertModeConsistent(manager, 1, true)
|
|
assert.equal(entry.manual, false)
|
|
})
|
|
|
|
test('多屏排布:kiosk 窗口的 URL 不带 windowed', (t) => {
|
|
const manager = makeManager(t)
|
|
fakeScreen.displays = [fakeDisplay(1, { internal: true }), fakeDisplay(2)]
|
|
|
|
manager.enable()
|
|
|
|
assertModeConsistent(manager, 1, false)
|
|
assertModeConsistent(manager, 2, false)
|
|
})
|
|
|
|
test('回归:窗口化窗口被排布改成 kiosk 时,URL 一并换成不带 windowed 并重载', (t) => {
|
|
const manager = makeManager(t)
|
|
fakeScreen.displays = [fakeDisplay(1, { internal: true })]
|
|
manager.enable()
|
|
|
|
const before = assertModeConsistent(manager, 1, true)
|
|
const loadsBefore = before.win.webContents.loadCount
|
|
assert.match(before.win.webContents.getURL(), /windowed=1/)
|
|
|
|
// 插上第二块屏,整机从「单屏窗口化」变成「多屏 kiosk」
|
|
fakeScreen.displays = [fakeDisplay(1, { internal: true }), fakeDisplay(2)]
|
|
manager.layout()
|
|
|
|
const after = assertModeConsistent(manager, 1, false)
|
|
assert.equal(after.win, before.win, '同一个窗口应当就地改模式,而不是重建')
|
|
assert.equal(after.win.webContents.loadCount, loadsBefore + 1, '模式变了必须重载一次 URL')
|
|
assert.doesNotMatch(after.win.webContents.getURL(), /windowed=1/)
|
|
})
|
|
|
|
test('回归反向:kiosk 窗口被排布改成窗口化时,URL 补上 windowed=1', (t) => {
|
|
const manager = makeManager(t)
|
|
fakeScreen.displays = [fakeDisplay(1, { internal: true }), fakeDisplay(2)]
|
|
manager.enable()
|
|
|
|
const before = assertModeConsistent(manager, 1, false)
|
|
const loadsBefore = before.win.webContents.loadCount
|
|
|
|
// 拔掉扩展屏,只剩主屏 → 退化成窗口化
|
|
fakeScreen.displays = [fakeDisplay(1, { internal: true })]
|
|
manager.layout()
|
|
|
|
const after = assertModeConsistent(manager, 1, true)
|
|
assert.equal(after.win, before.win, '同一个窗口应当就地改模式,而不是重建')
|
|
assert.equal(after.win.webContents.loadCount, loadsBefore + 1, '模式变了必须重载一次 URL')
|
|
assert.match(after.win.webContents.getURL(), /windowed=1/)
|
|
})
|
|
|
|
test('管理面板手动开的窗口保持自己的模式,不被自动排布改成 kiosk', (t) => {
|
|
const manager = makeManager(t)
|
|
const primary = fakeDisplay(1, { internal: true })
|
|
fakeScreen.displays = [primary]
|
|
|
|
const opened = manager.openOn(primary, { page: 'standings', single: true })
|
|
assert.deepEqual(opened, { created: true, mode: 'windowed' })
|
|
assertModeConsistent(manager, 1, true)
|
|
|
|
// 之后整机变成多屏,自动排布也不许把这个手动开的小窗顶成 kiosk
|
|
fakeScreen.displays = [primary, fakeDisplay(2)]
|
|
manager.layout()
|
|
|
|
assertModeConsistent(manager, 1, true)
|
|
assertModeConsistent(manager, 2, false)
|
|
})
|
|
|
|
test('模式上报以 entry 为准:窗口化窗口被 F11 全屏后仍报 windowed', (t) => {
|
|
const manager = makeManager(t)
|
|
const primary = fakeDisplay(1, { internal: true })
|
|
fakeScreen.displays = [primary]
|
|
|
|
manager.openOn(primary, { single: true })
|
|
const win = manager.windows.get(1).win
|
|
win.setFullScreen(true)
|
|
|
|
// 只聚焦不重建时,模式必须还说窗口化:拿 isFullScreen() 反推会误报成 kiosk
|
|
assert.deepEqual(manager.openOn(primary, { single: true }), { created: false, mode: 'windowed' })
|
|
assert.equal(manager.listDisplays().displays.find((item) => item.id === 1).mode, 'windowed')
|
|
assert.equal(manager.modeOf(manager.windows.get(1)), 'windowed')
|
|
|
|
// kiosk 窗口不受 F11 影响,仍报 kiosk
|
|
fakeScreen.displays = [primary, fakeDisplay(2)]
|
|
manager.layout()
|
|
assert.equal(manager.listDisplays().displays.find((item) => item.id === 2).mode, 'kiosk')
|
|
})
|
|
|
|
test('F11 下发的是算出来的确定值,不回读窗口', (t) => {
|
|
const manager = makeManager(t)
|
|
fakeScreen.displays = [fakeDisplay(1, { internal: true })]
|
|
manager.enable()
|
|
|
|
const win = manager.windows.get(1).win
|
|
const [handler] = win.webContents.listeners('before-input-event')
|
|
assert.ok(handler, '窗口化窗口必须挂 F11 处理')
|
|
|
|
let prevented = 0
|
|
const pressF11 = () => handler({ preventDefault: () => { prevented += 1 } }, { type: 'keyDown', key: 'F11' })
|
|
|
|
win.webContents.sent.length = 0
|
|
pressF11()
|
|
assert.equal(prevented, 1)
|
|
assert.equal(win.isFullScreen(), true)
|
|
assert.deepEqual(win.webContents.sent, [['window:fullscreen-changed', true]])
|
|
|
|
win.webContents.sent.length = 0
|
|
pressF11()
|
|
assert.equal(win.isFullScreen(), false)
|
|
assert.deepEqual(win.webContents.sent, [['window:fullscreen-changed', false]])
|
|
|
|
// 切换瞬间回读可能拿到旧值:enter/leave 必须下发事件本身的确定值
|
|
win.webContents.sent.length = 0
|
|
win.staleFullScreen = false
|
|
win.emit('enter-full-screen')
|
|
assert.deepEqual(win.webContents.sent, [['window:fullscreen-changed', true]])
|
|
|
|
win.webContents.sent.length = 0
|
|
win.staleFullScreen = true
|
|
win.emit('leave-full-screen')
|
|
assert.deepEqual(win.webContents.sent, [['window:fullscreen-changed', false]])
|
|
win.staleFullScreen = undefined
|
|
|
|
// 加载完成那次窗口状态已经稳定,继续用「读一次」
|
|
win.webContents.sent.length = 0
|
|
win.fullScreen = true
|
|
win.webContents.emit('did-finish-load')
|
|
assert.deepEqual(win.webContents.sent, [['window:fullscreen-changed', true]])
|
|
})
|
|
|
|
test('kiosk 窗口不挂 F11 处理', (t) => {
|
|
const manager = makeManager(t)
|
|
fakeScreen.displays = [fakeDisplay(1, { internal: true }), fakeDisplay(2)]
|
|
|
|
manager.enable()
|
|
|
|
assert.equal(manager.windows.get(2).win.webContents.listeners('before-input-event').length, 0)
|
|
})
|