feat: complete tree shell cutover and regression coverage

This commit is contained in:
lix-2026
2026-04-18 09:38:16 +08:00
parent d3876e56eb
commit 111a87d4fd
53 changed files with 5440 additions and 516 deletions
+2
View File
@@ -31,6 +31,7 @@ pub struct TraceContext {
#[serde(rename_all = "camelCase")]
pub struct AuthContext {
pub authorization: Option<String>,
pub cookie_header: Option<String>,
pub actor_id: String,
pub actor_type: String,
pub session_id: Option<String>,
@@ -76,6 +77,7 @@ impl RequestContext {
},
auth: AuthContext {
authorization: header_value(headers, axum::http::header::AUTHORIZATION.as_str()),
cookie_header: header_value(headers, axum::http::header::COOKIE.as_str()),
actor_id: header_value(headers, HEADER_ACTOR_ID)
.unwrap_or_else(|| "anonymous".into()),
actor_type: header_value(headers, HEADER_ACTOR_TYPE)
+1
View File
@@ -4,5 +4,6 @@ pub mod error;
pub mod middleware;
pub mod routes;
pub mod transport;
pub mod tree_shell;
pub use app::{build_app, AppConfig, AppState};
@@ -4,11 +4,8 @@ use axum::middleware::Next;
use axum::response::Response;
pub async fn inject_request_context(mut request: Request, next: Next) -> Response {
let context = RequestContext::from_http_parts(
request.method(),
request.uri(),
request.headers(),
);
let context =
RequestContext::from_http_parts(request.method(), request.uri(), request.headers());
request.extensions_mut().insert(context.clone());
let mut response = next.run(request).await;
@@ -78,7 +78,11 @@ pub fn build_tree_target(
}
}
pub fn ensure_non_empty(value: &str, field: &'static str, context: &RequestContext) -> Result<String, WebError> {
pub fn ensure_non_empty(
value: &str,
field: &'static str,
context: &RequestContext,
) -> Result<String, WebError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(WebError::bad_request_code(
@@ -99,12 +103,11 @@ pub fn read_optional_non_empty(value: Option<String>) -> Option<String> {
pub fn ensure_sort_order(sort_order: i64, context: &RequestContext) -> Result<i64, WebError> {
if sort_order < 0 {
return Err(WebError::bad_request_code(
"tree_command_validation",
"sortOrder 不能小于 0",
)
.with_context(context)
.with_header("x-error-phase", "tree_command_validate"));
return Err(
WebError::bad_request_code("tree_command_validation", "sortOrder 不能小于 0")
.with_context(context)
.with_header("x-error-phase", "tree_command_validate"),
);
}
Ok(sort_order)
}
+2 -7
View File
@@ -2,9 +2,7 @@ use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::query_support::resolve_effective_workspace_id;
use crate::routes::snapshot_support::{
load_projection_snapshot, ProjectionSnapshotSpec,
};
use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec};
use axum::extract::Query;
use axum::extract::{Extension, State};
use axum::http::StatusCode;
@@ -77,10 +75,7 @@ pub async fn next_sidebar(
.with_header("x-error-phase", "compat_sidebar_shape")
.with_header("x-upstream-service", "convex")
})?;
dataset_object.insert(
"kernel_sidebar_projection".into(),
snapshot.projection,
);
dataset_object.insert("kernel_sidebar_projection".into(), snapshot.projection);
Ok((
StatusCode::OK,
+204 -7
View File
@@ -1,9 +1,7 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::query_support::{
resolve_effective_workspace_id,
};
use crate::routes::query_support::resolve_effective_workspace_id;
use crate::routes::snapshot_support::{
execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query,
ProjectionSnapshotSpec,
@@ -59,10 +57,11 @@ fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, Json<Val
)
}
pub async fn project_sidebar(
async fn project_projection(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<KernelProjectionQuery>,
projection: KernelProjectionKind,
) -> Result<(StatusCode, Json<Value>), WebError> {
let effective_workspace_id =
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)?
@@ -74,7 +73,7 @@ pub async fn project_sidebar(
workspace_id: &effective_workspace_id,
root_node_id: query.root_node_id.as_deref(),
depth: query.depth,
projection: KernelProjectionKind::SidebarTree,
projection,
},
)
.await?;
@@ -82,6 +81,38 @@ pub async fn project_sidebar(
Ok(ok_response(&context, snapshot.projection))
}
pub async fn project_tree_sidebar(
state: State<AppState>,
context: Extension<RequestContext>,
query: Query<KernelProjectionQuery>,
) -> Result<(StatusCode, Json<Value>), WebError> {
project_projection(state, context, query, KernelProjectionKind::SidebarTree).await
}
pub async fn project_tree_page(
state: State<AppState>,
context: Extension<RequestContext>,
query: Query<KernelProjectionQuery>,
) -> Result<(StatusCode, Json<Value>), WebError> {
project_projection(state, context, query, KernelProjectionKind::PageTree).await
}
pub async fn project_tree_file(
state: State<AppState>,
context: Extension<RequestContext>,
query: Query<KernelProjectionQuery>,
) -> Result<(StatusCode, Json<Value>), WebError> {
project_projection(state, context, query, KernelProjectionKind::FileTree).await
}
pub async fn project_sidebar(
state: State<AppState>,
context: Extension<RequestContext>,
query: Query<KernelProjectionQuery>,
) -> Result<(StatusCode, Json<Value>), WebError> {
project_tree_sidebar(state, context, query).await
}
pub async fn subtree(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
@@ -154,8 +185,9 @@ pub async fn graph(
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use axum::body::Body;
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use serde_json::Value;
use tower::util::ServiceExt;
fn app() -> axum::Router {
@@ -168,7 +200,7 @@ mod tests {
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}]},"bridgeLogs:listWorkspaceOverview":{"workspace_id":"ws_demo","command_logs":[{"command_id":"cmd_1","request_id":"req_1","status":"applied","created_at":"2026-04-16T00:00:00Z"}],"domain_events":[{"command_id":"cmd_1","status":"published","created_at":"2026-04-16T00:00:00Z"}],"next_cursor":null,"has_more":false,"filters":{"command_status":null,"event_status":null,"target_page_id":null,"target_block_id":null,"aggregate_type":null,"aggregate_id":null},"generated_at":"2026-04-16T00:00:00Z"}}"#.into()),
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"media_assets":[{"id":"asset_file_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"封面.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_child_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"节点图片.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_pdf_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"说明书.pdf","mime_type":"application/pdf","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_book_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"小说.epub","mime_type":"application/epub+zip","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_assets":[{"id":"mind_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"mindmap","file_name":"头脑风暴.json","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"table_assets":[{"id":"table_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"luckysheet","file_name":"预算.luckysheet","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_asset_children":{"mind_1":["asset_child_1"]}},"bridgeLogs:listWorkspaceOverview":{"workspace_id":"ws_demo","command_logs":[{"command_id":"cmd_1","request_id":"req_1","status":"applied","created_at":"2026-04-16T00:00:00Z"}],"domain_events":[{"command_id":"cmd_1","status":"published","created_at":"2026-04-16T00:00:00Z"}],"next_cursor":null,"has_more":false,"filters":{"command_status":null,"event_status":null,"target_page_id":null,"target_block_id":null,"aggregate_type":null,"aggregate_id":null},"generated_at":"2026-04-16T00:00:00Z"}}"#.into()),
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
@@ -190,4 +222,169 @@ mod tests {
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn tree_projection_contract_route_keeps_shared_fields() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/kernel/projections/sidebar?workspaceId=ws_demo&rootNodeId=page_root")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
let first = &payload["result"]["items"][0];
assert_eq!(first["projectionKind"], "sidebar_tree");
assert_eq!(first["resourceMeta"]["resourceKind"], "document");
assert_eq!(first["iconHint"], "page");
}
#[tokio::test]
async fn tree_projection_routes_return_sidebar_page_and_file_projection_kinds() {
let cases = [
(
"/api/tree/projections/sidebar?workspaceId=ws_demo&rootNodeId=page_root",
"sidebar_tree",
),
(
"/api/tree/projections/page?workspaceId=ws_demo&rootNodeId=page_root",
"page_tree",
),
(
"/api/tree/projections/file?workspaceId=ws_demo&rootNodeId=page_root",
"file_tree",
),
];
for (uri, expected_projection_kind) in cases {
let response = app()
.oneshot(
Request::builder()
.uri(uri)
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["result"]["projection"], expected_projection_kind);
assert_eq!(
payload["result"]["items"][0]["projectionKind"],
expected_projection_kind
);
}
}
#[tokio::test]
async fn tree_projection_routes_keep_ok_response_shape() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/tree/projections/page?workspaceId=ws_demo&rootNodeId=page_root")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["ok"], true);
assert_eq!(payload["result"]["projection"], "page_tree");
assert_eq!(payload["result"]["rootNodeId"], "page_root");
}
#[tokio::test]
async fn file_tree_projection_includes_index_and_asset_rows() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/tree/projections/file?workspaceId=ws_demo&rootNodeId=page_root")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
let items = payload["result"]["items"].as_array().expect("items");
let row_kinds = items
.iter()
.filter_map(|item| item["rowKind"].as_str())
.collect::<Vec<_>>();
let resource_kinds = items
.iter()
.filter_map(|item| item["resourceMeta"]["resourceKind"].as_str())
.collect::<Vec<_>>();
assert!(row_kinds.contains(&"document"));
assert!(row_kinds.contains(&"index"));
assert!(row_kinds.contains(&"asset"));
assert!(row_kinds.contains(&"asset_folder"));
assert!(resource_kinds.contains(&"document"));
assert!(resource_kinds.contains(&"index"));
assert!(resource_kinds.contains(&"asset"));
assert!(resource_kinds.contains(&"mindmap"));
assert!(resource_kinds.contains(&"table"));
assert_eq!(items[0]["nodeId"], "page_root");
assert_eq!(items[1]["nodeId"], "index:page_root");
}
#[tokio::test]
async fn file_tree_projection_resource_variants_keep_icon_hint_and_capabilities() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/tree/projections/file?workspaceId=ws_demo&rootNodeId=page_root")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
let item_by_row_id = payload["result"]["items"]
.as_array()
.expect("items")
.iter()
.filter_map(|item| item["rowId"].as_str().map(|row_id| (row_id, item)))
.collect::<std::collections::BTreeMap<_, _>>();
assert_eq!(item_by_row_id["asset:asset_pdf_1"]["iconHint"], "pdf");
assert_eq!(item_by_row_id["asset:asset_book_1"]["iconHint"], "book");
assert_eq!(item_by_row_id["asset:table_1"]["iconHint"], "table");
assert_eq!(item_by_row_id["asset-folder:mind_1"]["iconHint"], "mindmap");
assert_eq!(
item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["resourceKind"],
"mindmap"
);
assert!(item_by_row_id["asset-folder:mind_1"]["capabilities"]
.as_array()
.expect("capabilities")
.iter()
.any(|value| value == "expand"));
}
}
+6
View File
@@ -22,6 +22,12 @@ pub fn build_router(state: AppState) -> Router {
Router::new()
.route("/health", get(health::health))
.route("/tree", get(tree::tree_shell))
.route(
"/api/tree/projections/sidebar",
get(kernel::project_tree_sidebar),
)
.route("/api/tree/projections/page", get(kernel::project_tree_page))
.route("/api/tree/projections/file", get(kernel::project_tree_file))
.route(
"/api/kernel/projections/sidebar",
get(kernel::project_sidebar),
@@ -126,6 +126,7 @@ pub async fn execute_runtime_query_via_convex(
effective_workspace_id: Option<&str>,
query: RuntimeQueryEnvelopeWire,
) -> Result<Value, WebError> {
let data = fetch_query_data_via_convex(config, context, effective_workspace_id, query.clone()).await?;
let data =
fetch_query_data_via_convex(config, context, effective_workspace_id, query.clone()).await?;
execute_runtime_query_against_data(context, effective_workspace_id, query, data)
}
+267 -23
View File
@@ -5,12 +5,8 @@ use crate::routes::command_support::{
build_tree_target, ensure_non_empty, ensure_sort_order, execute_runtime_command_via_convex,
read_optional_non_empty,
};
use crate::routes::query_support::{
resolve_effective_workspace_id,
};
use crate::routes::snapshot_support::{
load_projection_snapshot, ProjectionSnapshotSpec,
};
use crate::routes::query_support::resolve_effective_workspace_id;
use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec};
use axum::extract::{Extension, Query, State};
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
@@ -118,7 +114,12 @@ fn normalize_tree_mode(value: Option<&str>) -> &'static str {
}
fn normalize_bool_flag(value: Option<&str>, default: bool) -> bool {
match value.unwrap_or_default().trim().to_ascii_lowercase().as_str() {
match value
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
.as_str()
{
"1" | "true" | "yes" | "on" => true,
"0" | "false" | "no" | "off" => false,
_ => default,
@@ -812,6 +813,7 @@ fn build_tree_shell_html(
nodeId: normalizeText(item?.nodeId),
parentNodeId: normalizeParent(item?.parentNodeId),
title: normalizeText(item?.title, "无标题"),
depth: normalizeNumber(item?.depth, 0),
childCount: normalizeNumber(item?.childCount, 0),
position: normalizeNumber(item?.position),
expandedByDefault: item?.expandedByDefault !== false,
@@ -971,6 +973,17 @@ fn build_tree_shell_html(
<circle cx="12" cy="8" r="1.2"/>
</svg>
`,
edit: `
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3.4 11.8 3 13l1.2-.4 6.6-6.6-1.4-1.4-6 6.2Z" stroke="currentColor" stroke-width="1.1" stroke-linejoin="round"/>
<path d="m9.9 4.6 1.5-1.5a1 1 0 0 1 1.4 0l.6.6a1 1 0 0 1 0 1.4l-1.5 1.5" stroke="currentColor" stroke-width="1.1" stroke-linecap="round"/>
</svg>
`,
up: `
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M8 12.6V4.2M8 4.2 5.4 6.8M8 4.2l2.6 2.6" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`,
page: `
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M4 2.8h5.2l2.8 2.8v7.6H4V2.8Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
@@ -1422,6 +1435,8 @@ fn build_tree_shell_html(
const actions = document.createElement("div");
actions.className = "tree-actions";
const siblingRows = getSiblings(item.parentNodeId);
const siblingIndex = siblingRows.findIndex((entry) => entry.nodeId === item.nodeId);
actions.appendChild(
createActionButton(
ICONS.add,
@@ -1432,6 +1447,24 @@ fn build_tree_shell_html(
),
);
if (mode === "page") {
actions.appendChild(
createActionButton(
ICONS.edit,
"tree-action-rename",
`重命名 ${item.title}`,
() => void handleRename(item.nodeId),
false,
),
);
actions.appendChild(
createActionButton(
ICONS.up,
"tree-action-move-up",
`上移 ${item.title}`,
() => void handleMove(item.nodeId, -1),
siblingIndex <= 0,
),
);
actions.appendChild(
createActionButton(
ICONS.more,
@@ -1916,9 +1949,10 @@ pub async fn tree_shell(
&snapshot.dataset,
);
let mut response = Html(html).into_response();
response
.headers_mut()
.insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"));
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
);
Ok(response)
}
@@ -1938,9 +1972,10 @@ fn create_command_wire(
} => {
let document_id = ensure_non_empty(&document_id, "documentId", context)?;
let parent_id = read_optional_non_empty(parent_id);
let access_scope = read_optional_non_empty(access_scope).unwrap_or_else(|| "private".into());
let access_scope =
read_optional_non_empty(access_scope).unwrap_or_else(|| "private".into());
Ok(RuntimeCommandEnvelopeWire {
name: "documents.create".into(),
name: "tree.node.create".into(),
command_id: format!("tree_create_{}", context.trace.request_id),
idempotency_key: context.source.idempotency_key.clone(),
actor: bridge_runtime::RuntimeActorWire {
@@ -1952,7 +1987,11 @@ fn create_command_wire(
channel: context.source.channel.clone(),
client: context.source.client.clone(),
},
target: Some(build_tree_target(workspace_id, Some(document_id.as_str()), None)),
target: Some(build_tree_target(
workspace_id,
Some(document_id.as_str()),
None,
)),
payload: json!({
"documentId": document_id,
"workspaceId": workspace_id,
@@ -1974,7 +2013,7 @@ fn create_command_wire(
} => {
let document_id = ensure_non_empty(&document_id, "documentId", context)?;
Ok(RuntimeCommandEnvelopeWire {
name: "documents.title.update".into(),
name: "tree.node.rename".into(),
command_id: format!("tree_rename_{}", context.trace.request_id),
idempotency_key: context.source.idempotency_key.clone(),
actor: bridge_runtime::RuntimeActorWire {
@@ -1986,7 +2025,11 @@ fn create_command_wire(
channel: context.source.channel.clone(),
client: context.source.client.clone(),
},
target: Some(build_tree_target(workspace_id, Some(document_id.as_str()), None)),
target: Some(build_tree_target(
workspace_id,
Some(document_id.as_str()),
None,
)),
payload: json!({
"documentId": document_id,
"title": title,
@@ -2007,7 +2050,7 @@ fn create_command_wire(
let sort_order = ensure_sort_order(sort_order, context)?;
let parent_id = read_optional_non_empty(parent_id);
Ok(RuntimeCommandEnvelopeWire {
name: "documents.move".into(),
name: "tree.subtree.move".into(),
command_id: format!("tree_move_{}", context.trace.request_id),
idempotency_key: context.source.idempotency_key.clone(),
actor: bridge_runtime::RuntimeActorWire {
@@ -2019,7 +2062,11 @@ fn create_command_wire(
channel: context.source.channel.clone(),
client: context.source.client.clone(),
},
target: Some(build_tree_target(workspace_id, Some(document_id.as_str()), None)),
target: Some(build_tree_target(
workspace_id,
Some(document_id.as_str()),
None,
)),
payload: json!({
"documentId": document_id,
"parentId": parent_id,
@@ -2097,9 +2144,7 @@ pub async fn tree_command(
None,
),
TreeCommandRequest::Rename {
document_id,
title,
..
document_id, title, ..
} => (
"rename",
document_id.clone(),
@@ -2154,9 +2199,12 @@ pub async fn tree_command(
#[cfg(test)]
mod tests {
use super::{create_command_wire, TreeCommandRequest};
use crate::app::{build_app, AppConfig, AppState};
use crate::context::RequestContext;
use crate::routes::command_support::build_runtime_command_plan;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::http::{HeaderMap, Method, Request, StatusCode, Uri};
use serde_json::Value;
use tower::util::ServiceExt;
@@ -2277,7 +2325,10 @@ mod tests {
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["result"]["action"], Value::String("create".into()));
assert_eq!(payload["result"]["documentId"], Value::String("page_new".into()));
assert_eq!(
payload["result"]["documentId"],
Value::String("page_new".into())
);
}
#[tokio::test]
@@ -2345,6 +2396,199 @@ mod tests {
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["result"]["action"], Value::String("move".into()));
assert_eq!(payload["result"]["documentId"], Value::String("page_child".into()));
assert_eq!(
payload["result"]["documentId"],
Value::String("page_child".into())
);
}
#[tokio::test]
async fn tree_route_contracts_command_response_keeps_trace_and_workspace_fields() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/tree/commands")
.header("content-type", "application/json")
.header("Authorization", "Bearer demo-token")
.body(Body::from(
r#"{"action":"rename","workspaceId":"ws_demo","documentId":"page_child","title":"命名"}"#,
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert!(payload["requestId"].as_str().is_some());
assert!(payload["traceId"].as_str().is_some());
assert_eq!(payload["result"]["workspaceId"], "ws_demo");
}
#[tokio::test]
async fn tree_route_contracts_compat_sidebar_keeps_boundary_and_trace_fields() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/compat/next/sidebar?workspaceId=ws_demo")
.header("Authorization", "Bearer demo-token")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["boundary"], "next_sidebar_compat");
assert_eq!(payload["workspaceId"], "ws_demo");
assert!(payload["requestId"].as_str().is_some());
assert!(payload["traceId"].as_str().is_some());
}
#[test]
fn tree_commands_prefer_tree_protocol_names_in_command_wire() {
let context = RequestContext::from_http_parts(
&Method::POST,
&"/api/tree/commands".parse::<Uri>().expect("uri"),
&HeaderMap::new(),
);
let create_wire = create_command_wire(
&context,
"ws_demo",
TreeCommandRequest::Create {
workspace_id: Some("ws_demo".into()),
document_id: "page_new".into(),
parent_id: Some("page_root".into()),
title: "新页面".into(),
access_scope: Some("private".into()),
content: Some(Value::Array(Vec::new())),
},
)
.expect("create wire");
assert_eq!(create_wire.name, "tree.node.create");
let rename_wire = create_command_wire(
&context,
"ws_demo",
TreeCommandRequest::Rename {
workspace_id: Some("ws_demo".into()),
document_id: "page_child".into(),
title: "重命名".into(),
},
)
.expect("rename wire");
assert_eq!(rename_wire.name, "tree.node.rename");
let move_wire = create_command_wire(
&context,
"ws_demo",
TreeCommandRequest::Move {
workspace_id: Some("ws_demo".into()),
document_id: "page_child".into(),
parent_id: Some("page_root".into()),
sort_order: 1,
},
)
.expect("move wire");
assert_eq!(move_wire.name, "tree.subtree.move");
}
#[test]
fn tree_commands_keep_documents_alias_mapping_for_runtime_plan() {
let context = RequestContext::from_http_parts(
&Method::POST,
&"/api/tree/commands".parse::<Uri>().expect("uri"),
&HeaderMap::new(),
);
let tree_create_wire = create_command_wire(
&context,
"ws_demo",
TreeCommandRequest::Create {
workspace_id: Some("ws_demo".into()),
document_id: "page_new".into(),
parent_id: Some("page_root".into()),
title: "新页面".into(),
access_scope: Some("private".into()),
content: Some(Value::Array(Vec::new())),
},
)
.expect("tree create wire");
let compat_create_wire = bridge_runtime::RuntimeCommandEnvelopeWire {
name: "documents.create".into(),
..tree_create_wire.clone()
};
let tree_create_plan =
build_runtime_command_plan(&context, Some("ws_demo"), tree_create_wire)
.expect("tree create plan");
let compat_create_plan =
build_runtime_command_plan(&context, Some("ws_demo"), compat_create_wire)
.expect("compat create plan");
assert_eq!(
tree_create_plan.function_name,
"documents:createWithParentReference"
);
assert_eq!(
tree_create_plan.function_name,
compat_create_plan.function_name
);
let tree_rename_wire = create_command_wire(
&context,
"ws_demo",
TreeCommandRequest::Rename {
workspace_id: Some("ws_demo".into()),
document_id: "page_child".into(),
title: "重命名".into(),
},
)
.expect("tree rename wire");
let compat_rename_wire = bridge_runtime::RuntimeCommandEnvelopeWire {
name: "documents.title.update".into(),
..tree_rename_wire.clone()
};
let tree_rename_plan =
build_runtime_command_plan(&context, Some("ws_demo"), tree_rename_wire)
.expect("tree rename plan");
let compat_rename_plan =
build_runtime_command_plan(&context, Some("ws_demo"), compat_rename_wire)
.expect("compat rename plan");
assert_eq!(tree_rename_plan.function_name, "documents:updateTitle");
assert_eq!(
tree_rename_plan.function_name,
compat_rename_plan.function_name
);
let tree_move_wire = create_command_wire(
&context,
"ws_demo",
TreeCommandRequest::Move {
workspace_id: Some("ws_demo".into()),
document_id: "page_child".into(),
parent_id: Some("page_root".into()),
sort_order: 1,
},
)
.expect("tree move wire");
let compat_move_wire = bridge_runtime::RuntimeCommandEnvelopeWire {
name: "documents.move".into(),
..tree_move_wire.clone()
};
let tree_move_plan = build_runtime_command_plan(&context, Some("ws_demo"), tree_move_wire)
.expect("tree move plan");
let compat_move_plan =
build_runtime_command_plan(&context, Some("ws_demo"), compat_move_wire)
.expect("compat move plan");
assert_eq!(tree_move_plan.function_name, "documents:move");
assert_eq!(tree_move_plan.function_name, compat_move_plan.function_name);
}
}
+11 -5
View File
@@ -29,9 +29,7 @@ async fn handle_socket(
query: StreamSnapshotQuery,
payload: Value,
) {
let _ = socket
.send(serialize_snapshot_message(&payload))
.await;
let _ = socket.send(serialize_snapshot_message(&payload)).await;
while let Some(message) = socket.next().await {
let Ok(message) = message else {
@@ -43,7 +41,11 @@ async fn handle_socket(
if is_resync_request(&text) {
match load_stream_snapshot(state.config(), &context, &query).await {
Ok(snapshot) => {
if socket.send(serialize_resync_message(&snapshot)).await.is_err() {
if socket
.send(serialize_resync_message(&snapshot))
.await
.is_err()
{
break;
}
}
@@ -72,7 +74,11 @@ async fn handle_socket(
"accepted": false,
"reason": "unsupported_message",
});
if socket.send(Message::Text(ack.to_string().into())).await.is_err() {
if socket
.send(Message::Text(ack.to_string().into()))
.await
.is_err()
{
break;
}
}
+112 -24
View File
@@ -1,8 +1,6 @@
use crate::app::AppConfig;
use crate::context::RequestContext;
use crate::error::WebError;
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use bridge_runtime::{RuntimeCommandExecutionPlan, RuntimeQueryExecutionPlan};
use serde_json::{json, Value};
use std::fs;
@@ -14,6 +12,7 @@ const HEADER_WORKSPACE_ID: &str = "x-mnote-workspace-id";
const HEADER_SOURCE_CHANNEL: &str = "x-mnote-source-channel";
const HEADER_SOURCE_CLIENT: &str = "x-mnote-source-client";
const HEADER_IDEMPOTENCY_KEY: &str = "x-idempotency-key";
const COOKIE_MNOTE_WEB_CONVEX_TOKEN: &str = "mnote_web_convex_token";
fn read_env_or_dotenv(key: &str) -> Option<String> {
if let Ok(value) = std::env::var(key) {
@@ -57,6 +56,10 @@ fn build_authorization(config: &AppConfig, context: &RequestContext) -> Result<S
return Ok(authorization.to_string());
}
if let Some(convex_token) = extract_cookie_value(context, COOKIE_MNOTE_WEB_CONVEX_TOKEN) {
return Ok(format!("Bearer {convex_token}"));
}
let admin_key = config
.convex_admin_key
.clone()
@@ -71,27 +74,32 @@ fn build_authorization(config: &AppConfig, context: &RequestContext) -> Result<S
.with_header("x-upstream-service", "convex")
})?;
let effective_actor_id = if context.auth.actor_id.trim().is_empty()
|| context.auth.actor_id == "anonymous"
{
config.dev_user_id.clone()
} else {
context.auth.actor_id.clone()
};
// 说明:
// - Next 侧 `setAdminAuth(adminKey)` 走的是纯 admin auth,而不是伪造用户身份。
// - Convex 业务函数内部会在缺少真实 token 时自行回退到 DEV_USER_ID。
// - 这里若强行附带伪造 identity,会让 getAuthUserId(ctx) 命中一个未映射用户,
// 反而绕过 DEV_USER_ID fallback,导致 workspace membership 校验失败。
Ok(format!("Convex {admin_key}"))
}
let identity = json!({
"subject": effective_actor_id,
"issuer": "https://mnote.local/dev-auth",
"tokenIdentifier": format!("dev-user|{}", effective_actor_id),
"name": config.dev_user_name,
"email": config.dev_user_email,
});
let encoded = STANDARD.encode(
serde_json::to_string(&identity)
.map_err(|error| WebError::internal(format!("开发用户身份序列化失败: {error}")))?,
);
Ok(format!("Convex {admin_key}:{encoded}"))
fn extract_cookie_value(context: &RequestContext, name: &str) -> Option<String> {
context
.auth
.cookie_header
.as_deref()
.and_then(|cookie_header| {
cookie_header.split(';').find_map(|segment| {
let (key, value) = segment.trim().split_once('=')?;
if key.trim() != name {
return None;
}
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
Some(trimmed.to_string())
})
})
}
fn convex_url(config: &AppConfig, context: &RequestContext) -> Result<String, WebError> {
@@ -303,7 +311,10 @@ pub async fn execute_convex_command_plan(
if plan.function_name.trim().is_empty() || plan.function_name.ends_with(":unknown") {
return Err(WebError::bad_request_code(
"transport_command_unsupported",
format!("mnote-web transport 暂不支持 command: {}", plan.function_name),
format!(
"mnote-web transport 暂不支持 command: {}",
plan.function_name
),
)
.with_context(context)
.with_header("x-error-phase", "plan_validation"));
@@ -359,7 +370,10 @@ pub async fn execute_convex_command_plan(
let response = request.send().await.map_err(|error| {
let base = if error.is_timeout() {
WebError::gateway_timeout_code("convex_timeout", format!("Convex mutation 超时: {error}"))
WebError::gateway_timeout_code(
"convex_timeout",
format!("Convex mutation 超时: {error}"),
)
} else {
WebError::service_unavailable_code(
"convex_unavailable",
@@ -419,3 +433,77 @@ pub async fn execute_convex_command_plan(
.with_header("x-upstream-status", status.as_u16().to_string())),
}
}
#[cfg(test)]
mod tests {
use super::build_authorization;
use crate::app::AppConfig;
use crate::context::RequestContext;
use axum::http::{HeaderMap, HeaderValue, Method, Uri};
fn config() -> AppConfig {
AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:3104".into(),
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: Some("http://127.0.0.1:3210".into()),
convex_admin_key: Some("admin-demo".into()),
allow_dev_fixtures: false,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
}
}
fn request_context(headers: HeaderMap) -> RequestContext {
RequestContext::from_http_parts(
&Method::GET,
&"/api/compat/next/sidebar?workspaceId=ws_demo"
.parse::<Uri>()
.expect("uri"),
&headers,
)
}
#[test]
fn build_authorization_prefers_forwarded_authorization() {
let mut headers = HeaderMap::new();
headers.insert(
"authorization",
HeaderValue::from_static("Bearer real-token"),
);
let authorization =
build_authorization(&config(), &request_context(headers)).expect("authorization");
assert_eq!(authorization, "Bearer real-token");
}
#[test]
fn build_authorization_falls_back_to_plain_admin_auth() {
let authorization = build_authorization(&config(), &request_context(HeaderMap::new()))
.expect("authorization");
assert_eq!(authorization, "Convex admin-demo");
}
#[test]
fn build_authorization_reads_convex_token_from_cookie() {
let mut headers = HeaderMap::new();
headers.insert(
"cookie",
HeaderValue::from_static(
"foo=bar; mnote_web_convex_token=token-from-cookie; theme=light",
),
);
let authorization =
build_authorization(&config(), &request_context(headers)).expect("authorization");
assert_eq!(authorization, "Bearer token-from-cookie");
}
}
@@ -0,0 +1,39 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TreeShellCommandAction {
Create,
Rename,
Move,
Archive,
Restore,
Purge,
Embed,
Copy,
}
impl TreeShellCommandAction {
pub fn preferred_command_name(self) -> &'static str {
match self {
Self::Create => "tree.node.create",
Self::Rename => "tree.node.rename",
Self::Move => "tree.subtree.move",
Self::Archive => "tree.node.archive",
Self::Restore => "tree.node.restore",
Self::Purge => "tree.node.purge",
Self::Embed => "tree.node.embed",
Self::Copy => "tree.subtree.copy",
}
}
pub fn compat_command_name(self) -> &'static str {
match self {
Self::Create => "documents.create",
Self::Rename => "documents.title.update",
Self::Move => "documents.move",
Self::Archive => "documents.delete",
Self::Restore => "documents.restore",
Self::Purge => "documents.purge",
Self::Embed => "documents.embed",
Self::Copy => "documents.copy_tree",
}
}
}
@@ -0,0 +1,21 @@
use super::protocol;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileTreeRenderRow {
pub row_id: String,
pub row_kind: String,
pub title: String,
}
pub fn build_filetree_testids(rows: &[FileTreeRenderRow]) -> Vec<(&'static str, String)> {
rows.iter()
.map(|row| {
let test_id = match row.row_kind.as_str() {
"document" => protocol::TEST_ID_FILETREE_DOC_ROW,
"index" => protocol::TEST_ID_FILETREE_INDEX_ROW,
_ => protocol::TEST_ID_FILETREE_ASSET_ROW,
};
(test_id, row.row_id.clone())
})
.collect()
}
@@ -0,0 +1,22 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct TreeShellBootstrap {
pub workspace_id: String,
pub root_node_id: Option<String>,
pub mode: String,
pub request_id: String,
pub trace_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeShellLoader {
pub bootstrap: TreeShellBootstrap,
}
impl TreeShellLoader {
pub fn from_bootstrap(bootstrap: TreeShellBootstrap) -> Self {
Self { bootstrap }
}
}
@@ -0,0 +1,54 @@
pub mod dispatcher;
pub mod filetree_renderer;
pub mod loader;
pub mod page_renderer;
pub mod picker_renderer;
pub mod protocol;
pub mod state;
use leptos::prelude::*;
#[component]
pub fn TreeShellRoot(mode: &'static str, headline: String) -> impl IntoView {
view! {
<section data-testid="tree-shell-root" data-mode=mode>
<header>
<p>"mnote tree shell"</p>
<h1>{headline}</h1>
</header>
</section>
}
}
#[cfg(test)]
mod tests {
use super::dispatcher::TreeShellCommandAction;
use super::loader::{TreeShellBootstrap, TreeShellLoader};
use super::protocol;
use super::state::TreeShellUiState;
#[test]
fn tree_shell_leptos_scaffold_exposes_loader_dispatcher_and_state() {
let bootstrap = TreeShellBootstrap {
workspace_id: "ws_demo".into(),
root_node_id: Some("page_root".into()),
mode: protocol::MODE_PAGE.into(),
request_id: "req_demo".into(),
trace_id: "trace_demo".into(),
};
let loader = TreeShellLoader::from_bootstrap(bootstrap.clone());
let mut state = TreeShellUiState::from_defaults(&["page_root".into()]);
state.select("page_root");
state.focus("page_root");
assert_eq!(loader.bootstrap.workspace_id, "ws_demo");
assert_eq!(
TreeShellCommandAction::Create.preferred_command_name(),
"tree.node.create"
);
assert!(state.expanded.contains("page_root"));
assert_eq!(state.selected.as_deref(), Some("page_root"));
assert_eq!(state.focused.as_deref(), Some("page_root"));
assert_eq!(bootstrap.mode, protocol::MODE_PAGE);
}
}
@@ -0,0 +1,66 @@
use super::protocol;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PageTreeRenderRow {
pub node_id: String,
pub title: String,
pub depth: u32,
pub expandable: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PageTreeDomRow {
pub test_id: &'static str,
pub action_create_test_id: &'static str,
pub action_rename_test_id: &'static str,
pub action_move_up_test_id: &'static str,
pub node_id: String,
pub depth: u32,
pub title: String,
pub expandable: bool,
}
pub fn build_page_tree_dom_rows(rows: &[PageTreeRenderRow]) -> Vec<PageTreeDomRow> {
rows.iter()
.map(|row| PageTreeDomRow {
test_id: protocol::TEST_ID_TREE_NODE_OPEN,
action_create_test_id: protocol::TEST_ID_TREE_ACTION_CREATE,
action_rename_test_id: protocol::TEST_ID_TREE_ACTION_RENAME,
action_move_up_test_id: protocol::TEST_ID_TREE_ACTION_MOVE_UP,
node_id: row.node_id.clone(),
depth: row.depth,
title: row.title.clone(),
expandable: row.expandable,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::{build_page_tree_dom_rows, PageTreeRenderRow};
#[test]
fn tree_shell_page_renderer_builds_rows_with_stable_testids() {
let rows = build_page_tree_dom_rows(&[
PageTreeRenderRow {
node_id: "page_root".into(),
title: "首页".into(),
depth: 0,
expandable: true,
},
PageTreeRenderRow {
node_id: "page_child".into(),
title: "子页".into(),
depth: 1,
expandable: false,
},
]);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].test_id, "tree-node-open");
assert_eq!(rows[0].action_create_test_id, "tree-action-create");
assert_eq!(rows[0].action_rename_test_id, "tree-action-rename");
assert_eq!(rows[0].action_move_up_test_id, "tree-action-move-up");
assert_eq!(rows[1].depth, 1);
}
}
@@ -0,0 +1,59 @@
use super::protocol;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PickerRenderRow {
pub node_id: String,
pub title: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PickerRenderResult {
pub root_test_id: &'static str,
pub item_count: usize,
pub allow_root_pick: bool,
}
pub fn build_picker_render_result(
rows: &[PickerRenderRow],
allow_root_pick: bool,
) -> PickerRenderResult {
PickerRenderResult {
root_test_id: protocol::TEST_ID_TREE_PICKER_ROOT,
item_count: rows.len(),
allow_root_pick,
}
}
#[cfg(test)]
mod tests {
use super::super::filetree_renderer::{build_filetree_testids, FileTreeRenderRow};
use super::{build_picker_render_result, PickerRenderRow};
#[test]
fn tree_shell_filetree_picker_builds_file_rows_and_picker_mode() {
let file_rows = build_filetree_testids(&[
FileTreeRenderRow {
row_id: "doc:page_root".into(),
row_kind: "document".into(),
title: "首页".into(),
},
FileTreeRenderRow {
row_id: "asset:asset_1".into(),
row_kind: "asset".into(),
title: "附件".into(),
},
]);
let picker = build_picker_render_result(
&[PickerRenderRow {
node_id: "page_root".into(),
title: "首页".into(),
}],
true,
);
assert_eq!(file_rows[0].0, "filetree-doc-row");
assert_eq!(file_rows[1].0, "filetree-asset-row");
assert_eq!(picker.root_test_id, "tree-picker-root");
assert!(picker.allow_root_pick);
}
}
@@ -0,0 +1,14 @@
pub const MODE_PAGE: &str = "page";
pub const MODE_FILETREE: &str = "filetree";
pub const MODE_PICKER: &str = "picker";
pub const TEST_ID_TREE_SHELL_ROOT: &str = "tree-shell-root";
pub const TEST_ID_TREE_NODE_OPEN: &str = "tree-node-open";
pub const TEST_ID_TREE_NODE_TOGGLE: &str = "tree-node-toggle";
pub const TEST_ID_TREE_ACTION_CREATE: &str = "tree-action-create";
pub const TEST_ID_TREE_ACTION_RENAME: &str = "tree-action-rename";
pub const TEST_ID_TREE_ACTION_MOVE_UP: &str = "tree-action-move-up";
pub const TEST_ID_TREE_PICKER_ROOT: &str = "tree-picker-root";
pub const TEST_ID_FILETREE_DOC_ROW: &str = "filetree-doc-row";
pub const TEST_ID_FILETREE_INDEX_ROW: &str = "filetree-index-row";
pub const TEST_ID_FILETREE_ASSET_ROW: &str = "filetree-asset-row";
@@ -0,0 +1,75 @@
use std::collections::BTreeSet;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct TreeShellUiState {
pub expanded: BTreeSet<String>,
pub selected: Option<String>,
pub focused: Option<String>,
pub keyboard_anchor: Option<String>,
pub dragging: Option<String>,
pub drop_target: Option<String>,
}
impl TreeShellUiState {
pub fn from_defaults(expanded: &[String]) -> Self {
Self {
expanded: expanded.iter().cloned().collect(),
..Self::default()
}
}
pub fn toggle_expanded(&mut self, node_id: &str) {
if !self.expanded.insert(node_id.to_string()) {
self.expanded.remove(node_id);
}
}
pub fn select(&mut self, node_id: &str) {
self.selected = Some(node_id.to_string());
self.keyboard_anchor = Some(node_id.to_string());
}
pub fn focus(&mut self, node_id: &str) {
self.focused = Some(node_id.to_string());
}
pub fn start_drag(&mut self, node_id: &str) {
self.dragging = Some(node_id.to_string());
}
pub fn set_drop_target(&mut self, node_id: Option<&str>) {
self.drop_target = node_id.map(ToOwned::to_owned);
}
pub fn clear_drag(&mut self) {
self.dragging = None;
self.drop_target = None;
}
}
#[cfg(test)]
mod tests {
use super::TreeShellUiState;
#[test]
fn tree_shell_interaction_state_tracks_selection_focus_keyboard_and_dragging() {
let mut state = TreeShellUiState::from_defaults(&["page_root".into()]);
state.toggle_expanded("page_child");
state.select("page_child");
state.focus("page_child");
state.start_drag("page_child");
state.set_drop_target(Some("page_root"));
assert!(state.expanded.contains("page_root"));
assert!(state.expanded.contains("page_child"));
assert_eq!(state.selected.as_deref(), Some("page_child"));
assert_eq!(state.focused.as_deref(), Some("page_child"));
assert_eq!(state.keyboard_anchor.as_deref(), Some("page_child"));
assert_eq!(state.dragging.as_deref(), Some("page_child"));
assert_eq!(state.drop_target.as_deref(), Some("page_root"));
state.clear_drag();
assert!(state.dragging.is_none());
assert!(state.drop_target.is_none());
}
}