888 lines
34 KiB
Rust
888 lines
34 KiB
Rust
//! API 集成回归。通过实际本地 HTTP 请求检查队列、候选替换、状态码、版本和完整文档发布。
|
|
//! 测试服务使用独立监听地址和串行 worker;应结合断言理解成功输入、失败候选、过期事件和关闭的不同保留语义。
|
|
//!
|
|
use super::*;
|
|
use std::{
|
|
io::Write,
|
|
net::{SocketAddr, TcpStream},
|
|
sync::atomic::{AtomicUsize, Ordering},
|
|
time::Duration,
|
|
};
|
|
|
|
#[test]
|
|
/// 模拟 reset 后旧画面仍含编号 2 的窗口,而新 Console VM 恰好再次分配编号 2。
|
|
/// 关闭 session=0 的旧投影只能移除画面,不能按相同数字误关新 VM 的真实资源。
|
|
fn preserved_picture_close_does_not_retire_same_number_in_fresh_console_vm() {
|
|
let (_, mut picture) = endor_host::ReplSession::load(
|
|
include_str!("../../../examples/windows.endor"),
|
|
"app.main",
|
|
1,
|
|
150,
|
|
)
|
|
.unwrap();
|
|
// 把旧文档标成无活动 VM 的保留投影;编号相同也不能据此识别当前资源。
|
|
picture.frame.session = 0;
|
|
for view in picture.windows.values_mut() {
|
|
view.frame.session = 0;
|
|
}
|
|
let mut fresh = endor_host::ReplSession::new(151).unwrap();
|
|
assert!(
|
|
fresh
|
|
.execute("pending=Window()\nui.window().resize(0,600)", "app.main")
|
|
.is_err()
|
|
);
|
|
assert!(fresh.accepts_window(2));
|
|
let namespace = Arc::new(RwLock::new(NamespaceState {
|
|
document: picture,
|
|
submission: 1,
|
|
runtime_session: 151,
|
|
..NamespaceState::default()
|
|
}));
|
|
let mut worker = Worker {
|
|
repl: Some((1, fresh)),
|
|
next_session: 151,
|
|
};
|
|
let notify: Notify = Arc::new(|| {});
|
|
namespace.write().unwrap().window_viewports.insert(
|
|
2,
|
|
crate::layout::Size {
|
|
width: 800.0,
|
|
height: 600.0,
|
|
},
|
|
);
|
|
worker.refresh_viewport(&namespace, ¬ify);
|
|
assert_eq!(
|
|
namespace.read().unwrap().document.windows[&2].frame.session,
|
|
0
|
|
);
|
|
let response = worker.close_inactive_window(
|
|
&crate::events::Input {
|
|
window: 2,
|
|
session: 0,
|
|
layout_version: 0,
|
|
mount: 0,
|
|
kind: "window-close".into(),
|
|
point: None,
|
|
delta: None,
|
|
press_point: None,
|
|
tab_drop: None,
|
|
screen_position: None,
|
|
},
|
|
1,
|
|
&namespace,
|
|
¬ify,
|
|
);
|
|
assert_eq!(response.status_code(), StatusCode(200));
|
|
assert!(namespace.read().unwrap().document.windows.is_empty());
|
|
let runtime = &mut worker.repl.as_mut().unwrap().1;
|
|
assert_eq!(runtime.id, 151);
|
|
assert!(runtime.accepts_window(2));
|
|
assert_eq!(
|
|
runtime.execute("pending.id()", "app.main").unwrap().value,
|
|
"2"
|
|
);
|
|
}
|
|
|
|
/// 一次测试独占的临时 HTTP 服务;端口由系统分配,持有线程句柄以便结束时解除阻塞并回收。
|
|
struct TestServer {
|
|
server: Arc<Server>,
|
|
address: SocketAddr,
|
|
api: Option<thread::JoinHandle<()>>,
|
|
}
|
|
|
|
impl TestServer {
|
|
/// 发送带明确 Content-Length 的 UTF-8 文本请求,使用 Connection: close 读至响应结束。
|
|
/// 返回状态码、原始头和正文;10 秒超时使服务端死锁表现为测试失败而非无限等待。
|
|
fn request(&self, method: &str, path: &str, body: &str) -> (u16, String, String) {
|
|
let mut stream = TcpStream::connect(self.address).unwrap();
|
|
stream
|
|
.set_read_timeout(Some(Duration::from_secs(10)))
|
|
.unwrap();
|
|
stream
|
|
.set_write_timeout(Some(Duration::from_secs(10)))
|
|
.unwrap();
|
|
write!(stream, "{method} {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\n\r\n{body}", body.len()).unwrap();
|
|
let mut response = String::new();
|
|
stream.read_to_string(&mut response).unwrap();
|
|
let (headers, body) = response.split_once("\r\n\r\n").unwrap();
|
|
let status = headers.split_whitespace().nth(1).unwrap().parse().unwrap();
|
|
(status, headers.to_owned(), body.to_owned())
|
|
}
|
|
}
|
|
|
|
impl Drop for TestServer {
|
|
/// 解除服务端 accept 等待并等待线程结束,避免测试之间遗留监听线程。
|
|
fn drop(&mut self) {
|
|
self.server.unblock();
|
|
if let Some(api) = self.api.take() {
|
|
let _ = api.join();
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
/// 通过 HTTP 提交覆盖 moveTab 的 Page 子类,再发送带帧身份的 tab-drop。
|
|
/// 检查源覆盖恰好调用一次、页面顺序改变,以及重放旧事件返回 409 而不损坏运行时。
|
|
fn native_tab_drop_route_reorders_live_pages_and_rejects_reused_frames() {
|
|
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
|
|
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
|
|
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
|
|
let client = TestServer {
|
|
address: server.server_addr().to_ip().unwrap(),
|
|
server,
|
|
api: Some(api),
|
|
};
|
|
let source = r#"class Tracked: Page {
|
|
calls: Object
|
|
function moveTab(self: Tracked, position: Number): Page { self.calls.increment() return Page.moveTab(self,position) }
|
|
}
|
|
calls=ui.state(0)
|
|
first=Tracked(calls)
|
|
first.title="first"
|
|
first.render(TextBlock("first"))
|
|
second=Page()
|
|
second.title="second"
|
|
second.render(TextBlock("second"))
|
|
first.activate()"#;
|
|
let result = client.request("PUT", "/v1/namespaces/app.main/code", source);
|
|
assert_eq!(result.0, 200, "{}", result.2);
|
|
let input = {
|
|
let current = namespace.read().unwrap();
|
|
let frame = ¤t.document.frame;
|
|
crate::events::Input {
|
|
window: 1,
|
|
session: frame.session,
|
|
layout_version: frame.layout_version,
|
|
mount: frame.tabs[0].id,
|
|
kind: "tab-drop".into(),
|
|
point: None,
|
|
press_point: None,
|
|
delta: None,
|
|
screen_position: None,
|
|
tab_drop: Some(crate::events::TabDrop {
|
|
window: 1,
|
|
layout_version: frame.layout_version,
|
|
index: 2,
|
|
}),
|
|
}
|
|
};
|
|
// 保留同一事件正文用于重放测试,不能重新读取新 layout_version,否则就不是过期输入。
|
|
let body = serde_json::to_string(&input).unwrap();
|
|
let response = client.request("POST", "/v1/namespaces/app.main/events", &body);
|
|
assert_eq!(response.0, 200, "{}", response.2);
|
|
let response = client.request("POST", "/v1/namespaces/app.main/repl", "calls.value");
|
|
let value: serde_json::Value = serde_json::from_str(&response.2).unwrap();
|
|
assert_eq!(value["value"], "1");
|
|
assert_eq!(
|
|
namespace.read().unwrap().document.frame.tabs[1].title,
|
|
"first"
|
|
);
|
|
assert_eq!(
|
|
client
|
|
.request("POST", "/v1/namespaces/app.main/events", &body)
|
|
.0,
|
|
409
|
|
);
|
|
assert!(namespace.read().unwrap().document.frame.fault.is_none());
|
|
}
|
|
|
|
#[test]
|
|
/// 零延时任务触发非法窗口尺寸,使后台回调先产生运行时故障。
|
|
/// 之后多次 REPL 请求都应被拒绝,同时保留原始 stop/window size 诊断,而非用后续错误覆盖。
|
|
fn background_failure_preserves_its_cause_after_rejected_console_input() {
|
|
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
|
|
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
|
|
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
|
|
let client = TestServer {
|
|
address: server.server_addr().to_ip().unwrap(),
|
|
server,
|
|
api: Some(api),
|
|
};
|
|
let source = r#"class Failure {
|
|
function stop(self: Failure): Unit { ui.window().resize(0,600) }
|
|
}
|
|
failure=Failure()
|
|
page=Page()
|
|
page.render(TextBlock("preserved"))
|
|
task=ui.after(ui,"failure",0,failure.stop)"#;
|
|
let result = client.request("PUT", "/v1/namespaces/app.main/code", source);
|
|
assert_eq!(result.0, 200, "{}", result.2);
|
|
let repl = "/v1/namespaces/app.main/repl";
|
|
assert_eq!(client.request("POST", repl, "42").0, 422);
|
|
let fault = namespace
|
|
.read()
|
|
.unwrap()
|
|
.document
|
|
.frame
|
|
.fault
|
|
.clone()
|
|
.unwrap();
|
|
assert!(fault.contains("window size"), "{fault}");
|
|
assert!(fault.contains("stop"), "{fault}");
|
|
assert_eq!(client.request("POST", repl, "42").0, 422);
|
|
assert_eq!(
|
|
namespace.read().unwrap().document.frame.fault.as_ref(),
|
|
Some(&fault)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
/// 依次创建未挂载对象、读取共享值、挂载、reset,再用完整 PUT 替换会话。
|
|
/// 验证未挂载输入不增 revision,reset 清绑定但保留画面,成功 PUT 才切换到新 VM。
|
|
fn console_keeps_memory_until_reset_or_complete_put_without_erasing_the_page() {
|
|
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
|
|
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
|
|
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
|
|
let client = TestServer {
|
|
address: server.server_addr().to_ip().unwrap(),
|
|
server,
|
|
api: Some(api),
|
|
};
|
|
let repl = "/v1/namespaces/app.main/repl";
|
|
let code = "/v1/namespaces/app.main/code";
|
|
// 先创建对象但不 render;返回值可以跨 REPL 请求复用,发布 revision 此时仍为 0。
|
|
let created = client.request("POST", repl, "title = ui.text(\"中文\")");
|
|
assert_eq!(created.0, 200, "{}", created.2);
|
|
let created: serde_json::Value = serde_json::from_str(&created.2).unwrap();
|
|
assert_eq!(created["rendered"], false);
|
|
assert_eq!(created["revision"], 0);
|
|
let read: serde_json::Value =
|
|
serde_json::from_str(&client.request("POST", repl, "title").2).unwrap();
|
|
assert_eq!(read["value"], created["value"]);
|
|
assert_eq!(
|
|
client.request("POST", repl, "ui.page().render(title)").0,
|
|
200
|
|
);
|
|
let snapshot = client.request("GET", code, "");
|
|
assert!(
|
|
snapshot
|
|
.1
|
|
.to_lowercase()
|
|
.contains("x-tessera-source-kind: repl-transcript")
|
|
);
|
|
assert_eq!(namespace.read().unwrap().revision, 1);
|
|
assert_eq!(client.request("DELETE", repl, "").0, 200);
|
|
assert_eq!(client.request("GET", code, "").2, snapshot.2);
|
|
assert_eq!(client.request("POST", repl, "title").0, 422);
|
|
assert_eq!(namespace.read().unwrap().revision, 1);
|
|
assert_eq!(client.request("POST", repl, "retained = 42").0, 200);
|
|
assert_eq!(
|
|
client
|
|
.request("PUT", code, include_str!("../../../examples/page.endor"))
|
|
.0,
|
|
200
|
|
);
|
|
assert_eq!(client.request("POST", repl, "retained").0, 422);
|
|
assert_eq!(namespace.read().unwrap().revision, 2);
|
|
}
|
|
|
|
#[test]
|
|
/// 经真实 HTTP 完成指南读取、PUT/GET、失败候选、再次 PUT 和 DELETE。
|
|
/// 同时检查诊断位置、源文本/版本头、空文档、错误 namespace 与通知次数,确保仅成功发布改变版本。
|
|
fn http_executes_real_endor_preserves_failed_updates_and_supports_delete() {
|
|
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
|
|
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
|
|
let notifications = Arc::new(AtomicUsize::new(0));
|
|
let counter = notifications.clone();
|
|
let notify: Notify = Arc::new(move || {
|
|
counter.fetch_add(1, Ordering::SeqCst);
|
|
});
|
|
let api = start_server(server.clone(), namespace.clone(), notify).unwrap();
|
|
let client = TestServer {
|
|
address: server.server_addr().to_ip().unwrap(),
|
|
server,
|
|
api: Some(api),
|
|
};
|
|
let route = "/v1/namespaces/app.main/code";
|
|
let example = include_str!("../../../examples/page.endor");
|
|
let (status, _, guide) = client.request("GET", "/agent", "");
|
|
assert_eq!(status, 200);
|
|
assert!(guide.contains("let myui = ui.page()"));
|
|
let (status, _, body) = client.request("PUT", route, example);
|
|
assert_eq!(status, 200, "{body}");
|
|
assert_eq!(
|
|
serde_json::from_str::<serde_json::Value>(&body).unwrap()["revision"],
|
|
1
|
|
);
|
|
let (status, headers, source) = client.request("GET", route, "");
|
|
assert_eq!(status, 200);
|
|
assert!(headers.to_lowercase().contains("x-tessera-revision: 1"));
|
|
assert_eq!(source, example);
|
|
let (status, _, body) =
|
|
client.request("PUT", route, "function main(): View { return missing }");
|
|
assert_eq!(status, 422, "{body}");
|
|
assert_eq!(
|
|
serde_json::from_str::<serde_json::Value>(&body).unwrap()["diagnostics"][0]["line"],
|
|
1
|
|
);
|
|
assert_eq!(client.request("GET", route, "").2, example);
|
|
let second = example.replace("Hello Endor", "Second");
|
|
assert_eq!(client.request("PUT", route, &second).0, 200);
|
|
assert_eq!(namespace.read().unwrap().revision, 2);
|
|
assert_eq!(client.request("DELETE", route, "").0, 200);
|
|
assert_eq!(client.request("GET", route, "").2, "");
|
|
assert!(namespace.read().unwrap().document.frame.commands.is_empty());
|
|
assert_eq!(client.request("PUT", route, example).0, 200);
|
|
assert_eq!(namespace.read().unwrap().revision, 4);
|
|
assert_eq!(
|
|
client
|
|
.request("PUT", "/v1/namespaces/other/code", example)
|
|
.0,
|
|
404
|
|
);
|
|
assert_eq!(notifications.load(Ordering::SeqCst), 4);
|
|
}
|
|
|
|
#[test]
|
|
/// 直接推进 worker 的 submission,模拟新候选已接受但尚未成功替换的时刻。
|
|
/// 旧 VM 的 retained 仍可读;clear 撤销 namespace 后再刷新,才释放 live owner。
|
|
fn queued_candidate_keeps_live_owner_until_delete_revokes_it() {
|
|
let (runtime, document) = endor_host::ReplSession::load(
|
|
"retained = ui.state(9)\npage = Page()\npage.render(ui.textOf(retained,\"value\"))",
|
|
"app.main",
|
|
1,
|
|
277,
|
|
)
|
|
.unwrap();
|
|
let namespace = Arc::new(RwLock::new(NamespaceState {
|
|
document,
|
|
submission: 2,
|
|
runtime_session: runtime.id,
|
|
..NamespaceState::default()
|
|
}));
|
|
let mut worker = Worker {
|
|
repl: Some((1, runtime)),
|
|
next_session: 277,
|
|
};
|
|
let notify: Notify = Arc::new(|| {});
|
|
worker.refresh_viewport(&namespace, ¬ify);
|
|
assert_eq!(worker.repl.as_ref().unwrap().0, 2);
|
|
assert_eq!(
|
|
worker
|
|
.repl
|
|
.as_mut()
|
|
.unwrap()
|
|
.1
|
|
.execute("retained.value", "app.main")
|
|
.unwrap()
|
|
.value,
|
|
"9"
|
|
);
|
|
{
|
|
let mut current = namespace.write().unwrap();
|
|
current.submission = 3;
|
|
worker.preserve_live_owner(¤t);
|
|
}
|
|
assert_eq!(worker.repl.as_ref().unwrap().0, 3);
|
|
namespace.write().unwrap().clear().unwrap();
|
|
worker.refresh_viewport(&namespace, ¬ify);
|
|
assert!(worker.repl.is_none());
|
|
}
|
|
|
|
#[test]
|
|
/// 用通知回调中的通道阻塞第一轮 worker,再使第二个 PUT 排队。
|
|
/// 此时 GET/DELETE 仍应可用;释放 worker 后第一请求成功、被 DELETE 超越的候选返回 409,文档保持清空。
|
|
fn http_delete_remains_available_and_invalidates_queued_put() {
|
|
use std::{
|
|
sync::{Mutex, mpsc},
|
|
time::Instant,
|
|
};
|
|
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
|
|
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
|
|
let (entered_tx, entered_rx) = mpsc::channel();
|
|
let (release_tx, release_rx) = mpsc::channel();
|
|
// 通知是共享闭包;用 Mutex 包装接收端使其满足线程共享约束,只阻塞第一次通知。
|
|
let release_rx = Mutex::new(release_rx);
|
|
let calls = AtomicUsize::new(0);
|
|
let notify: Notify = Arc::new(move || {
|
|
if calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
|
entered_tx.send(()).unwrap();
|
|
release_rx.lock().unwrap().recv().unwrap();
|
|
}
|
|
});
|
|
let api = start_server(server.clone(), namespace.clone(), notify).unwrap();
|
|
let client = TestServer {
|
|
address: server.server_addr().to_ip().unwrap(),
|
|
server,
|
|
api: Some(api),
|
|
};
|
|
// 析构守卫在 scoped 线程 join 前释放阻塞通知;即使断言 panic 也不能让 worker 永久等通道。
|
|
// 持有释放信号的发送端;离开作用域即通知 worker 继续,包括异常展开路径。
|
|
struct Release(mpsc::Sender<()>);
|
|
impl Drop for Release {
|
|
// send 失败意味着接收端已结束,此时无需再阻塞测试清理。
|
|
fn drop(&mut self) {
|
|
let _ = self.0.send(());
|
|
}
|
|
}
|
|
thread::scope(|scope| {
|
|
let release = Release(release_tx);
|
|
let client = &client;
|
|
let route = "/v1/namespaces/app.main/code";
|
|
let example = include_str!("../../../examples/page.endor");
|
|
let first = scope.spawn(move || client.request("PUT", route, example));
|
|
entered_rx.recv_timeout(Duration::from_secs(10)).unwrap();
|
|
let pending = scope.spawn(move || client.request("PUT", route, example));
|
|
let deadline = Instant::now() + Duration::from_secs(10);
|
|
while namespace.read().unwrap().submission < 2 {
|
|
assert!(Instant::now() < deadline, "second PUT was not admitted");
|
|
thread::sleep(Duration::from_millis(5));
|
|
}
|
|
assert_eq!(client.request("GET", route, "").2, example);
|
|
assert_eq!(client.request("DELETE", route, "").0, 200);
|
|
// DELETE 已同步清空文档并提升提交身份,现在解除第一请求通知阻塞,让排队候选观察到失效。
|
|
drop(release);
|
|
assert_eq!(first.join().unwrap().0, 200);
|
|
assert_eq!(pending.join().unwrap().0, 409);
|
|
assert_eq!(client.request("GET", route, "").2, "");
|
|
assert_eq!(namespace.read().unwrap().revision, 2);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
/// 检查 code 路径解析只接受完整的单段 namespace;缺少后缀和嵌套斜线均返回 None。
|
|
fn extracts_namespace_only_from_code_routes() {
|
|
assert_eq!(
|
|
namespace_from_path("/v1/namespaces/app.main/code"),
|
|
Some("app.main")
|
|
);
|
|
assert_eq!(namespace_from_path("/v1/namespaces/app.main"), None);
|
|
assert_eq!(namespace_from_path("/v1/namespaces/a/b/code"), None);
|
|
}
|
|
|
|
#[test]
|
|
/// 对生成的指南做契约回归:保留代码 CRUD、可运行示例和当前地址,移除旧 export scene/display_list 暴露。
|
|
fn guide_contains_only_code_crud_routes() {
|
|
let guide = agent_guide(&NamespaceState::default());
|
|
assert!(guide.contains("Code CRUD"));
|
|
assert!(guide.contains("let myui = ui.page()"));
|
|
assert!(!guide.contains("export scene"));
|
|
assert!(guide.contains("PUT http://127.0.0.1:4737"));
|
|
assert!(!guide.contains("display_list"));
|
|
}
|
|
|
|
#[test]
|
|
/// 创建已经就绪的定时任务,先按前台模式刷新再读取计数,确保输入前不抢跑回调。
|
|
/// 随后允许空闲刷新并推进待完成工作,计数应从 0 变为 1。
|
|
fn foreground_refresh_does_not_deliver_new_callbacks_ahead_of_input() {
|
|
let source = r#"
|
|
class Action {
|
|
count: Object
|
|
function fire(self: Action): Unit { self.count.increment() }
|
|
}
|
|
count=ui.state(0)
|
|
action=Action(count)
|
|
ui.after(ui,"ready",0,action.fire)
|
|
page=Page()
|
|
page.render(TextBlock("input priority"))
|
|
"#;
|
|
let (runtime, document) = endor_host::ReplSession::load(source, "app.main", 1, 801).unwrap();
|
|
let namespace = Arc::new(RwLock::new(NamespaceState {
|
|
document,
|
|
submission: 1,
|
|
runtime_session: 801,
|
|
..NamespaceState::default()
|
|
}));
|
|
let mut worker = Worker {
|
|
repl: Some((1, runtime)),
|
|
next_session: 801,
|
|
};
|
|
let notify: Notify = Arc::new(|| {});
|
|
// 前台模式仅处理输入前必须收敛的刷新,不引入新的任务交付。
|
|
worker.refresh_viewport_mode(&namespace, ¬ify, true);
|
|
let value = worker
|
|
.repl
|
|
.as_mut()
|
|
.unwrap()
|
|
.1
|
|
.execute("count.value", "app.main")
|
|
.unwrap()
|
|
.value;
|
|
assert_eq!(value, "0");
|
|
worker.refresh_viewport(&namespace, ¬ify);
|
|
let runtime = &mut worker.repl.as_mut().unwrap().1;
|
|
for _ in 0..64 {
|
|
if !runtime.has_pending_refresh() {
|
|
break;
|
|
}
|
|
runtime
|
|
.resize_windows_incremental(&std::collections::BTreeMap::new(), false)
|
|
.unwrap();
|
|
}
|
|
let value = runtime.execute("count.value", "app.main").unwrap().value;
|
|
assert_eq!(value, "1");
|
|
}
|
|
|
|
#[test]
|
|
/// 在负世界坐标放置按钮,通过 HTTP 平移/锚定缩放,再发送世界坐标 click。
|
|
/// 检查相机数值、旧帧重放 409、按钮计数,以及零倍率 400 不导致 VM fault。
|
|
fn canvas_camera_crosses_real_http_route_and_preserves_negative_world_clicks() {
|
|
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
|
|
let namespace = Arc::new(RwLock::new(NamespaceState {
|
|
viewport: Some(crate::layout::Size {
|
|
width: 600.0,
|
|
height: 400.0,
|
|
}),
|
|
..NamespaceState::default()
|
|
}));
|
|
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
|
|
let client = TestServer {
|
|
address: server.server_addr().to_ip().unwrap(),
|
|
server,
|
|
api: Some(api),
|
|
};
|
|
let source = r#"
|
|
count=ui.state(0)
|
|
button=Button("world")
|
|
button.onClick(count.increment)
|
|
button.x=-160
|
|
button.y=-80
|
|
page=Page()
|
|
page.mode="canvas"
|
|
page.render(button)
|
|
"#;
|
|
let response = client.request("PUT", "/v1/namespaces/app.main/code", source);
|
|
assert_eq!(response.0, 200, "{}", response.2);
|
|
let camera = |kind: &str, delta: [f32; 2], point: Option<[f32; 2]>| {
|
|
let frame = &namespace.read().unwrap().document.frame;
|
|
serde_json::to_string(&crate::events::Input {
|
|
window: 1,
|
|
session: frame.session,
|
|
layout_version: frame.layout_version,
|
|
mount: 0,
|
|
kind: kind.into(),
|
|
delta: Some(delta),
|
|
point,
|
|
press_point: None,
|
|
screen_position: None,
|
|
tab_drop: None,
|
|
})
|
|
.unwrap()
|
|
};
|
|
let event_path = "/v1/namespaces/app.main/events";
|
|
let pan = camera("page-pan", [-240.0, -160.0], None);
|
|
let response = client.request("POST", event_path, &pan);
|
|
assert_eq!(response.0, 200, "{}", response.2);
|
|
assert_eq!(
|
|
namespace.read().unwrap().document.frame.surface.pan,
|
|
crate::geometry::Point::new(-240.0, -160.0)
|
|
);
|
|
assert_eq!(client.request("POST", event_path, &pan).0, 409);
|
|
let response = client.request(
|
|
"POST",
|
|
event_path,
|
|
&camera("page-zoom", [2.0, 0.0], Some([120.0, 90.0])),
|
|
);
|
|
assert_eq!(response.0, 200, "{}", response.2);
|
|
{
|
|
let current = namespace.read().unwrap();
|
|
assert_eq!(current.document.frame.surface.zoom, 2.0);
|
|
assert_eq!(
|
|
current.document.frame.surface.pan,
|
|
crate::geometry::Point::new(-180.0, -115.0)
|
|
);
|
|
}
|
|
let input = {
|
|
let current = namespace.read().unwrap();
|
|
let frame = ¤t.document.frame;
|
|
let mount = frame.mounts.iter().find(|m| m.interactive).unwrap();
|
|
assert!(mount.x < 0.0 && mount.y < 0.0);
|
|
crate::events::Input {
|
|
window: 1,
|
|
session: frame.session,
|
|
layout_version: frame.layout_version,
|
|
mount: mount.id,
|
|
kind: "click".into(),
|
|
point: Some([-150.0, -70.0]),
|
|
press_point: Some([-150.0, -70.0]),
|
|
delta: None,
|
|
screen_position: None,
|
|
tab_drop: None,
|
|
}
|
|
};
|
|
let response = client.request("POST", event_path, &serde_json::to_string(&input).unwrap());
|
|
assert_eq!(response.0, 200, "{}", response.2);
|
|
let response = client.request("POST", "/v1/namespaces/app.main/repl", "count.value");
|
|
assert_eq!(response.0, 200, "{}", response.2);
|
|
assert_eq!(
|
|
serde_json::from_str::<serde_json::Value>(&response.2).unwrap()["value"],
|
|
"1"
|
|
);
|
|
let response = client.request(
|
|
"POST",
|
|
event_path,
|
|
&camera("page-zoom", [0.0, 0.0], Some([120.0, 90.0])),
|
|
);
|
|
assert_eq!(response.0, 400);
|
|
assert!(namespace.read().unwrap().document.frame.fault.is_none());
|
|
}
|
|
|
|
#[test]
|
|
/// 给局部 CanvasViewport 添加父内边距和自身左边框,检验页面坐标到内容相机锚点的转换。
|
|
/// 成功平移/缩放后核对相机状态;边框、边界与视口外锚点均须返回 400,保留健康运行时。
|
|
fn explicit_viewport_camera_crosses_http_and_rejects_bad_anchors_without_faulting() {
|
|
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
|
|
let namespace = Arc::new(RwLock::new(NamespaceState {
|
|
viewport: Some(crate::layout::Size {
|
|
width: 600.0,
|
|
height: 400.0,
|
|
}),
|
|
..NamespaceState::default()
|
|
}));
|
|
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
|
|
let client = TestServer {
|
|
address: server.server_addr().to_ip().unwrap(),
|
|
server,
|
|
api: Some(api),
|
|
};
|
|
let source = r#"
|
|
content = Button("inside")
|
|
content.x = 40
|
|
content.y = 20
|
|
scene = Scene2D(content)
|
|
view = CanvasViewport(200, 100, scene)
|
|
view.borderLeft = 10
|
|
page = Page()
|
|
page.render(Column(20, 0, view))
|
|
"#;
|
|
let response = client.request("PUT", "/v1/namespaces/app.main/code", source);
|
|
assert_eq!(response.0, 200, "{}", response.2);
|
|
let camera = |kind: &str, delta: [f32; 2], point: Option<[f32; 2]>| {
|
|
let current = namespace.read().unwrap();
|
|
let frame = ¤t.document.frame;
|
|
serde_json::to_string(&crate::events::Input {
|
|
window: 1,
|
|
session: frame.session,
|
|
layout_version: frame.layout_version,
|
|
mount: frame
|
|
.mounts
|
|
.iter()
|
|
.find(|mount| mount.canvas_viewport)
|
|
.unwrap()
|
|
.id,
|
|
kind: kind.into(),
|
|
delta: Some(delta),
|
|
point,
|
|
press_point: None,
|
|
screen_position: None,
|
|
tab_drop: None,
|
|
})
|
|
.unwrap()
|
|
};
|
|
let path = "/v1/namespaces/app.main/events";
|
|
let pan = camera("viewport-pan", [20.0, -10.0], None);
|
|
let response = client.request("POST", path, &pan);
|
|
assert_eq!(response.0, 200, "{}", response.2);
|
|
assert_eq!(client.request("POST", path, &pan).0, 409);
|
|
let response = client.request(
|
|
"POST",
|
|
path,
|
|
&camera("viewport-zoom", [2.0, 0.0], Some([80.0, 60.0])),
|
|
);
|
|
assert_eq!(response.0, 200, "{}", response.2);
|
|
for (expression, expected) in [
|
|
("view.camera.x", "45"),
|
|
("view.camera.y", "10"),
|
|
("view.camera.zoom", "2"),
|
|
] {
|
|
let response = client.request("POST", "/v1/namespaces/app.main/repl", expression);
|
|
assert_eq!(response.0, 200, "{}", response.2);
|
|
assert_eq!(
|
|
serde_json::from_str::<serde_json::Value>(&response.2).unwrap()["value"],
|
|
expected
|
|
);
|
|
}
|
|
// 分别覆盖左边框、右侧边界及远离视口的锚点,避免只校验整个窗口范围。
|
|
for point in [[25.0, 30.0], [220.0, 30.0], [700.0, 700.0]] {
|
|
let response = client.request(
|
|
"POST",
|
|
path,
|
|
&camera("viewport-zoom", [2.0, 0.0], Some(point)),
|
|
);
|
|
assert_eq!(response.0, 400, "{}", response.2);
|
|
assert!(namespace.read().unwrap().document.frame.fault.is_none());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
/// 通过真实 HTTP 检查透视缩放/orbit、正交缩放,再替换为源实现 PanOnly。
|
|
/// 比较相机数值与自定义控制器结果,并验证视口外锚点和禁用控制器的拒绝状态不会停掉 VM。
|
|
fn scene3d_controllers_cross_http_and_can_be_replaced_by_endor_code() {
|
|
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
|
|
let namespace = Arc::new(RwLock::new(NamespaceState {
|
|
viewport: Some(crate::layout::Size {
|
|
width: 400.,
|
|
height: 300.,
|
|
}),
|
|
..NamespaceState::default()
|
|
}));
|
|
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
|
|
let client = TestServer {
|
|
address: server.server_addr().to_ip().unwrap(),
|
|
server,
|
|
api: Some(api),
|
|
};
|
|
let source = r##"
|
|
class PanOnly {
|
|
function update(self: PanOnly, camera: Object, gesture: CameraGesture): Unit { camera.translate(gesture.dx, gesture.dy, 0) }
|
|
}
|
|
view = Viewport3D(200,100,Scene3D(Box3D(2,2,2,"#CC4455")))
|
|
page = Page()
|
|
page.render(view)
|
|
"##;
|
|
let response = client.request("PUT", "/v1/namespaces/app.main/code", source);
|
|
assert_eq!(response.0, 200, "{}", response.2);
|
|
let gesture = |kind: &str, delta: [f32; 2], point: Option<[f32; 2]>| {
|
|
let current = namespace.read().unwrap();
|
|
let frame = ¤t.document.frame;
|
|
serde_json::to_string(&crate::events::Input {
|
|
window: 1,
|
|
session: frame.session,
|
|
layout_version: frame.layout_version,
|
|
mount: frame
|
|
.mounts
|
|
.iter()
|
|
.find(|mount| mount.scene3d_viewport)
|
|
.unwrap()
|
|
.id,
|
|
kind: kind.into(),
|
|
delta: Some(delta),
|
|
point,
|
|
press_point: None,
|
|
tab_drop: None,
|
|
screen_position: None,
|
|
})
|
|
.unwrap()
|
|
};
|
|
let event_path = "/v1/namespaces/app.main/events";
|
|
let zoom = gesture("scene-zoom", [2., 0.], Some([100., 50.]));
|
|
let response = client.request("POST", event_path, &zoom);
|
|
assert_eq!(response.0, 200, "{}", response.2);
|
|
assert_eq!(client.request("POST", event_path, &zoom).0, 409);
|
|
let value = |expression: &str| {
|
|
let response = client.request("POST", "/v1/namespaces/app.main/repl", expression);
|
|
assert_eq!(response.0, 200, "{}", response.2);
|
|
serde_json::from_str::<serde_json::Value>(&response.2).unwrap()["value"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_owned()
|
|
};
|
|
assert_eq!(value("view.camera.z"), "2.5");
|
|
let response = client.request("POST", event_path, &gesture("scene-orbit", [20., 0.], None));
|
|
assert_eq!(response.0, 200, "{}", response.2);
|
|
let x = value("view.camera.x").parse::<f32>().unwrap();
|
|
assert!((x - 2.5 * 0.1f32.sin()).abs() < 0.0001);
|
|
value("view.camera = OrthographicCamera()");
|
|
let response = client.request(
|
|
"POST",
|
|
event_path,
|
|
&gesture("scene-zoom", [2., 0.], Some([100., 50.])),
|
|
);
|
|
assert_eq!(response.0, 200, "{}", response.2);
|
|
assert_eq!(value("view.camera.height"), "2.5");
|
|
value("view.controller = PanOnly()");
|
|
let response = client.request("POST", event_path, &gesture("scene-pan", [7., 0.], None));
|
|
assert_eq!(response.0, 200, "{}", response.2);
|
|
assert_eq!(value("view.camera.x"), "7");
|
|
let response = client.request(
|
|
"POST",
|
|
event_path,
|
|
&gesture("scene-zoom", [2., 0.], Some([250., 50.])),
|
|
);
|
|
assert_eq!(response.0, 400);
|
|
assert!(namespace.read().unwrap().document.frame.fault.is_none());
|
|
value("view.controller = \"none\"");
|
|
let response = client.request("POST", event_path, &gesture("scene-pan", [7., 0.], None));
|
|
assert_eq!(response.0, 409);
|
|
assert!(namespace.read().unwrap().document.frame.fault.is_none());
|
|
}
|
|
|
|
#[test]
|
|
/// 在旋转的 Panel3D 上嵌入二维 CanvasViewport,经射线/UV 映射解释相机事件。
|
|
/// 缺失指针必须拒绝;带指针的平移/缩放只改变面板内相机,外层三维相机保持原值。
|
|
fn canvas_in_world_ui_uses_ray_uv_coordinates_for_real_http_camera_events() {
|
|
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
|
|
let namespace = Arc::new(RwLock::new(NamespaceState {
|
|
viewport: Some(crate::layout::Size {
|
|
width: 400.,
|
|
height: 300.,
|
|
}),
|
|
..NamespaceState::default()
|
|
}));
|
|
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
|
|
let client = TestServer {
|
|
address: server.server_addr().to_ip().unwrap(),
|
|
server,
|
|
api: Some(api),
|
|
};
|
|
let source = r##"
|
|
canvas=CanvasViewport(100,100,Scene2D(Button("UI in a world")))
|
|
panel=Panel3D(2,2,canvas)
|
|
panel.uiWidth=100
|
|
panel.uiHeight=100
|
|
panel.rotationY=25
|
|
view=Viewport3D(200,200,Scene3D(panel))
|
|
page=Page()
|
|
page.render(view)
|
|
"##;
|
|
let response = client.request("PUT", "/v1/namespaces/app.main/code", source);
|
|
assert_eq!(response.0, 200, "{}", response.2);
|
|
let gesture = |kind: &str, delta: [f32; 2], point: Option<[f32; 2]>| {
|
|
let current = namespace.read().unwrap();
|
|
let frame = ¤t.document.frame;
|
|
serde_json::to_string(&crate::events::Input {
|
|
window: 1,
|
|
session: frame.session,
|
|
layout_version: frame.layout_version,
|
|
mount: frame
|
|
.mounts
|
|
.iter()
|
|
.find(|mount| mount.canvas_viewport && mount.panel.is_some())
|
|
.unwrap()
|
|
.id,
|
|
kind: kind.into(),
|
|
point,
|
|
press_point: None,
|
|
delta: Some(delta),
|
|
tab_drop: None,
|
|
screen_position: None,
|
|
})
|
|
.unwrap()
|
|
};
|
|
let path = "/v1/namespaces/app.main/events";
|
|
assert_eq!(
|
|
client
|
|
.request("POST", path, &gesture("viewport-pan", [10., 0.], None))
|
|
.0,
|
|
400
|
|
);
|
|
let response = client.request(
|
|
"POST",
|
|
path,
|
|
&gesture("viewport-pan", [10., 0.], Some([100., 100.])),
|
|
);
|
|
assert_eq!(response.0, 200, "{}", response.2);
|
|
let value = |expression: &str| {
|
|
let response = client.request("POST", "/v1/namespaces/app.main/repl", expression);
|
|
assert_eq!(response.0, 200, "{}", response.2);
|
|
serde_json::from_str::<serde_json::Value>(&response.2).unwrap()["value"]
|
|
.as_str()
|
|
.unwrap()
|
|
.parse::<f32>()
|
|
.unwrap()
|
|
};
|
|
let pan = value("canvas.camera.x");
|
|
// 面板倾斜后的投影位移不等于 UI 内部位移,不能把屏幕 delta 直接写入局部相机。
|
|
assert!(pan > 10.);
|
|
assert_eq!(value("view.camera.x"), 0.);
|
|
let response = client.request(
|
|
"POST",
|
|
path,
|
|
&gesture("viewport-zoom", [2., 0.], Some([100., 100.])),
|
|
);
|
|
assert_eq!(response.0, 200, "{}", response.2);
|
|
assert!((value("canvas.camera.x") - pan - 25.).abs() < 0.001);
|
|
assert_eq!(value("canvas.camera.zoom"), 2.);
|
|
assert!(namespace.read().unwrap().document.frame.fault.is_none());
|
|
}
|