560 lines
27 KiB
Rust
560 lines
27 KiB
Rust
//! 原生窗口输入到执行 worker 的纯数据传输。Input 只含窗口/会话/版本/Mount 身份及有限坐标。
|
||
//! 连续相机和滚动输入出队时可更新帧版本,但不改变捕获目标和所属 VM;离散点击仍保留严格的过期帧语义。
|
||
//! 生产端只合并相同目标的相邻绝对指针采样;有序动作是边界。relay 直接进入串行 worker,后台等待确认。
|
||
//!
|
||
//! shell::app 建立 Client 并发送捕获到的 Input;Client 后台 relay 经 api::RuntimeControl::dispatch_native 交给 worker。
|
||
//! HTTP 路由也反序列化同一 Input 并调用 validate,但不会走原生 relay 的连续输入版本刷新。
|
||
//! 这里只合并同目标相邻 pointermove 的绝对采样;滚动/相机的增量逐条保留,不能用最后一个覆盖累计移动。
|
||
use serde::{Deserialize, Serialize};
|
||
use std::{
|
||
collections::VecDeque,
|
||
sync::{Arc, Condvar, Mutex, RwLock},
|
||
thread,
|
||
};
|
||
use winit::event_loop::EventLoopProxy;
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[serde(deny_unknown_fields)]
|
||
/// 拖放目标窗口、目标布局版本和插入位置,源标签身份由外层 Input 保存。
|
||
pub struct TabDrop {
|
||
// 目标原生资源 ID,不是 GUI 标签索引或 Page 托管句柄。
|
||
pub window: u64,
|
||
// 事件所依据的已发布布局版本,由执行端校验过期帧;原生连续手势有单独刷新逻辑。
|
||
pub layout_version: u64,
|
||
// 移除前标签序列中的插入缝位置;同窗口向后移动时 dispatch_tab 会做一次下标修正。
|
||
pub index: usize,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[serde(deny_unknown_fields)]
|
||
/// 原生或 HTTP 输入的纯数据报文;身份与坐标必须同时通过 worker 和能力校验。
|
||
pub struct Input {
|
||
#[serde(default = "default_window")]
|
||
// 省略仅为兼容旧 HTTP 报文,显式 0 永远无效;原生客户端始终记录真实捕获窗口。
|
||
pub window: u64,
|
||
// 产生事件的运行时会话;重载后即使窗口或 Mount 数值相同也不能沿用旧事件。
|
||
pub session: u64,
|
||
// 产生输入时所见帧的几何版本;同会话内也不能把旧坐标直接用到任意新几何。
|
||
pub layout_version: u64,
|
||
// 捕获的具体出现身份;页面级相机事件使用零,独立视口相机事件要求非零。
|
||
pub mount: u64,
|
||
// 分派标签:click/scroll/相机/标签/工具等语义由 validate 与 UiBridge::dispatch 分工检查。
|
||
pub kind: String,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
// 事件位置或缩放锚点;click 与 press_point 配对,具体坐标语义由事件 kind 决定。
|
||
pub point: Option<[f32; 2]>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
// 高层 click 的按下点,与 point 的释放点一起让 source_hit 确认同一个出现被点击。
|
||
pub press_point: Option<[f32; 2]>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
// 平移/滚动增量,或缩放的 [倍率,0];不能无差别接受任意事件携带该字段。
|
||
pub delta: Option<[f32; 2]>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
// 仅 tab-drop 可携带,源端身份仍由外层 window/layout_version/mount 指定。
|
||
pub tab_drop: Option<TabDrop>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
// 拆标签的新窗口位置是屏幕坐标,与页面局部 point 分开;允许多屏环境负坐标。
|
||
pub screen_position: Option<[i32; 2]>,
|
||
}
|
||
|
||
/// serde 在旧报文缺 window 时调用,返回主资源 ID;不会把显式零值改写成合法窗口。
|
||
fn default_window() -> u64 {
|
||
crate::window::MAIN_WINDOW
|
||
}
|
||
|
||
impl Input {
|
||
/// API 路由和 UiBridge 分派前调用,验证报文结构;不读取 VM/发布文档,不能证明 session/Mount 仍有效。
|
||
pub fn validate(&self) -> Result<(), &'static str> {
|
||
if self.window == 0 {
|
||
// 资源身份零保留为非法值,避免丢失 window 的错误报文误投当前窗口。
|
||
return Err("window identity must be nonzero");
|
||
}
|
||
// 标签跨窗口放置仅接受目标元信息;禁止同时夹带指针/滚动/屏幕位置,避免混用两套操作。
|
||
if self.kind == "tab-drop" {
|
||
// 缺目标就没有可解释的插入窗口/位置,不从 point 或其他字段推断落点。
|
||
let Some(target) = &self.tab_drop else {
|
||
return Err("tab-drop requires target metadata");
|
||
};
|
||
if target.window == 0
|
||
|| target.index > crate::ui_bridge::MAX_PAGES
|
||
|| self.point.is_some()
|
||
|| self.press_point.is_some()
|
||
|| self.delta.is_some()
|
||
|| self.screen_position.is_some()
|
||
{
|
||
// 拖放用纯目标元数据,不接受与滚动/点击/拆窗定位混合的多义信封。
|
||
return Err("invalid tab drop metadata");
|
||
}
|
||
return Ok(());
|
||
}
|
||
// 非 tab-drop 不得携带目标窗口信息,即使其他字段恰好合法也拒绝。
|
||
if self.tab_drop.is_some() {
|
||
// 非拖放报文即使 kind 本身合法,也不能夹带被另一层意外消费的重排指令。
|
||
return Err("tab_drop is only accepted for tab-drop");
|
||
}
|
||
// 屏幕位置只服务 tab-detach 的新窗口定位,允许负数以支持左侧/上方显示器,但限制绝对范围。
|
||
if let Some(position) = self.screen_position
|
||
&& (self.kind != "tab-detach"
|
||
|| !position
|
||
.iter()
|
||
.all(|value| (-1_000_000..=1_000_000).contains(value)))
|
||
{
|
||
return Err("screen_position is only accepted for tab-detach with bounded coordinates");
|
||
}
|
||
if matches!(
|
||
self.kind.as_str(),
|
||
"page-pan"
|
||
| "page-zoom"
|
||
| "viewport-pan"
|
||
| "viewport-zoom"
|
||
| "scene-orbit"
|
||
| "scene-pan"
|
||
| "scene-zoom"
|
||
) {
|
||
// 独立视口输入可包含负的局部位置(例如捕获拖动移出视口);页面级锚点要求非负。
|
||
let viewport = self.kind.starts_with("viewport-") || self.kind.starts_with("scene-");
|
||
// 所有相机操作必须带有限、有界的增量;point 在非缩放操作可省略。
|
||
let valid_delta = self.delta.is_some_and(|delta| {
|
||
// 两个增量分量都要有限,不能只验证 zoom 会使用的第一分量而遗漏非法第二分量。
|
||
delta
|
||
.iter()
|
||
.all(|v| v.is_finite() && v.abs() <= 1_000_000.0)
|
||
});
|
||
// 非缩放手势可无锚点;存在时仍限制坐标,局部视口可负以支持捕获拖出边界。
|
||
let valid_point = self.point.is_none_or(|point| {
|
||
point
|
||
.iter()
|
||
.all(|v| v.is_finite() && v.abs() <= 1_000_000.0 && (viewport || *v >= 0.0))
|
||
});
|
||
if !valid_delta
|
||
|| !valid_point
|
||
|| (if viewport {
|
||
self.mount == 0
|
||
} else {
|
||
self.mount != 0
|
||
})
|
||
|| self.press_point.is_some()
|
||
|| self.screen_position.is_some()
|
||
{
|
||
// 页面相机 mount 必为零,局部视口必非零,防止把同一增量套到两种目标空间。
|
||
return Err("invalid page camera input");
|
||
}
|
||
// 缩放必须给锚点并使用正倍率 0.1..=10;第二分量必须为零,不把缩放与二维平移混合编码。
|
||
if self.kind.ends_with("-zoom")
|
||
&& (self.point.is_none()
|
||
|| self
|
||
.delta
|
||
.is_none_or(|delta| !(0.1..=10.0).contains(&delta[0]) || delta[1] != 0.0))
|
||
{
|
||
// 这里仅检查倍率/锚点格式;锚点是否仍在实际视口内由 page_input 结合几何检查。
|
||
return Err("page-zoom requires an anchor and positive scale factor");
|
||
}
|
||
return Ok(());
|
||
}
|
||
// 滚动需要当前点与二维有限增量,不接受按下点;Mount 能力与身份由执行端进一步核对。
|
||
if self.kind == "scroll" {
|
||
return match (self.point, self.press_point, self.delta) {
|
||
(Some(point), None, Some(delta))
|
||
if point
|
||
.iter()
|
||
.all(|v| v.is_finite() && v.abs() <= 1_000_000.0)
|
||
&& delta
|
||
.iter()
|
||
.all(|v| v.is_finite() && v.abs() <= 1_000_000.0) =>
|
||
{
|
||
// 滚动只接点+增量,具体可消费方向、裁剪和嵌套链仍在桥接/滚动模块决定。
|
||
Ok(())
|
||
}
|
||
_ => Err("scroll requires point and finite delta, with no press_point"),
|
||
};
|
||
}
|
||
if matches!(
|
||
self.kind.as_str(),
|
||
"pointermove" | "pointerdown" | "pointerup" | "pointerleave" | "pointercancel"
|
||
) {
|
||
// 原始 pointer 报文要求一个明确目标和绝对位置,不允许夹带 click 的成对按下点或增量。
|
||
return if self.mount != 0
|
||
&& self.press_point.is_none()
|
||
&& self.delta.is_none()
|
||
&& self.screen_position.is_none()
|
||
&& self
|
||
.point
|
||
.is_some_and(|p| p.iter().all(|v| v.is_finite() && v.abs() <= 1_000_000.))
|
||
{
|
||
// 格式合法后还需检查挂载和 pointer_events;此处不把任意 Mount 变成可交互目标。
|
||
Ok(())
|
||
} else {
|
||
// 错误字段或越界坐标统一拒绝,不替发送方删字段修复成另一个事件。
|
||
Err("pointer event requires finite point and target")
|
||
};
|
||
}
|
||
if self.delta.is_some() {
|
||
// 到这里相机与滚动路径都已返回,其余离散操作不得再消费 delta。
|
||
return Err("delta is only accepted for scroll input");
|
||
}
|
||
// 无指针坐标的离散事件可通过此结构校验;若有指针信息,必须是 click 的按下/释放成对坐标。
|
||
// 这里不做完整 kind 白名单授权,具体操作仍由后续分发层检查。
|
||
match (self.point, self.press_point) {
|
||
// 无坐标的 focus/标签等离散动作交后续 kind 白名单与对象能力检查。
|
||
(None, None) => Ok(()),
|
||
(Some(point), Some(press))
|
||
if self.kind == "click"
|
||
&& point
|
||
.into_iter()
|
||
.chain(press)
|
||
.all(|value| value.is_finite() && value.abs() <= 1_000_000.0) =>
|
||
{
|
||
// click 必须同时带两个有效端点,避免只在释放点命中就触发按下在别处的按钮。
|
||
Ok(())
|
||
}
|
||
_ => Err("pointer click requires finite press/release coordinates"),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Client 生产端与 relay 共用的有界状态;只保留尚未提交 worker 的 Input,不包含 VM 对象。
|
||
struct Queue {
|
||
// 相邻同目标绝对移动可在队尾覆盖;其他动作是序列屏障,不能越过它们合并采样。
|
||
items: VecDeque<Input>,
|
||
// 统计活 Client,而非 Arc 总强引用;后台 relay 自己持 Arc 也不能阻止最后生产者关闭队列。
|
||
senders: usize,
|
||
// 最后生产者释放后置位,receive 醒来退出,不再尝试分派已无人负责的旧输入。
|
||
closed: bool,
|
||
}
|
||
/// 队列锁与条件变量的共享入口,Client 只短暂加锁,后台等待不占用 GUI 事件循环。
|
||
struct Inbox {
|
||
queue: Mutex<Queue>,
|
||
ready: Condvar,
|
||
}
|
||
/// shell::app 持有的原生输入发送句柄;后台 relay 负责串行等待 worker 确认,GUI 不等待 VM 执行。
|
||
pub struct Client(Arc<Inbox>);
|
||
impl Clone for Client {
|
||
/// GUI 多个生产者复制句柄时同步增加逻辑生产者数,保证 Drop 不把仍在用的队列提前关闭。
|
||
fn clone(&self) -> Self {
|
||
self.0.queue.lock().unwrap().senders += 1;
|
||
Self(self.0.clone())
|
||
}
|
||
}
|
||
impl Drop for Client {
|
||
/// 原生生产者离开时减少计数;最后一个关闭、清空并唤醒 relay,不因 relay 自己的 Arc 而挂住线程。
|
||
fn drop(&mut self) {
|
||
if let Ok(mut q) = self.0.queue.lock() {
|
||
// 锁内计数/关闭与 send/enqueue 互斥,关闭后不留下新的待发报文。
|
||
q.senders -= 1;
|
||
if q.senders == 0 {
|
||
// 退出丢弃尚未发送输入,不用旧会话的残留交互拖延窗口关闭。
|
||
q.closed = true;
|
||
q.items.clear();
|
||
self.0.ready.notify_all();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
/// Client::send 在锁内调用,最多保留 64 条,队尾同目标 pointermove 可覆盖成最新绝对采样。
|
||
fn enqueue(queue: &mut VecDeque<Input>, input: Input) -> bool {
|
||
if input.kind == "pointermove"
|
||
&& let Some(last) = queue.back_mut()
|
||
&& last.kind == "pointermove"
|
||
&& (last.window, last.session, last.mount) == (input.window, input.session, input.mount)
|
||
{
|
||
// 只覆盖紧邻且同窗/同会话/同 Mount 的移动;按下/抬起或换目标都成为不可跨越的屏障。
|
||
*last = input;
|
||
return true;
|
||
}
|
||
if queue.len() >= 64 {
|
||
// 容量检查在可合并路径之后,因此单一连续移动不会因同类采样填满队列。
|
||
return false;
|
||
}
|
||
queue.push_back(input);
|
||
true
|
||
}
|
||
impl Inbox {
|
||
/// relay 的接收循环调用,条件变量等待新事件或关闭,唤醒后再次检查以处理虚假唤醒。
|
||
fn receive(&self) -> Option<Input> {
|
||
// 锁损坏视为关闭,不尝试从不可靠队列恢复一条可能重复的输入。
|
||
let mut q = self.queue.lock().ok()?;
|
||
while q.items.is_empty() && !q.closed {
|
||
// wait 原子放锁并等待;GUI 生产者可入队,通知后重新取得同一状态锁。
|
||
q = self.ready.wait(q).ok()?;
|
||
}
|
||
if q.closed { None } else { q.items.pop_front() }
|
||
}
|
||
}
|
||
impl Client {
|
||
/// shell::app 初始化时调用,创建有界 Inbox 与单个 relay;RuntimeControl 直达 worker,不绕 HTTP 回环。
|
||
pub fn new(
|
||
proxy: EventLoopProxy<crate::app::AppEvent>,
|
||
namespace: Arc<RwLock<crate::document::NamespaceState>>,
|
||
control: crate::api::RuntimeControl,
|
||
) -> anyhow::Result<Self> {
|
||
// 初始生产者就是返回的 Client;receiver 的 Arc 不计入 senders,避免接收线程自保活。
|
||
let inbox = Arc::new(Inbox {
|
||
queue: Mutex::new(Queue {
|
||
items: VecDeque::new(),
|
||
senders: 1,
|
||
closed: false,
|
||
}),
|
||
ready: Condvar::new(),
|
||
});
|
||
// move 闭包拥有接收 Arc 与发布状态只读入口,线程不取得 VM 所有权。
|
||
let receiver = inbox.clone();
|
||
thread::Builder::new()
|
||
.name("tessera-native-events".into())
|
||
.spawn(move || {
|
||
while let Some(mut input) = receiver.receive() {
|
||
// 每条输入出队时再看最新权威发布帧,连续采样不沿用排队前已经过时的布局版本。
|
||
if let Ok(current) = namespace.read() {
|
||
if !refresh_continuous_input(&mut input, ¤t) {
|
||
// 目标消失/换会话时丢弃,不能把旧手势重定向到同位置的新出现。
|
||
continue;
|
||
}
|
||
} else {
|
||
// 共享发布状态不可读时不分派缺乏校验的输入,等待下一条正常事件。
|
||
continue;
|
||
}
|
||
if let Err(error) = control.dispatch_native(input) {
|
||
// worker 回执错误通过原生事件循环呈现诊断,不直接操作窗口或执行源回调。
|
||
let _ = proxy.send_event(crate::app::AppEvent::RuntimeFailed {
|
||
message: error.to_string(),
|
||
});
|
||
}
|
||
}
|
||
})?;
|
||
Ok(Self(inbox))
|
||
}
|
||
/// shell::app 捕获输入时调用,非阻塞入队/合并后通知 relay;true 不表示 VM 已执行或帧已发布。
|
||
pub fn send(&self, input: Input) -> bool {
|
||
// 锁内仅处理纯数据队列,不把 worker 回执等待放在 GUI 路径。
|
||
let Ok(mut q) = self.0.queue.lock() else {
|
||
return false;
|
||
};
|
||
if q.closed {
|
||
return false;
|
||
}
|
||
// enqueue 的覆盖合并也算接收成功,relay 会读取最终最新样本。
|
||
let queued = enqueue(&mut q.items, input);
|
||
if queued {
|
||
// 单 relay 只需唤醒一个等待者;关闭路径用 notify_all 保证退出不遗留等待。
|
||
self.0.ready.notify_one();
|
||
}
|
||
queued
|
||
}
|
||
}
|
||
|
||
/// Client relay 出队时调用,仅给指定类别的原生输入追随同目标新帧版本;不修改捕获目标和会话身份。
|
||
/// HTTP 调用不走这个入口;click/标签等其余动作直接保留旧版本,后面仍会严格拒绝过期帧。
|
||
pub(crate) fn refresh_continuous_input(
|
||
input: &mut Input,
|
||
current: &crate::document::NamespaceState,
|
||
) -> bool {
|
||
if !matches!(
|
||
input.kind.as_str(),
|
||
"viewport-pan"
|
||
| "viewport-zoom"
|
||
| "scene-pan"
|
||
| "scene-orbit"
|
||
| "scene-zoom"
|
||
| "scroll"
|
||
| "pointermove"
|
||
| "pointerdown"
|
||
| "pointerup"
|
||
| "pointerleave"
|
||
| "pointercancel"
|
||
) {
|
||
// 未列入可追随类别的输入既不丢弃也不改写,在执行端按原几何版本验证。
|
||
return true;
|
||
}
|
||
// 选取事件原窗口的权威帧;窗口已经消失时丢弃,不能把手势改投主窗口。
|
||
let frame = if input.window == crate::window::MAIN_WINDOW {
|
||
Some(¤t.document.frame)
|
||
} else {
|
||
current
|
||
.document
|
||
.windows
|
||
.get(&input.window)
|
||
.map(|document| &document.frame)
|
||
};
|
||
let Some(frame) = frame else {
|
||
// 原窗口不再有权威帧,不能改选 current/main 去继续应用相机或指针动作。
|
||
return false;
|
||
};
|
||
// 必须仍属同一运行时会话、帧无故障且原捕获 Mount 具备对应能力,才可更新布局版本。
|
||
if current.runtime_session != input.session
|
||
|| frame.session != input.session
|
||
|| frame.fault.is_some()
|
||
|| !frame.mounts.iter().any(|mount| {
|
||
mount.id == input.mount
|
||
&& if input.kind.starts_with("viewport-") {
|
||
// 独立二维相机只要求原 Mount 仍为 CanvasViewport。
|
||
mount.canvas_viewport
|
||
} else if input.kind.starts_with("scene-") {
|
||
// 3D 还需相机控制器,禁用控制器后旧手势不能继续改模型。
|
||
mount.scene3d_viewport && mount.camera_controls
|
||
} else if input.kind.starts_with("pointer") {
|
||
// 原始指针必须仍有相应订阅能力,外框存在不足以证明可以继续接收。
|
||
mount.pointer_events
|
||
} else {
|
||
// 剩余唯一可追随类别是 scroll,需保持滚动能力。
|
||
mount.scroll.is_some()
|
||
}
|
||
})
|
||
{
|
||
// 保留会话/目标边界,任一权威身份或能力不匹配都不能仅更新版本后继续发送。
|
||
return false;
|
||
}
|
||
// 只追随新版本,不改 session/window/mount,不重选目标;每条排队增量仍独立发送。
|
||
input.layout_version = frame.layout_version;
|
||
true
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
#[test]
|
||
/// Rust 测试框架调用:用真实持久 VM 反复分派旧帧 pan,验证版本追随不丢增量也不越过会话/目标边界。
|
||
fn queued_native_pan_keeps_each_delta_and_rejects_replaced_targets() {
|
||
// 建立可观测 camera.x 的最小场景,输入目标从实际发布帧选出,不硬编码出现 ID。
|
||
let (mut session, document) = crate::endor_host::ReplSession::load(
|
||
"view=CanvasViewport(200,100,Scene2D(RectBlock(20,20,0)))\npage=Page()\npage.render(view)",
|
||
"app.main", 1, 912,
|
||
).unwrap();
|
||
// 找真实 CanvasViewport 出现,确保 refresh_continuous_input 的能力判定有实际帧数据。
|
||
let mount = document
|
||
.frame
|
||
.mounts
|
||
.iter()
|
||
.find(|mount| mount.canvas_viewport)
|
||
.unwrap()
|
||
.id;
|
||
// 捕获初始版本的固定输入,后面每轮都克隆它,模拟输入生产速度超过刷新速度的队列。
|
||
let original = Input {
|
||
window: 1,
|
||
session: 912,
|
||
layout_version: document.frame.layout_version,
|
||
mount,
|
||
kind: "viewport-pan".into(),
|
||
point: Some([50., 50.]),
|
||
delta: Some([3., 0.]),
|
||
press_point: None,
|
||
tab_drop: None,
|
||
screen_position: None,
|
||
};
|
||
// current 表示 relay 每次出队时能读到的最新共享文档,不直接借用 VM 当前内部草稿。
|
||
let mut current = crate::document::NamespaceState {
|
||
runtime_session: 912,
|
||
document,
|
||
..Default::default()
|
||
};
|
||
// 反复从旧帧原始手势克隆输入,验证每条增量都被刷新版本并累计,12×3 应得到 36。
|
||
for _ in 0..12 {
|
||
// 每轮都从最旧版本重建输入,只有 refresh 的行为能让后续 11 条继续有效。
|
||
let mut queued = original.clone();
|
||
assert!(refresh_continuous_input(&mut queued, ¤t));
|
||
current.document = session.dispatch(&queued).unwrap().document.unwrap();
|
||
}
|
||
assert_eq!(
|
||
session.execute("view.camera.x", "app.main").unwrap().value,
|
||
"36"
|
||
);
|
||
// 离散点击不得自动刷新版本;随后验证会话替换和目标移除都会拒绝连续手势。
|
||
// 同一信封改成离散 click,应只通过“不改写”快路,不享受连续手势的版本追随。
|
||
let mut click = original.clone();
|
||
click.kind = "click".into();
|
||
assert!(refresh_continuous_input(&mut click, ¤t));
|
||
assert_eq!(click.layout_version, original.layout_version);
|
||
// 模拟整程序替换:数字窗口/Mount 即使相同,旧 runtime_session 仍必须使手势失效。
|
||
current.runtime_session = 913;
|
||
assert!(!refresh_continuous_input(&mut original.clone(), ¤t));
|
||
current.runtime_session = 912;
|
||
// 恢复原会话但删掉目标,证明能力/目标检查不只依赖 session 相等。
|
||
current.document.frame.mounts.clear();
|
||
assert!(!refresh_continuous_input(&mut original.clone(), ¤t));
|
||
}
|
||
#[test]
|
||
/// Rust 测试框架调用,逐项破坏合法拖放报文,验证种类与辅助字段绑定、标签上限及多屏坐标范围。
|
||
fn drop_metadata_is_scoped_to_its_event_kind_and_bounded() {
|
||
// 从完整合法 JSON 起步,后面的每次修改只触发一种目标限制,避免无关字段同时掩盖失败原因。
|
||
let mut input:Input=serde_json::from_str(r#"{"window":1,"session":1,"layout_version":2,"mount":3,"kind":"tab-drop","tab_drop":{"window":2,"layout_version":4,"index":1}}"#).unwrap();
|
||
assert!(input.validate().is_ok());
|
||
// tab_drop 元数据不可由普通 click 夹带,否则某层可能意外消费成跨窗口移动。
|
||
input.kind = "click".into();
|
||
assert!(input.validate().is_err());
|
||
input.kind = "tab-drop".into();
|
||
// 数字 65 超过 MAX_PAGES=64 的结构上界,不需要真实 VM 窗口即可拒绝。
|
||
input.tab_drop.as_mut().unwrap().index = 65;
|
||
assert!(input.validate().is_err());
|
||
// 拆窗使用另一种辅助元数据,先去掉 tab_drop,再验证负屏幕坐标合法但极值不合法。
|
||
input.kind = "tab-detach".into();
|
||
input.tab_drop = None;
|
||
input.screen_position = Some([-400, 200]);
|
||
assert!(input.validate().is_ok());
|
||
input.screen_position = Some([i32::MIN, 0]);
|
||
assert!(input.validate().is_err());
|
||
}
|
||
|
||
#[test]
|
||
/// Rust 测试框架调用,区分 serde 的缺字段默认与明确传零,保证兼容不掩盖非法资源身份。
|
||
fn legacy_event_window_defaults_but_zero_is_never_a_resource() {
|
||
// 故意省略 window,检查反序列化确实调用 default_window,而非字段初始零值。
|
||
let mut input: Input =
|
||
serde_json::from_str(r#"{"session":1,"layout_version":2,"mount":3,"kind":"focus"}"#)
|
||
.unwrap();
|
||
assert_eq!(input.window, crate::window::MAIN_WINDOW);
|
||
assert!(input.validate().is_ok());
|
||
// 显式修改为零必须拒绝,不能再次触发 default_window 纠正成主窗口。
|
||
input.window = 0;
|
||
assert!(input.validate().is_err());
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod coalescing_tests {
|
||
use super::*;
|
||
#[test]
|
||
/// Rust 测试框架调用,验证生产端高频绝对移动可合并,动作顺序和跨窗口目标切换仍保持屏障。
|
||
fn producer_side_motion_coalescing_keeps_action_and_target_boundaries() {
|
||
// 默认主窗口、固定 session/Mount 的原始绝对位置采样,供后续复制成不同动作。
|
||
let template: Input = serde_json::from_str(
|
||
r#"{"session":1,"layout_version":1,"mount":2,"kind":"pointermove","point":[0,0]}"#,
|
||
)
|
||
.unwrap();
|
||
// 直接测试 enqueue 的纯队列规则,不启动 relay 或 VM,避免异步消费影响可观察顺序。
|
||
let mut q = VecDeque::new();
|
||
for i in 0..1000 {
|
||
// 即使远超容量 64,相邻同目标 pointermove 也应持续覆盖唯一队尾项。
|
||
let mut sample = template.clone();
|
||
sample.point = Some([i as f32, 0.]);
|
||
assert!(enqueue(&mut q, sample));
|
||
}
|
||
assert_eq!(q.len(), 1);
|
||
assert_eq!(q[0].point.unwrap()[0], 999.);
|
||
// down 是离散动作屏障,不能被下一次移动覆盖或跨过。
|
||
let mut down = template.clone();
|
||
down.kind = "pointerdown".into();
|
||
enqueue(&mut q, down);
|
||
// 新移动只能形成 down 后的一项,不能合并回 down 前的旧移动。
|
||
let mut moved = template.clone();
|
||
moved.point = Some([1200., 0.]);
|
||
enqueue(&mut q, moved);
|
||
// up 再次切开动作序列,保证处理器观察先按下、后移动、再抬起。
|
||
let mut up = template.clone();
|
||
up.kind = "pointerup".into();
|
||
enqueue(&mut q, up);
|
||
// 相同 session/Mount 但换窗口也必须单独成项,目标比较不能漏掉 window。
|
||
let mut other = template;
|
||
other.window = 2;
|
||
enqueue(&mut q, other);
|
||
assert_eq!(
|
||
q.iter().map(|i| i.kind.as_str()).collect::<Vec<_>>(),
|
||
[
|
||
"pointermove",
|
||
"pointerdown",
|
||
"pointermove",
|
||
"pointerup",
|
||
"pointermove"
|
||
]
|
||
);
|
||
assert_eq!(q.back().unwrap().window, 2);
|
||
}
|
||
}
|