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
+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);
}
}