公告: - 服务端把当前公告落库到 settings(display.announce_text / announce_seconds), 作为 display.announce 随 snapshot 与 /api/settings 下发(非 admin 快照也保留) - announce 指令除转发外写库;新增 announce_clear 清空并广播;两者写审计 - realtime 协议登记 announce_clear(补上,使清除能即时推到大屏) - 大屏把「全屏推送」与「底栏常驻」拆开:推送 seconds 秒后只退出全屏态,公告留在底栏, 直到被替换或清除;新增公告状态机(可测)与底栏取值优先级 推送中 > 服务端持久值 > 「暂无公告」;管理端加「清除公告」与当前公告状态 团体报名: - EntryPicker 团体/对抗模式允许不勾队员直接报名该单位(此前必须勾人,导致 「只想给单位报个团体项目」做不到、单位下没有运动员时完全报不了) - 核实录入页团体行:显示/填名次/保存/发布/算分闭环无缺陷(用真实服务端跑过), 抽出共享的 entryRowLabel 供两处复用 榜单口径: - 基础数据 → 积分模板页新增「榜单口径(全局)」卡片:individualToTeamWeight 数字输入(默认 1、0–100),说明「个人项目得分按该系数折算计入单位总分」, 保存走 PUT /api/settings;因该值在服务端是全局键、模板表里没有此字段, 故做成独立卡片并标注全局,不塞进模板表单 自检:服务端 11 包全绿;前端 vue-tsc + build、node --test 99/99。
201 lines
8.0 KiB
TypeScript
201 lines
8.0 KiB
TypeScript
import { test } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { nextTick, ref, type Ref } from 'vue'
|
|
import { parseSettings } from '../guards'
|
|
import { useScoreboard } from '../useScoreboard'
|
|
import {
|
|
DEFAULT_ANNOUNCE_SECONDS,
|
|
createAnnouncement,
|
|
resolveAnnounceText,
|
|
type AnnouncementController,
|
|
} from '../announce'
|
|
import type { DisplayPageKey } from '../types'
|
|
|
|
// 公告分两段:全屏 seconds 秒 + 底栏常驻。计时结束只退全屏,正文必须留着,
|
|
// 只有新公告或 announce_clear 才换掉它——曾经这里跟着计时器一起清空,
|
|
// 底栏「公告」槽位立刻回落成「暂无公告」。
|
|
|
|
interface Harness {
|
|
page: Ref<DisplayPageKey>
|
|
events: string[]
|
|
controller: AnnouncementController
|
|
/** 当前公告正文;读成函数,避免 assert.equal 的类型收窄把后续读取判成 never。 */
|
|
text: () => string | null
|
|
}
|
|
|
|
/** 复刻 useScoreboard 里的挂接方式:进全屏切到公告页,退全屏切回返回页。 */
|
|
function setup(initialPage: DisplayPageKey = 'standings'): Harness {
|
|
const page = ref<DisplayPageKey>(initialPage)
|
|
const events: string[] = []
|
|
const controller = createAnnouncement({
|
|
page,
|
|
onFullscreenEnter: () => {
|
|
events.push('enter')
|
|
page.value = 'announcement'
|
|
},
|
|
onFullscreenExit: (returnPage) => {
|
|
events.push(`exit:${returnPage}`)
|
|
if (page.value === 'announcement') page.value = returnPage
|
|
},
|
|
})
|
|
return { page, events, controller, text: () => controller.current.value?.text ?? null }
|
|
}
|
|
|
|
test('推送公告:到点只退全屏,正文留在底栏', (t) => {
|
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
const host = setup('standings')
|
|
|
|
host.controller.push('请各队到检录处集合', 10)
|
|
assert.equal(host.text(), '请各队到检录处集合')
|
|
assert.equal(host.controller.current.value?.seconds, 10)
|
|
assert.equal(host.page.value, 'announcement', '推送即进全屏')
|
|
|
|
t.mock.timers.tick(9_000)
|
|
assert.equal(host.page.value, 'announcement', '没到点不能提前退场')
|
|
|
|
t.mock.timers.tick(1_000)
|
|
assert.equal(host.page.value, 'standings', '到点回到进入公告前的页')
|
|
assert.deepEqual(host.events, ['enter', 'exit:standings'])
|
|
assert.equal(
|
|
host.text(),
|
|
'请各队到检录处集合',
|
|
'正文不能跟着计时器一起清掉,底栏要继续显示',
|
|
)
|
|
})
|
|
|
|
test('清除公告:退全屏并清空正文,计时器不再触发', (t) => {
|
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
const host = setup('team-medals')
|
|
|
|
host.controller.push('临时通知', 30)
|
|
host.controller.clear()
|
|
assert.equal(host.controller.current.value, null)
|
|
assert.equal(host.page.value, 'team-medals', '全屏中清除要回到进入公告前的页')
|
|
|
|
t.mock.timers.tick(60_000)
|
|
assert.deepEqual(host.events, ['enter', 'exit:team-medals'], '清除后不能再退场一次')
|
|
})
|
|
|
|
test('全屏已结束时清除:只清正文,不动当前页', (t) => {
|
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
const host = setup('standings')
|
|
|
|
host.controller.push('已过期的公告', 5)
|
|
t.mock.timers.tick(5_000)
|
|
host.controller.clear()
|
|
|
|
assert.equal(host.controller.current.value, null)
|
|
assert.equal(host.page.value, 'standings')
|
|
assert.deepEqual(host.events, ['enter', 'exit:standings'])
|
|
})
|
|
|
|
test('没推过公告时清除是空操作:手动停在公告页不会被踢走', () => {
|
|
const host = setup('announcement')
|
|
host.controller.clear()
|
|
assert.equal(host.controller.current.value, null)
|
|
assert.equal(host.page.value, 'announcement')
|
|
assert.deepEqual(host.events, [])
|
|
})
|
|
|
|
test('空白公告被忽略,不进全屏也不顶掉当前公告', (t) => {
|
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
const host = setup('standings')
|
|
|
|
host.controller.push(' ', 10)
|
|
host.controller.push('', 10)
|
|
assert.equal(host.controller.current.value, null)
|
|
assert.equal(host.page.value, 'standings')
|
|
assert.deepEqual(host.events, [])
|
|
|
|
host.controller.push('正式公告', 10)
|
|
host.controller.push(' ', 10)
|
|
assert.equal(host.text(), '正式公告')
|
|
})
|
|
|
|
test('连推两条:返回页始终是进入公告前那一页', (t) => {
|
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
const host = setup('event-results')
|
|
|
|
host.controller.push('第一条', 10)
|
|
t.mock.timers.tick(3_000)
|
|
// 第二条到来时 page 已经是 announcement,不能把返回页记成公告页自身。
|
|
host.controller.push('第二条', 10)
|
|
assert.equal(host.text(), '第二条')
|
|
|
|
t.mock.timers.tick(10_000)
|
|
assert.equal(host.page.value, 'event-results')
|
|
// 第二条会再进一次全屏(页已在公告页,进入动作是幂等的),但返回页仍是 event-results。
|
|
assert.deepEqual(host.events, ['enter', 'enter', 'exit:event-results'])
|
|
assert.equal(host.text(), '第二条', '换成第二条后仍常驻底栏')
|
|
})
|
|
|
|
test('seconds=0 表示不定时全屏,只能由清除收场', (t) => {
|
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
const host = setup('standings')
|
|
|
|
host.controller.push('长期公告', 0)
|
|
t.mock.timers.tick(600_000)
|
|
assert.equal(host.page.value, 'announcement')
|
|
|
|
host.controller.clear()
|
|
assert.equal(host.page.value, 'standings')
|
|
assert.equal(host.controller.current.value, null)
|
|
})
|
|
|
|
test('dispose 停表:卸载后不再自动退场', (t) => {
|
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
const host = setup('standings')
|
|
|
|
host.controller.push('公告', 10)
|
|
host.controller.dispose()
|
|
t.mock.timers.tick(10_000)
|
|
assert.deepEqual(host.events, ['enter'])
|
|
assert.equal(host.text(), '公告')
|
|
})
|
|
|
|
test('公告取值优先级:推送中的实时值 > 服务端持久化 > 占位', () => {
|
|
const live = { text: '实时公告', seconds: 10, startedAt: 1 }
|
|
|
|
assert.equal(resolveAnnounceText(live, '持久化公告'), '实时公告')
|
|
assert.equal(resolveAnnounceText(null, '持久化公告'), '持久化公告', '大屏错过指令时靠设置兜底')
|
|
assert.equal(resolveAnnounceText(null, null), '暂无公告')
|
|
assert.equal(resolveAnnounceText(null, undefined), '暂无公告')
|
|
assert.equal(resolveAnnounceText(null, ' '), '暂无公告')
|
|
assert.equal(resolveAnnounceText({ ...live, text: ' ' }, '持久化公告'), '持久化公告')
|
|
assert.equal(resolveAnnounceText(null, ' 两侧留白 '), '两侧留白')
|
|
})
|
|
|
|
test('parseSettings 归一公告:嵌套与扁平键都认,空白当没有', () => {
|
|
assert.equal(parseSettings({}).display.announce, null)
|
|
assert.equal(parseSettings({ display: { announce: '注意安全' } }).display.announce, '注意安全')
|
|
assert.equal(parseSettings({ 'display.announce_text': '注意安全' }).display.announce, '注意安全')
|
|
assert.equal(parseSettings({ display: { announce: ' ' } }).display.announce, null)
|
|
assert.equal(parseSettings({ display: { announce: 42 } }).display.announce, null)
|
|
})
|
|
|
|
// 大屏可能整条指令都没收到(断线、后开窗口),所以设置里的公告也要驱动同一套状态机。
|
|
test('快照里的公告变化:新公告进全屏,同一条不重复,服务端清空后跟着清', async (t) => {
|
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
const board = useScoreboard()
|
|
|
|
board.settings.value = parseSettings({ display: { announce: '紧急通知' } })
|
|
await nextTick()
|
|
assert.equal(board.announceText.value, '紧急通知')
|
|
assert.equal(board.page.value, 'announcement', '设置里出现本机没有的公告时要广播全屏')
|
|
|
|
t.mock.timers.tick(DEFAULT_ANNOUNCE_SECONDS * 1000)
|
|
assert.equal(board.page.value, 'standings', '全屏到点退回原页')
|
|
assert.equal(board.announceText.value, '紧急通知', '全屏结束后底栏仍留着这条公告')
|
|
|
|
// 指令推过同一条后,持久化会跟着回推一次设置:不能再全屏一次。
|
|
board.settings.value = parseSettings({ display: { announce: '紧急通知' } })
|
|
await nextTick()
|
|
assert.equal(board.page.value, 'standings')
|
|
assert.equal(board.announcement.value?.text, '紧急通知')
|
|
|
|
board.settings.value = parseSettings({})
|
|
await nextTick()
|
|
assert.equal(board.announcement.value, null)
|
|
assert.equal(board.announceText.value, '暂无公告')
|
|
})
|