Files
ESP32S3_vt/desktop/core/src/snapshot.rs
T
Horldsence a93ae065cf desktop: 双方血条(角上平行四边形)、归零即击败、去掉受击抖动、裁判端可改队名
界面改动都在浏览器预览里逐条核对过(DOM 尺寸 + 视觉),截图留在 desktop/screenshots/
(该目录不进仓库)。

1. 客户端显示对手的血量
   快照里两端都给两支队伍(顺序固定为队伍号 0/1),界面按 `team` 取那一格,不再靠
   数组下标。顺带修掉一个真 bug:用户端"对手那一格"以前会走 `armor_id`——那是我自己
   绑定的主板,于是右上角显示的是自己的血,比不显示更糟。现在只有"自己那一块"才用
   `armor_id`;同理 `assigned_armor` 里的裁判端槽位指定对用户端不再生效。

2. 血条改到画面两个上角,平行四边形,一条分 5 格
   左上角自己、右上角对手(裁判端两格各贴自己的角,读法一致)。整条 skewX(-15deg),
   右条再 scaleX(-1) 镜像,两条斜边朝外对称、**都从靠中心的一侧开始灭**。一格 20 点血。
   条挂在绝对定位的一层上并 pointer-events:none(那一层盖着画面,而画面要能滚轮缩放、
   拖动),倍率与复位徽标因此从上沿挪到下沿。未接入显示"装甲板未接入"而不是 0。
   注意:不要在 JS 里再反转一次数组——那会和 CSS 的 scaleX(-1) 互相抵消,表现是右条
   从外侧开始灭(代码里写了注释钉住这件事)。

3. 受击画面不抖动
   删掉整幅图(含瞄准器)的位移与 shakeOffset。`HitFeedbackConfig::shake` 字段保留但
   **不再生效**:配置文件是 deny_unknown_fields 的,删字段会让所有写过 shake = true 的
   配置解析失败,而配置解析失败等于程序起不来。默认值改 false,设置面板去掉开关,
   并有单测钉住"旧配置读得进来、默认不抖"。

4. 血量归零算击败,不显示"损毁"
   血条上不再出现"已击毁/损毁",归零的结论放到图传中央:胜利 / 击败 / 平局。结论在
   Rust 侧算(snapshot::resolve_outcomes,4 条单测),规则有意不对称——**输只看自己**
   (自己归零就是输,哪怕对手主板掉线),**赢要看两边**(对手归零且自己还有血),
   两边同时归零是平局。它一直显示到血量被重置,不做几秒淡出,否则截图与回放里就再也
   没有"这局结束了"的痕迹。

5. 裁判端可以改队伍名
   两格画面各自下面一个输入框:回车/失焦生效、Esc 取消、超 12 字截断、留空回落到
   「队伍一 / 队伍二」。命令是 `set_team_name(team, name)`,**一次只改一边**——传整个
   两条的数组就成了"读两条、改一条、写两条",而读来的那两条来自最多 200ms 前的快照,
   紧接着改另一边会把上一次的改动覆盖回去。规范化统一在 `config::sanitize_team_name`
   (合并空白、按字符截断、留空回落),并同样过密码闸门(裁判端本来就没有密码)。
   名字只有一个来源(快照的 `teamNames` / DTO 的 `name`):装甲板表、槽位标签、设置
   面板与"等待图传"的占位文字都跟着改,不再有第二处自己拼"队伍一/队伍二"。

测试:core 80(69→80)、src-tauri 30、protocol 15;`pnpm build`(tsc + vite)通过。
2026-10-14 20:48:12 +08:00

702 lines
24 KiB
Rust

//! 推给界面的快照类型。
//!
//! # 为什么单独一个模块
//!
//! 这些结构体的字段名就是**界面看到的 JSON 字段名**,所以它们是接口的一部分,值得
//! 集中放、集中改。放在这里而不是 Tauri 层,是因为其中几个的构造规则(比如"关掉
//! 诊断时不留数字")需要单元测试。
//!
//! # 为什么 id 同时给数字和文本
//!
//! 界面显示、日志、配置文件里用的都是 `0x3D4D8A9A` 这种十六进制形式。让 Rust 侧拼好
//! (`id_text`) 而不是让前端各处自己 `toString(16)`,可以避免"某处少补了前导零"这种
//! 只能靠肉眼比对才发现的不一致。
use serde::Serialize;
use crate::armor::ArmorBoard;
use crate::config::{Config, CrosshairConfig, GridConfig, HealthConfig, HitFeedbackConfig, Role};
use crate::receiver::{CameraInfo, SourceStats};
/// 把 id 写成界面上、配置文件里、抓包时都一致的形式。
pub fn id_text(id: u32) -> String {
format!("0x{id:08X}")
}
/// 一块图传板。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CameraDto {
pub id: u32,
pub id_text: String,
pub addr: String,
pub online: bool,
pub silence_ms: u64,
pub firmware: u16,
}
impl From<&CameraInfo> for CameraDto {
fn from(camera: &CameraInfo) -> Self {
Self {
id: camera.id,
id_text: id_text(camera.id),
addr: camera.addr.ip().to_string(),
online: camera.is_online(),
silence_ms: camera.silence().as_millis() as u64,
firmware: camera.firmware,
}
}
}
/// 一块装甲板主板。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ArmorBoardDto {
pub id: u32,
pub id_text: String,
pub addr: String,
pub online: bool,
/// 板子自报的队伍(编译期定的 `ARMOR_TEAM`)。
pub team: u8,
/// 最后一次读数。整车一条血,所以这里一个数。
pub hp: u16,
pub plates_seen: u8,
pub reports_all_plates: bool,
pub silence_ms: u64,
}
impl From<&ArmorBoard> for ArmorBoardDto {
fn from(board: &ArmorBoard) -> Self {
Self {
id: board.id,
id_text: id_text(board.id),
addr: board.addr.ip().to_string(),
online: board.is_online(),
team: board.team,
hp: board.hp,
plates_seen: board.plates_seen.count_ones() as u8,
reports_all_plates: board.reports_all_plates(),
silence_ms: board.silence().as_millis() as u64,
}
}
}
/// 一路画面的读数。
///
/// # 关掉诊断时这些数字**不存在**,而不是被界面藏起来
///
/// 旧客户端在队伍端 release 下把这些字段填成空字符串。现在换成数字,规则不变:非诊断
/// 模式下它们被置零/置空。由编译模式决定而不是命令行参数——命令行开关意味着二进制里
/// 带着那套东西,忘开一次就泄露了。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CameraStatsDto {
pub camera: u32,
pub camera_text: String,
pub online: bool,
/// 界面直接显示这一行,所以"在线但还没画面"与"离线"的区别由这里给出,而不是让
/// 前端从 online + hasFrames 猜。
pub status: String,
pub has_frames: bool,
pub fps: f32,
pub width: u16,
pub height: u16,
pub frames_shown: u64,
pub frames_dropped: u64,
pub loss_percent: f32,
pub datagrams: u64,
pub last_frame_bytes: usize,
pub gap_ms: Option<f32>,
}
impl CameraStatsDto {
/// `diagnostics` 为 false 时不产出技术数字(见类型文档)。
pub fn build(
diagnostics: bool,
camera: u32,
online: bool,
stats: &SourceStats,
fps: f32,
) -> Self {
let has_frames = stats.frames_shown > 0;
// 从没出过画面时区分"在线但还没收到"和"离线":前者只是刚连上,显示"等待画面"
// 比让人以为板子挂了诚实。
//
// 有画面时显示 id —— 这对调试有用。队伍端 release 下这一路只有自己绑定的板子,
// 显示它不构成泄露(界面上根本没有别人的板子可选)。
let status = if !has_frames {
if online {
"已连接,等待画面…".to_string()
} else {
"离线".to_string()
}
} else {
id_text(camera)
};
if !diagnostics {
return Self {
camera,
camera_text: id_text(camera),
online,
status,
has_frames,
fps: 0.0,
width: 0,
height: 0,
frames_shown: 0,
frames_dropped: 0,
loss_percent: 0.0,
datagrams: 0,
last_frame_bytes: 0,
gap_ms: None,
};
}
// 没出过一个字节的源,`stats.width`/`height` 还是默认值 0,于是界面会显示成
// "0x0"——一个看起来像分辨率的假分辨率。用 has_frames 把它挡掉。
Self {
camera,
camera_text: id_text(camera),
online,
status,
has_frames,
fps: if has_frames { fps } else { 0.0 },
width: if has_frames { stats.width } else { 0 },
height: if has_frames { stats.height } else { 0 },
frames_shown: stats.frames_shown,
frames_dropped: stats.frames_dropped,
loss_percent: stats.loss_percent(),
datagrams: stats.datagrams,
last_frame_bytes: stats.last_frame_len,
gap_ms: stats.gap_ms(),
}
}
}
/// 一边的血量。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TeamHealthDto {
pub team: u8,
pub name: String,
/// 有没有可信的数据。false 时界面显示 `placeholder` 而不是 0——0 是一个看起来像
/// 真值的假值,现场看到 0 会以为对方已经没血了。
pub available: bool,
pub placeholder: String,
/// 整车血量(四条装甲板同值,所以取第一条)。
pub hp: Option<u16>,
/// 四条装甲板各自的读数;`available` 为 true 时有效。
pub hps: Vec<u16>,
/// 绑定的装甲板主板 id(用户端)/ 分配到的(裁判端)。
pub board: Option<u32>,
pub board_text: Option<String>,
/// 血量偏低或已归零。
pub critical: bool,
/// 这一边的胜负结论,由 [`resolve_outcomes`] 从**双方**的血量算出来。
///
/// 放在每一边的 DTO 上而不是单独一个全局字段:界面上一格画面就是一边,一个格子里
/// 要放的就是"从我这边看,现在是什么结果"。裁判端两格因此各看各的那份。
pub outcome: Option<MatchOutcome>,
}
/// 一边看到的比赛结果。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum MatchOutcome {
/// 对手归零,自己还活着。
Victory,
/// 自己归零,对手还活着。
Defeat,
/// 两边同时归零。不写成"平局"以外的任何一个字:同时打空时两边都看到"胜利"才是错的。
Draw,
}
impl TeamHealthDto {
/// 还没有数据源。
pub fn absent(team: u8, board: Option<u32>, names: &[String; 2]) -> Self {
Self {
team,
name: team_name(team, names),
available: false,
placeholder: format!(
"装甲板未接入:等主板在 UDP {} 上广播血量",
protocol::ARMOR_PORT
),
hp: None,
// 依然给出 4 个格子:界面用 `available` 决定颜色,空数组会让占位框整个消失,
// 看起来像布局出错,而不是像"还没有数据"。
hps: vec![0; crate::receiver::ARMOR_BOARDS],
board,
board_text: board.map(id_text),
critical: false,
outcome: None,
}
}
/// 有数据。
pub fn present(
team: u8,
board: Option<u32>,
hps: [u16; crate::receiver::ARMOR_BOARDS],
low: u16,
names: &[String; 2],
) -> Self {
let hp = hps.first().copied().unwrap_or(0);
Self {
team,
name: team_name(team, names),
available: true,
placeholder: String::new(),
hp: Some(hp),
hps: hps.to_vec(),
board,
board_text: board.map(id_text),
critical: hp == 0 || hp <= low,
outcome: None,
}
}
}
/// 把双方的血量合起来算成各自看到的胜负结论,写回 `outcome`。
///
/// # 规则是有意不对称的
///
/// - **输只需要看自己**:自己归零就是输,哪怕对手那块板子的数据没收到。这一条要成立,
/// 否则"我被打死了但对手的主板掉线"会显示成什么都没发生。
/// - **赢要看两边**:必须看到对手归零**且**自己还有血。少了后一半,一块从没上报过血量的
/// 自己方主板会让人"凭空胜利"。
/// - 两边同时归零是平局:那种情况下任何一边显示"胜利"都是错的。
///
/// 变量不足(对不上的队伍、只有一份 DTO)时什么都不写。
pub fn resolve_outcomes(health: &mut [TeamHealthDto]) {
if health.len() != 2 {
return;
}
let dead = |index: usize| health[index].available && health[index].hp == Some(0);
let alive = |index: usize| matches!(health[index].hp, Some(hp) if hp > 0) && health[index].available;
let (a_dead, b_dead) = (dead(0), dead(1));
let (a_alive, b_alive) = (alive(0), alive(1));
health[0].outcome = if a_dead && b_dead {
Some(MatchOutcome::Draw)
} else if b_dead && a_alive {
Some(MatchOutcome::Victory)
} else if a_dead {
Some(MatchOutcome::Defeat)
} else {
None
};
health[1].outcome = if a_dead && b_dead {
Some(MatchOutcome::Draw)
} else if a_dead && b_alive {
Some(MatchOutcome::Victory)
} else if b_dead {
Some(MatchOutcome::Defeat)
} else {
None
};
}
/// 队伍名的兜底值(与 `Config::default().team_names` 一致)。
pub fn default_team_name(team: u8) -> String {
crate::config::DEFAULT_TEAM_NAMES[usize::from(team.min(1))].to_string()
}
/// 这一边在界面上叫什么。
///
/// 配置里的名字是裁判端可以改的,所以名字必须**从这里来**,不能让界面各处自己拼
/// "队伍一"/"队伍二"——那种拼法会在改了名字之后留下一半旧名字。
pub fn team_name(team: u8, names: &[String; 2]) -> String {
let name = names[usize::from(team.min(1))].trim();
if name.is_empty() {
default_team_name(team)
} else {
name.to_string()
}
}
/// 一次掉血(由 [`crate::armor::HealthTracker`] 派生)。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HitDto {
pub board: u32,
pub team: u8,
pub index: u8,
pub from: u16,
pub to: u16,
pub damage: u16,
/// Unix 毫秒。前端用它驱动闪红的动画,不需要自己计时。
pub at_ms: u64,
}
/// 录制状态。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RecordingDto {
pub camera: u32,
pub camera_text: String,
pub active: bool,
pub path: Option<String>,
/// 告诉 ffmpeg 的输入帧率。回放快放/慢放时,第一个要看的就是这个数。
pub input_fps: f32,
pub frames_written: u64,
/// 因为写入端跟不上而丢掉的帧。丢帧不报出来的话,回放里"少了半秒"会看起来像
/// 相机的问题。
pub frames_dropped: u64,
pub started_ms: Option<u64>,
pub error: Option<String>,
}
/// 裁判端两个槽位。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SlotsDto {
/// 左槽位 → 摄像头 id。
pub camera_a: Option<u32>,
pub camera_b: Option<u32>,
/// 左槽位 → 装甲板主板 id。
pub armor_a: Option<u32>,
pub armor_b: Option<u32>,
pub manual: bool,
}
/// 推给界面的完整状态。
///
/// 一次性给全,而不是让界面按需多次查询:5 Hz 下这条消息只有几百字节,而"界面各处
/// 分别拉数据"会引入一类难查的问题——两个面板显示的是不同时刻的状态(比如绑定改了
/// 一半)。前端因此只需要一个 reducer。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Snapshot {
pub role: Role,
pub team: u8,
pub mirrored: bool,
/// 演示模式:画面与血量是本地合成的,不是场上的板子。界面必须把它显示出来,
/// 否则截图与验收会被误读成真实链路。
pub demo: bool,
/// 是否产出技术读数(帧率/丢帧)。用户端 release 下为 false。
pub diagnostics: bool,
/// 密码闸门是否已经打开(会话内)。
pub unlocked: bool,
/// 发现列表是否放开(设置面板打开时)。开着才可能列出别人的板子。
pub discovery_open: bool,
pub uptime_ms: u64,
/// 连着这个后端的界面数。
pub clients: usize,
pub config: ConfigView,
pub cameras: Vec<CameraDto>,
pub armor_boards: Vec<ArmorBoardDto>,
/// 每块已知摄像头的读数。
pub stats: Vec<CameraStatsDto>,
/// 裁判端两个槽位。用户端的 `a` 就是它绑定的那一块。
pub slots: SlotsDto,
/// **两端都是两条,顺序是队伍号 0、1**:用户端也要显示对手的血量(画面右上角)。
///
/// 界面按 `team` 取自己那一格,不靠数组下标——用户端绑的是队伍二时 `health[0]` 是别人。
pub health: Vec<TeamHealthDto>,
pub recordings: Vec<RecordingDto>,
/// 启动时的说明(旧配置迁移、端口占用等)。
pub notes: Vec<String>,
}
/// 配置里可以给界面看的那部分。
///
/// **不含密码哈希**:界面只需要知道"设过没有",把 PHC 字符串送进 WebView 没有任何用处,
/// 只是多一个能被日志、崩溃报告带走的东西。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfigView {
pub role: Role,
/// 身份是否已由人选过。false 时界面先问一次(见 `Config::role_chosen`)。
pub role_chosen: bool,
pub team: u8,
/// 两边在界面上显示的名字(裁判端可以改)。界面各处显示队伍名都要用它,
/// 包括血条之外的地方(装甲板表、等待画面的占位文字)。
pub team_names: [String; 2],
pub camera_id: Option<u32>,
pub camera_id_text: Option<String>,
pub armor_id: Option<u32>,
pub armor_id_text: Option<String>,
pub mirror: bool,
pub password_set: bool,
pub hud: HudView,
pub recording_dir: String,
pub ffmpeg: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HudView {
pub health: HealthConfig,
pub crosshair: CrosshairConfig,
pub grid: GridConfig,
pub hit_feedback: HitFeedbackConfig,
pub show_zoom: bool,
pub show_stats: bool,
}
impl From<&Config> for ConfigView {
fn from(config: &Config) -> Self {
Self {
role: config.role,
role_chosen: config.role_chosen,
team: config.team,
team_names: config.team_names.clone(),
camera_id: config.camera_id,
camera_id_text: config.camera_id.map(id_text),
armor_id: config.armor_id,
armor_id_text: config.armor_id.map(id_text),
mirror: config.mirror,
password_set: config.password.is_some(),
hud: HudView {
health: config.hud.health.clone(),
crosshair: config.hud.crosshair.clone(),
grid: config.hud.grid.clone(),
hit_feedback: config.hud.hit_feedback.clone(),
show_zoom: config.hud.show_zoom,
show_stats: config.hud.show_stats,
},
recording_dir: config.recordings_dir().display().to_string(),
ffmpeg: config.recording.ffmpeg.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::receiver::SourceStats;
fn stats() -> SourceStats {
let mut stats = SourceStats::default();
// 直接构造:SourceStats 的字段是 pub 的,只有 last_shown_at 是私有的。
stats.frames_shown = 440;
stats.frames_dropped = 10;
stats.datagrams = 8000;
stats.width = 640;
stats.height = 480;
stats.last_frame_len = 22_000;
stats
}
#[test]
fn diagnostics_off_leaves_no_numbers_behind() {
// 不是"界面藏起来",是**不存在**。
let dto = CameraStatsDto::build(false, 0x3D4D_8A9A, true, &stats(), 22.0);
assert_eq!(dto.frames_shown, 0);
assert_eq!(dto.frames_dropped, 0);
assert_eq!(dto.datagrams, 0);
assert_eq!(dto.fps, 0.0);
assert_eq!(dto.width, 0);
assert_eq!(dto.gap_ms, None);
assert_eq!(dto.last_frame_bytes, 0);
// 但界面仍然需要知道画面来没来。
assert!(dto.has_frames);
assert!(dto.online);
}
#[test]
fn diagnostics_on_reports_the_real_numbers() {
let dto = CameraStatsDto::build(true, 0x3D4D_8A9A, true, &stats(), 22.0);
assert_eq!(dto.frames_shown, 440);
assert_eq!(dto.frames_dropped, 10);
assert!((dto.loss_percent - 2.2222).abs() < 0.01);
assert_eq!((dto.width, dto.height), (640, 480));
assert_eq!(dto.fps, 22.0);
assert_eq!(dto.status, "0x3D4D8A9A");
}
#[test]
fn a_never_seen_camera_does_not_report_a_fake_resolution() {
let dto = CameraStatsDto::build(true, 1, true, &SourceStats::default(), 0.0);
assert_eq!(dto.status, "已连接,等待画面…");
assert_eq!((dto.width, dto.height), (0, 0));
assert!(!dto.has_frames, "0x0 would read like a real resolution");
}
#[test]
fn an_offline_camera_says_offline() {
let dto = CameraStatsDto::build(true, 1, false, &SourceStats::default(), 0.0);
assert_eq!(dto.status, "离线");
}
/// 默认队名,测试里到处用。
fn names() -> [String; 2] {
["队伍一".to_string(), "队伍二".to_string()]
}
#[test]
fn absent_health_is_a_placeholder_with_four_cells() {
let dto = TeamHealthDto::absent(0, None, &names());
assert!(!dto.available);
assert_eq!(dto.hp, None);
assert_eq!(dto.hps.len(), crate::receiver::ARMOR_BOARDS);
assert!(!dto.placeholder.is_empty());
assert_eq!(dto.outcome, None, "没有数据时不下结论");
}
#[test]
fn critical_health_is_flagged_at_zero_and_at_the_threshold() {
let low = TeamHealthDto::present(0, Some(1), [30, 30, 30, 30], 30, &names());
assert!(low.critical, "at the threshold");
let ok = TeamHealthDto::present(0, Some(1), [31, 31, 31, 31], 30, &names());
assert!(!ok.critical);
let dead = TeamHealthDto::present(0, Some(1), [0, 0, 0, 0], 30, &names());
assert!(dead.critical);
assert_eq!(dead.hp, Some(0));
}
#[test]
fn the_team_name_comes_from_the_config_not_from_a_hard_coded_string() {
let custom = ["红队".to_string(), "蓝队".to_string()];
assert_eq!(TeamHealthDto::absent(1, None, &custom).name, "蓝队");
assert_eq!(
TeamHealthDto::present(0, Some(1), [50; 4], 30, &custom).name,
"红队"
);
// 空名字回落默认值,而不是显示一个没有名字的血条。
let blank = ["".to_string(), " ".to_string()];
assert_eq!(team_name(0, &blank), "队伍一");
assert_eq!(team_name(1, &blank), "队伍二");
assert_eq!(team_name(9, &blank), "队伍二", "越界的队伍号按第二队处理");
}
#[test]
fn killing_the_opponent_reads_as_a_victory_for_the_other_side() {
let mut health = vec![
TeamHealthDto::present(0, Some(1), [100; 4], 30, &names()),
TeamHealthDto::present(1, Some(2), [0; 4], 30, &names()),
];
resolve_outcomes(&mut health);
assert_eq!(health[0].outcome, Some(MatchOutcome::Victory));
assert_eq!(health[1].outcome, Some(MatchOutcome::Defeat));
}
#[test]
fn nothing_is_announced_while_both_sides_are_alive() {
let mut health = vec![
TeamHealthDto::present(0, Some(1), [100; 4], 30, &names()),
TeamHealthDto::present(1, Some(2), [40; 4], 30, &names()),
];
resolve_outcomes(&mut health);
assert_eq!(health[0].outcome, None);
assert_eq!(health[1].outcome, None);
}
#[test]
fn both_sides_at_zero_is_a_draw_rather_than_two_victories() {
let mut health = vec![
TeamHealthDto::present(0, Some(1), [0; 4], 30, &names()),
TeamHealthDto::present(1, Some(2), [0; 4], 30, &names()),
];
resolve_outcomes(&mut health);
assert_eq!(health[0].outcome, Some(MatchOutcome::Draw));
assert_eq!(health[1].outcome, Some(MatchOutcome::Draw));
}
#[test]
fn a_dead_team_loses_even_when_the_other_board_is_missing() {
// 输只看自己:对手的主板掉线不该让"我被击毁"变成什么都没发生。
let mut health = vec![
TeamHealthDto::present(0, Some(1), [0; 4], 30, &names()),
TeamHealthDto::absent(1, None, &names()),
];
resolve_outcomes(&mut health);
assert_eq!(health[0].outcome, Some(MatchOutcome::Defeat));
assert_eq!(health[1].outcome, None, "没有自己数据的这边不下结论");
}
#[test]
fn a_missing_own_board_does_not_produce_a_victory() {
// 赢要看两边:只看到对手归零、不知道自己还有没有血时不能宣布胜利。
let mut health = vec![
TeamHealthDto::absent(0, None, &names()),
TeamHealthDto::present(1, Some(2), [0; 4], 30, &names()),
];
resolve_outcomes(&mut health);
assert_eq!(health[0].outcome, None);
assert_eq!(health[1].outcome, Some(MatchOutcome::Defeat));
}
#[test]
fn the_snapshot_serialises_the_outcome_as_a_plain_word() {
let mut health = vec![
TeamHealthDto::present(0, Some(1), [0; 4], 30, &names()),
TeamHealthDto::present(1, Some(2), [50; 4], 30, &names()),
];
resolve_outcomes(&mut health);
let json = serde_json::to_string(&health[0]).unwrap();
assert!(json.contains(r#""outcome":"defeat""#), "got {json}");
let json = serde_json::to_string(&health[1]).unwrap();
assert!(json.contains(r#""outcome":"victory""#), "got {json}");
}
#[test]
fn a_recording_reports_the_framerate_it_told_ffmpeg() {
// 回放速度不对时,这是唯一能解释原因的数字。
let dto = RecordingDto {
camera: 1,
camera_text: id_text(1),
active: true,
path: None,
input_fps: 21.5,
frames_written: 0,
frames_dropped: 0,
started_ms: None,
error: None,
};
let json = serde_json::to_string(&dto).unwrap();
assert!(json.contains(r#""inputFps":21.5"#), "got {json}");
}
#[test]
fn the_config_view_never_carries_the_password_hash() {
let mut config = Config::default();
config.role = Role::Team;
config.password = Some("$argon2id$super$secret$hash".into());
let view = ConfigView::from(&config);
assert!(view.password_set);
let json = serde_json::to_string(&view).unwrap();
assert!(
!json.contains("argon2id"),
"the hash must not reach the webview: {json}"
);
}
#[test]
fn ids_are_formatted_like_everywhere_else() {
assert_eq!(id_text(0x3D4D_8A9A), "0x3D4D8A9A");
assert_eq!(id_text(1), "0x00000001", "leading zeros matter for eyeballing");
}
}