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
@@ -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());
}
}