feat(tree): checkpoint resource lifecycle work
提交当前顶层 mnote Git 工作区,范围集中在 04-tree-domain 的 resource/trash/filetree 生命周期、mnote-web resource_trash 路由、Convex/Next 兼容接口、sidebar/file-tree 客户端适配、smoke 脚本与对应设计/bug 记录。 不包含被 ignore 的 design/05-editor-mainline/reference-code/leptos-tiptap 嵌套仓库改动。新增 smoke 的测试密码改为运行时读取 MNOTE_E2E_PASSWORD,避免提交明文 credential assignment。
This commit is contained in:
@@ -122,6 +122,27 @@ describe("tree-shell-host", () => {
|
||||
iconHint: "page",
|
||||
},
|
||||
},
|
||||
{
|
||||
projectionKind: "file_tree",
|
||||
rowId: "asset:asset_pdf",
|
||||
rowKind: "asset",
|
||||
nodeId: "asset_pdf",
|
||||
parentNodeId: "doc_2",
|
||||
title: "附件.pdf",
|
||||
depth: 1,
|
||||
childCount: 0,
|
||||
position: 3,
|
||||
expandedByDefault: false,
|
||||
iconHint: "pdf",
|
||||
capabilities: ["select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "pdf",
|
||||
documentId: "doc_2",
|
||||
assetId: "asset_pdf",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "pdf",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function queryFileTreeRow(rowId: string) {
|
||||
@@ -142,6 +163,7 @@ describe("tree-shell-host", () => {
|
||||
anchorRowId: string | null;
|
||||
focusedRowId: string | null;
|
||||
}) => void;
|
||||
onTreeMutation?: (payload: any) => void;
|
||||
} = {}) {
|
||||
act(() => {
|
||||
root.render(
|
||||
@@ -152,6 +174,7 @@ describe("tree-shell-host", () => {
|
||||
activeDocumentId={input.activeDocumentId ?? null}
|
||||
inlineFileTreeItems={fileTreeItems}
|
||||
onFileTreeDeleteSelection={input.onFileTreeDeleteSelection}
|
||||
onTreeMutation={input.onTreeMutation}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
@@ -367,4 +390,141 @@ describe("tree-shell-host", () => {
|
||||
|
||||
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
|
||||
});
|
||||
|
||||
it("F2 应进入 filetree 行内重命名,并用 tree.node.rename 提交页面标题", async () => {
|
||||
const handleTreeMutation = vi.fn();
|
||||
renderDomHost({ onTreeMutation: handleTreeMutation });
|
||||
|
||||
const row2 = queryFileTreeRow("index:doc_2");
|
||||
expect(row2).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
row2?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "F2" }));
|
||||
});
|
||||
await flushRuntimeDispatch();
|
||||
|
||||
const input = container.querySelector('.tree-rename-input[data-rename-id="index:doc_2"]') as HTMLInputElement | null;
|
||||
expect(input).not.toBeNull();
|
||||
act(() => {
|
||||
input!.value = "Doc 2 Renamed";
|
||||
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await act(async () => {
|
||||
input!.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const renameRequest = pendingRuntimeRequests.find(
|
||||
(entry) => (entry.request as { action?: unknown }).action === "rename",
|
||||
);
|
||||
expect(renameRequest?.request).toMatchObject({
|
||||
action: "rename",
|
||||
documentId: "doc_2",
|
||||
title: "Doc 2 Renamed",
|
||||
workspaceId: "ws_1",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
renameRequest?.resolve({
|
||||
result: {
|
||||
documentId: "doc_2",
|
||||
title: "Doc 2 Renamed",
|
||||
workspaceId: "ws_1",
|
||||
updatedAt: "2026-05-15T00:00:00Z",
|
||||
},
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(handleTreeMutation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "tree.node.renamed",
|
||||
documentId: "doc_2",
|
||||
title: "Doc 2 Renamed",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("F2 行内重命名应在输入框内阻断空名、非法字符和同级重名", async () => {
|
||||
renderDomHost();
|
||||
|
||||
const row2 = queryFileTreeRow("index:doc_2");
|
||||
expect(row2).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
row2?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "F2" }));
|
||||
});
|
||||
await flushRuntimeDispatch();
|
||||
|
||||
const input = container.querySelector('.tree-rename-input[data-rename-id="index:doc_2"]') as HTMLInputElement | null;
|
||||
expect(input).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
input!.value = " ";
|
||||
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await act(async () => {
|
||||
input!.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(container.querySelector('[data-testid="tree-rename-validation"]')?.textContent).toContain("名称不能为空");
|
||||
expect(input?.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(pendingRuntimeRequests.some((entry) => (entry.request as { action?: unknown }).action === "rename")).toBe(false);
|
||||
|
||||
act(() => {
|
||||
input!.value = "非法/名称";
|
||||
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await act(async () => {
|
||||
input!.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(container.querySelector('[data-testid="tree-rename-validation"]')?.textContent).toContain("不能包含");
|
||||
expect(pendingRuntimeRequests.some((entry) => (entry.request as { action?: unknown }).action === "rename")).toBe(false);
|
||||
|
||||
act(() => {
|
||||
input!.value = "Doc 1 索引";
|
||||
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await act(async () => {
|
||||
input!.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(container.querySelector('[data-testid="tree-rename-validation"]')?.textContent).toContain("同级已存在");
|
||||
expect(pendingRuntimeRequests.some((entry) => (entry.request as { action?: unknown }).action === "rename")).toBe(false);
|
||||
});
|
||||
|
||||
it("F2 重命名附件未输入扩展名时应保留原扩展名", async () => {
|
||||
renderDomHost();
|
||||
|
||||
const assetRow = queryFileTreeRow("asset:asset_pdf");
|
||||
expect(assetRow).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
assetRow?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "F2" }));
|
||||
});
|
||||
await flushRuntimeDispatch();
|
||||
|
||||
const input = container.querySelector('.tree-rename-input[data-rename-id="asset:asset_pdf"]') as HTMLInputElement | null;
|
||||
expect(input).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
input!.value = "附件新名";
|
||||
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await act(async () => {
|
||||
input!.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const renameRequest = pendingRuntimeRequests.find((entry) => {
|
||||
const request = entry.request as { action?: unknown; assetIds?: unknown };
|
||||
return request.action === "rename" && Array.isArray(request.assetIds);
|
||||
});
|
||||
expect(renameRequest?.request).toMatchObject({
|
||||
action: "rename",
|
||||
assetIds: ["asset_pdf"],
|
||||
newName: "附件新名.pdf",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user