use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::documents::{ content as document_content, meta as document_meta, DocumentContentQuery, DocumentMetaQuery, }; use axum::extract::{Extension, Json, Query, State}; use axum::http::{header, HeaderValue, StatusCode}; use axum::response::{Html, IntoResponse, Response}; use mnote_editor_core::{BlockType, DocumentBlock, DocumentModel, EditorCommand, EditorSession}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DocumentEditorShellQuery { pub document_id: String, pub workspace_id: Option, pub host: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RuntimeShellBlockWire { pub id: String, pub block_type: String, pub parent_id: Option, pub depth: u16, #[serde(default)] pub text: String, pub heading_level: Option, pub checked: Option, #[serde(default)] pub collapsed: bool, #[serde(default)] pub editable: bool, #[serde(default)] pub raw_type: String, pub language: Option, #[serde(default)] pub raw_block: Value, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RuntimeTransformRequest { pub document_id: String, pub workspace_id: Option, pub snapshot: RuntimeTransformSnapshotRequest, pub command: RuntimeTransformCommandRequest, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RuntimeTransformSnapshotRequest { pub blocks: Vec, } #[derive(Debug, Deserialize)] #[serde(rename_all = "snake_case", tag = "action")] pub enum RuntimeTransformCommandRequest { SetBlockType { block_id: String, block_type: String, }, SplitBlock { block_id: String, offset: usize, new_block_id: String, }, MergeWithPrevious { block_id: String, }, IndentBlock { block_id: String, }, OutdentBlock { block_id: String, }, ToggleHeadingCollapse { block_id: String, }, } fn escape_html(input: &str) -> String { input .replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } fn escape_inline_json(input: &str) -> String { input .replace('&', "\\u0026") .replace('<', "\\u003c") .replace('>', "\\u003e") } fn read_non_empty_string(value: &Value, keys: &[&str]) -> Option { let map = value.as_object()?; for key in keys { let candidate = map .get(*key) .and_then(Value::as_str) .map(str::trim) .unwrap_or(""); if !candidate.is_empty() { return Some(candidate.to_string()); } } None } fn read_bool(value: &Value, keys: &[&str], default: bool) -> bool { let Some(map) = value.as_object() else { return default; }; for key in keys { if let Some(flag) = map.get(*key).and_then(Value::as_bool) { return flag; } } default } fn read_u64(value: &Value, keys: &[&str], default: u64) -> u64 { let Some(map) = value.as_object() else { return default; }; for key in keys { if let Some(number) = map.get(*key).and_then(Value::as_u64) { return number; } } default } fn read_non_empty_prop_string(value: &Value, key: &str) -> Option { value .get("props") .and_then(Value::as_object) .and_then(|props| props.get(key)) .and_then(Value::as_str) .map(str::trim) .filter(|item| !item.is_empty()) .map(ToOwned::to_owned) } fn read_prop_bool(value: &Value, key: &str) -> Option { value .get("props") .and_then(Value::as_object) .and_then(|props| props.get(key)) .and_then(Value::as_bool) } fn read_prop_u8(value: &Value, key: &str) -> Option { value .get("props") .and_then(Value::as_object) .and_then(|props| props.get(key)) .and_then(Value::as_u64) .and_then(|item| u8::try_from(item).ok()) } fn get_inline_text(value: &Value) -> String { match value { Value::String(text) => text.clone(), Value::Array(items) => items .iter() .map(|item| match item { Value::String(text) => text.clone(), Value::Object(map) => { if let Some(text) = map.get("text").and_then(Value::as_str) { return text.to_string(); } if let Some(content) = map.get("content") { return get_inline_text(content); } String::new() } _ => String::new(), }) .collect::>() .join(""), _ => String::new(), } } fn split_content_root(content_result: &Value) -> (Vec, Option) { let current = content_result .get("content") .cloned() .unwrap_or(Value::Null); if let Some(array) = current.as_array() { return (array.clone(), None); } if let Some(map) = current.as_object() { let blocks = map .get("blocks") .and_then(Value::as_array) .cloned() .unwrap_or_default(); let mut wrapper = map.clone(); wrapper.remove("blocks"); return (blocks, Some(Value::Object(wrapper))); } (Vec::new(), None) } fn runtime_block_type_for_raw_type(raw_type: &str) -> (&'static str, bool) { match raw_type { "paragraph" => ("paragraph", true), "heading" => ("heading", true), "bulletListItem" | "bullet_list_item" => ("bullet_list_item", true), "numberedListItem" | "numbered_list_item" => ("numbered_list_item", true), "checkListItem" | "advancedTodo" | "todo" => ("todo", true), "quote" | "blockquote" => ("quote", true), "divider" => ("divider", true), "codeBlock" => ("code_block", true), "pageReference" => ("page_reference", false), "blockReference" => ("block_reference", false), "progress" | "progressBlock" => ("progress_placeholder", false), "media" | "mindmap" | "onlineTable" => ("media_placeholder", false), _ => ("media_placeholder", false), } } fn raw_type_for_runtime_block_type(block_type: &str, fallback_raw_type: &str) -> String { match block_type { "paragraph" => "paragraph".into(), "heading" => "heading".into(), "bullet_list_item" => "bulletListItem".into(), "numbered_list_item" => "numberedListItem".into(), "todo" => { if fallback_raw_type == "advancedTodo" { "advancedTodo".into() } else if fallback_raw_type == "todo" { "todo".into() } else { "checkListItem".into() } } "quote" => "quote".into(), "divider" => "divider".into(), "code_block" => "codeBlock".into(), "page_reference" => "pageReference".into(), "block_reference" => "blockReference".into(), "progress_placeholder" => { if fallback_raw_type.trim().is_empty() { "progressBlock".into() } else { fallback_raw_type.to_string() } } "media_placeholder" => { if fallback_raw_type.trim().is_empty() { "media".into() } else { fallback_raw_type.to_string() } } _ => { if fallback_raw_type.trim().is_empty() { "paragraph".into() } else { fallback_raw_type.to_string() } } } } fn append_runtime_shell_blocks( raw_blocks: &[Value], depth: u16, parent_id: Option<&str>, output: &mut Vec, ) { for (index, raw_block) in raw_blocks.iter().enumerate() { let id = read_non_empty_string(raw_block, &["id"]) .unwrap_or_else(|| format!("runtime_block_{}_{}", depth, index + 1)); let raw_type = read_non_empty_string(raw_block, &["type"]).unwrap_or_else(|| "paragraph".into()); let (block_type, editable) = runtime_block_type_for_raw_type(&raw_type); let text = get_inline_text(raw_block.get("content").unwrap_or(&Value::Null)); let block = RuntimeShellBlockWire { id: id.clone(), block_type: block_type.into(), parent_id: parent_id.map(ToOwned::to_owned), depth, text: text.clone(), heading_level: read_prop_u8(raw_block, "level"), checked: read_prop_bool(raw_block, "checked").or_else(|| { read_non_empty_prop_string(raw_block, "status") .map(|status| matches!(status.as_str(), "done" | "completed")) }), collapsed: read_prop_bool(raw_block, "collapsed").unwrap_or(false), editable, raw_type: raw_type.clone(), language: read_non_empty_prop_string(raw_block, "language"), raw_block: raw_block.clone(), }; output.push(block); let children = raw_block .get("children") .and_then(Value::as_array) .cloned() .unwrap_or_default(); if !children.is_empty() { append_runtime_shell_blocks(&children, depth + 1, Some(id.as_str()), output); } } } fn initial_runtime_shell_blocks( content_result: &Value, document_id: &str, ) -> (Vec, Option) { let (raw_blocks, content_wrapper) = split_content_root(content_result); let mut blocks = Vec::new(); append_runtime_shell_blocks(&raw_blocks, 0, None, &mut blocks); if blocks.is_empty() { blocks.push(RuntimeShellBlockWire { id: format!("{document_id}_block_1"), block_type: "paragraph".into(), parent_id: None, depth: 0, text: String::new(), heading_level: None, checked: None, collapsed: false, editable: true, raw_type: "paragraph".into(), language: None, raw_block: Value::Null, }); } (blocks, content_wrapper) } fn core_block_type_from_runtime(block_type: &str) -> BlockType { match block_type { "paragraph" => BlockType::Paragraph, "heading" => BlockType::Heading, "bullet_list_item" => BlockType::BulletListItem, "numbered_list_item" => BlockType::NumberedListItem, "todo" => BlockType::Todo, "quote" => BlockType::Quote, "divider" => BlockType::Divider, "code_block" => BlockType::CodeBlock, "page_reference" => BlockType::PageReference, "block_reference" => BlockType::BlockReference, "progress_placeholder" => BlockType::ProgressPlaceholder, _ => BlockType::MediaPlaceholder, } } fn runtime_block_type_from_core(block_type: &BlockType) -> &'static str { match block_type { BlockType::Paragraph => "paragraph", BlockType::Heading => "heading", BlockType::BulletListItem => "bullet_list_item", BlockType::NumberedListItem => "numbered_list_item", BlockType::Todo => "todo", BlockType::Quote => "quote", BlockType::Divider => "divider", BlockType::CodeBlock => "code_block", BlockType::PageReference => "page_reference", BlockType::BlockReference => "block_reference", BlockType::ProgressPlaceholder => "progress_placeholder", BlockType::MediaPlaceholder => "media_placeholder", } } fn runtime_snapshot_to_document(blocks: &[RuntimeShellBlockWire]) -> DocumentModel { DocumentModel::new( blocks .iter() .map(|block| { let mut item = DocumentBlock::new( block.id.clone(), core_block_type_from_runtime(&block.block_type), ) .with_text(block.text.clone()) .with_collapsed(block.collapsed); item.parent_id = block.parent_id.clone(); item.indent = block.depth; item.heading_level = block.heading_level; item.checked = block.checked; if let Some(language) = block.language.as_deref() { item.content.language = Some(language.to_string()); } item }) .collect(), ) } fn runtime_snapshot_from_document( document: &DocumentModel, previous: &[RuntimeShellBlockWire], ) -> Vec { document .blocks() .iter() .map(|block| { let previous_block = previous.iter().find(|item| item.id == block.id); let editable = previous_block.map(|item| item.editable).unwrap_or(true); let raw_type = previous_block .map(|item| { raw_type_for_runtime_block_type( runtime_block_type_from_core(&block.block_type), &item.raw_type, ) }) .unwrap_or_else(|| { raw_type_for_runtime_block_type( runtime_block_type_from_core(&block.block_type), "", ) }); RuntimeShellBlockWire { id: block.id.clone(), block_type: runtime_block_type_from_core(&block.block_type).into(), parent_id: block.parent_id.clone(), depth: block.indent, text: block.content.text.clone(), heading_level: block.heading_level, checked: block.checked, collapsed: block.collapsed, editable, raw_type, language: block.content.language.clone(), raw_block: Value::Null, } }) .collect() } fn runtime_editor_command( request: RuntimeTransformCommandRequest, ) -> Result { match request { RuntimeTransformCommandRequest::SetBlockType { block_id, block_type, } => { let Some(block_type) = BlockType::from_editor_label(block_type.as_str()) else { return Err(WebError::bad_request(format!( "不支持的 blockType: {block_type}" ))); }; Ok(EditorCommand::SetBlockType { block_id, block_type, }) } RuntimeTransformCommandRequest::SplitBlock { block_id, offset, new_block_id, } => Ok(EditorCommand::SplitBlock { block_id, offset, new_block_id, }), RuntimeTransformCommandRequest::MergeWithPrevious { block_id } => { Ok(EditorCommand::MergeWithPrevious { block_id }) } RuntimeTransformCommandRequest::IndentBlock { block_id } => { Ok(EditorCommand::IndentBlock { block_id }) } RuntimeTransformCommandRequest::OutdentBlock { block_id } => { Ok(EditorCommand::OutdentBlock { block_id }) } RuntimeTransformCommandRequest::ToggleHeadingCollapse { block_id } => { Ok(EditorCommand::ToggleHeadingCollapse { block_id }) } } } fn build_document_editor_shell_html( workspace_id: Option<&str>, document_id: &str, host: Option<&str>, meta_result: &Value, content_result: &Value, ) -> String { let page_title = read_non_empty_string(meta_result, &["title"]).unwrap_or_else(|| "无标题".into()); let updated_at = read_non_empty_string(meta_result, &["updated_at", "updatedAt"]) .unwrap_or_else(|| "未知".into()); let read_only = !read_bool(meta_result, &["can_edit", "canEdit"], true); let revision = read_u64(content_result, &["revision"], 0); let conflict_detection_key = read_non_empty_string( content_result, &["conflict_detection_key", "conflictDetectionKey"], ) .unwrap_or_else(|| format!("{document_id}:{revision}")); let (blocks, content_wrapper) = initial_runtime_shell_blocks(content_result, document_id); let initial_focus_id = blocks .first() .map(|node| node.id.clone()) .unwrap_or_else(|| "block-empty".into()); let app_state = json!({ "workspaceId": workspace_id, "documentId": document_id, "host": host, "meta": { "title": page_title, "updatedAt": updated_at, "readOnly": read_only, "revision": revision, "conflictDetectionKey": conflict_detection_key, }, "contentWrapper": content_wrapper, "initialFocusId": initial_focus_id, "blocks": blocks, }); let app_state_json = serde_json::to_string(&app_state).unwrap_or_else(|_| "{}".into()); let template = r##" mnote Document Runtime Debug
Document Runtime Debug

__PAGE_TITLE__

这是 mnote-web 的文档 runtime 调试页,只用于事务诊断、IME 排障、保存链回归与 bridge API 观察。 默认文档页仍应走产品页主链,这里不再作为正式编辑器 surface。

Workspace__WORKSPACE_ID__
Document__DOCUMENT_ID__
Host__HOST__
ReadOnly__READ_ONLY__
revision=__REVISION__ updatedAt=__UPDATED_AT__ blocks=0 save=idle
focus=- selection=- hover=-

Block List

    "##; template .replace("__PAGE_TITLE__", &escape_html(&page_title)) .replace( "__WORKSPACE_ID__", &escape_html(workspace_id.unwrap_or("未指定")), ) .replace("__DOCUMENT_ID__", &escape_html(document_id)) .replace("__HOST__", &escape_html(host.unwrap_or("mnote-web"))) .replace("__READ_ONLY__", if read_only { "true" } else { "false" }) .replace("__REVISION__", &escape_html(&revision.to_string())) .replace("__UPDATED_AT__", &escape_html(&updated_at)) .replace("__APP_STATE__", &escape_inline_json(&app_state_json)) } pub async fn document_editor_shell( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result { let (_, _, meta_json) = document_meta( State(state.clone()), Extension(context.clone()), Query(DocumentMetaQuery { document_id: query.document_id.clone(), workspace_id: query.workspace_id.clone(), }), ) .await?; let (_, _, content_json) = document_content( State(state), Extension(context), Query(DocumentContentQuery { document_id: query.document_id.clone(), workspace_id: query.workspace_id.clone(), }), ) .await?; let meta_result = meta_json.0.get("result").cloned().unwrap_or(Value::Null); let content_result = content_json.0.get("result").cloned().unwrap_or(Value::Null); let html = build_document_editor_shell_html( query.workspace_id.as_deref(), &query.document_id, query.host.as_deref(), &meta_result, &content_result, ); 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::CACHE_CONTROL, HeaderValue::from_static("no-store")); response.headers_mut().insert( "x-mnote-shell", HeaderValue::from_static("document-runtime-debug"), ); Ok(response) } pub async fn transform_runtime_snapshot( Extension(context): Extension, Json(body): Json, ) -> Result<(StatusCode, Json), WebError> { let document_id = body.document_id.trim(); if document_id.is_empty() { return Err( WebError::bad_request_code("document_id_required", "缺少有效 documentId") .with_context(&context), ); } let mut session = EditorSession::new(runtime_snapshot_to_document(&body.snapshot.blocks)); let command = runtime_editor_command(body.command).map_err(|error| error.with_context(&context))?; session .apply_command(command) .map_err(|error| WebError::bad_request(error.to_string()).with_context(&context))?; let blocks = runtime_snapshot_from_document(session.document(), &body.snapshot.blocks); Ok(( StatusCode::OK, Json(json!({ "ok": true, "requestId": context.trace.request_id, "traceId": context.trace.trace_id, "result": { "documentId": document_id, "workspaceId": body.workspace_id, "blocks": blocks, }, })), )) } #[cfg(test)] mod tests { use crate::app::{build_app, AppConfig, AppState}; use axum::body::{to_bytes, Body}; use axum::http::{Request, StatusCode}; use serde_json::{json, Value}; use tower::util::ServiceExt; fn app() -> axum::Router { build_app(AppState::new(AppConfig { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), public_bind_addr: "127.0.0.1:3000".into(), legacy_next_base_url: Some("http://127.0.0.1:3100".into()), enable_legacy_next_compat: true, enable_debug_shell_routes: true, enable_editor_actor: true, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), convex_url: None, convex_admin_key: None, allow_dev_fixtures: true, query_fixtures_json: Some( r#"{ "documents:getMeta": { "id": "doc_shell", "workspace_id": "ws_demo", "title": "编辑器壳页面", "updated_at": "2026-04-18T11:22:33Z", "can_edit": true, "show_structure": true }, "documents:getContent": { "title": "编辑器壳页面", "content": [ { "id": "heading_1", "type": "heading", "props": { "level": 1 }, "content": [{ "type": "text", "text": "第一节" }] } ], "revision": 9, "conflict_detection_key": "doc_shell:9", "page_subtree": { "projection_id": "kernel_projection:page_tree:doc_shell", "projection": "page_tree", "root_node_id": "doc_shell", "root_node": { "id": "doc_shell", "parent_node_id": null, "node_type": "page", "block_id": null, "anchor_block_id": null, "depth": 0, "metadata": { "title": "编辑器壳页面", "text_snippet": "页面首段", "block_type": null, "heading_level": null, "numbering": null, "child_count": 2, "order": 0, "path": ["编辑器壳页面"] } }, "subtree": { "root_node_id": "doc_shell", "nodes": [ { "id": "doc_shell", "parent_node_id": null, "node_type": "page", "block_id": null, "anchor_block_id": null, "depth": 0, "metadata": { "title": "编辑器壳页面", "text_snippet": "页面首段", "block_type": null, "heading_level": null, "numbering": null, "child_count": 2, "order": 0, "path": ["编辑器壳页面"] } }, { "id": "node_heading_1", "parent_node_id": "doc_shell", "node_type": "section", "block_id": "heading_1", "anchor_block_id": "heading_1", "depth": 1, "metadata": { "title": "第一节", "text_snippet": "第一节 页面首段", "block_type": "heading", "heading_level": 1, "numbering": "1", "child_count": 1, "order": 0, "path": ["编辑器壳页面", "第一节"] } }, { "id": "node_para_1", "parent_node_id": "node_heading_1", "node_type": "content_node", "block_id": "paragraph_1", "anchor_block_id": null, "depth": 2, "metadata": { "title": null, "text_snippet": "这是正文第一段", "block_type": "paragraph", "heading_level": null, "numbering": null, "child_count": 0, "order": 1, "path": ["编辑器壳页面", "第一节", "这是正文第一段"] } } ] }, "outline": [ { "id": "outline_heading_1", "node_id": "node_heading_1", "anchor_block_id": "heading_1", "title": "第一节", "level": 1, "numbering": "1" } ], "evidence": [], "stats": { "block_count": 3, "heading_count": 1, "evidence_count": 0, "max_depth": 2 } } } }"# .into(), ), mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), })) } #[tokio::test] async fn block_editor_shell_returns_interactive_html_document() { let response = app() .oneshot( Request::builder() .uri("/document-debug?documentId=doc_shell&workspaceId=ws_demo&host=next-document-page") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let content_type = response .headers() .get("content-type") .and_then(|value| value.to_str().ok()) .unwrap_or_default(); assert!(content_type.contains("text/html")); assert_eq!( response .headers() .get("x-mnote-shell") .and_then(|value| value.to_str().ok()), Some("document-runtime-debug") ); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let html = String::from_utf8(body.to_vec()).expect("utf8"); assert!(html.contains("block-editor-shell-state")); assert!(html.contains("block_editor_shell.ready")); assert!(html.contains("human_editor.focus:")); assert!(html.contains("data-block-id")); assert!(html.contains("editor-row")); assert!(html.contains("next-document-page")); assert!(html.contains("focus / selection / hover")); assert!(html.contains("\"blocks\":[")); assert!(html.contains("第一节")); assert!(html.contains("\"id\":\"heading_1\"")); assert!(html.contains("Document Runtime Debug")); } #[tokio::test] async fn editor_interactions_shell_exposes_slash_reference_and_indent_controls() { let response = app() .oneshot( Request::builder() .uri("/document-debug?documentId=doc_shell&workspaceId=ws_demo&host=next-document-page") .body(Body::empty()) .expect("request"), ) .await .expect("response"); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let html = String::from_utf8(body.to_vec()).expect("utf8"); assert!(html.contains("editor-command-slash")); assert!(html.contains("editor-command-indent")); assert!(html.contains("editor-command-outdent")); assert!(html.contains("[[page]]")); assert!(html.contains("((block))")); assert!(html.contains("human_editor.slash:")); assert!(html.contains("human_editor.reference:")); assert!(html.contains("\"indent_block\"")); assert!(html.contains("\"outdent_block\"")); } #[tokio::test] async fn editor_interactions_shell_exposes_heading_collapse_hooks() { let response = app() .oneshot( Request::builder() .uri("/document-debug?documentId=doc_shell&workspaceId=ws_demo") .body(Body::empty()) .expect("request"), ) .await .expect("response"); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let html = String::from_utf8(body.to_vec()).expect("utf8"); assert!(html.contains("editor-command-toggle-heading")); assert!(html.contains("human_editor.toggle_heading_collapse:")); assert!(html.contains("\"collapsed\":false")); assert!(html.contains("\"headingLevel\":1")); assert!(html.contains("\"blockType\":\"heading\"")); } #[tokio::test] async fn human_editor_runtime_shell_includes_real_input_and_save_markers() { let response = app() .oneshot( Request::builder() .uri("/document-debug?documentId=doc_shell&workspaceId=ws_demo") .body(Body::empty()) .expect("request"), ) .await .expect("response"); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let html = String::from_utf8(body.to_vec()).expect("utf8"); assert!(html.contains("editor-save-status")); assert!(html.contains("/api/documents/save")); assert!(html.contains("editor-input")); assert!(html.contains("human_editor_runtime.ready")); } #[tokio::test] async fn human_editor_input_shell_wires_beforeinput_and_composition_events() { let response = app() .oneshot( Request::builder() .uri("/document-debug?documentId=doc_shell&workspaceId=ws_demo") .body(Body::empty()) .expect("request"), ) .await .expect("response"); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let html = String::from_utf8(body.to_vec()).expect("utf8"); assert!(html.contains("human_editor_input.beforeinput")); assert!(html.contains("human_editor_input.composition")); assert!(html.contains("selectionStart")); assert!(html.contains("compositionstart")); } #[tokio::test] async fn human_editor_transactions_transform_route_applies_split_command() { let response = app() .oneshot( Request::builder() .method("POST") .uri("/api/documents/runtime/transform") .header("content-type", "application/json") .body(Body::from( json!({ "documentId": "doc_shell", "workspaceId": "ws_demo", "snapshot": { "blocks": [ { "id": "block_a", "blockType": "paragraph", "parentId": null, "depth": 0, "text": "AlphaBeta", "headingLevel": null, "checked": null, "collapsed": false, "editable": true, "rawType": "paragraph", "language": null } ] }, "command": { "action": "split_block", "block_id": "block_a", "offset": 5, "new_block_id": "block_b" } }) .to_string(), )) .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 blocks = payload["result"]["blocks"].as_array().expect("blocks"); assert_eq!(blocks.len(), 2); assert_eq!(blocks[0]["text"], "Alpha"); assert_eq!(blocks[1]["text"], "Beta"); } #[tokio::test] async fn human_editor_commands_shell_exposes_slash_and_reference_runtime_controls() { let response = app() .oneshot( Request::builder() .uri("/document-debug?documentId=doc_shell&workspaceId=ws_demo") .body(Body::empty()) .expect("request"), ) .await .expect("response"); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let html = String::from_utf8(body.to_vec()).expect("utf8"); assert!(html.contains("human_editor.slash_menu:")); assert!(html.contains("human_editor.reference:")); assert!(html.contains("[[page]]")); assert!(html.contains("((block))")); assert!(html.contains("set_block_type")); } }