diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 3357c7c5f..e9118ba73 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -2030,15 +2030,17 @@ async function confirmDropObject() { const msgKey = node.type === "view" ? "contextMenu.dropViewSuccess" : node.type === "materialized_view" ? "contextMenu.dropViewSuccess" : node.type === "procedure" ? "contextMenu.dropProcedureSuccess" : "contextMenu.dropFunctionSuccess"; toast(t(msgKey, { name: node.label }), 3000); closeDroppedTableObjectTabsForNode(node); - // Procedure/function drops refresh their parent instead of removing this - // node directly, so clear their pin before the old identity can survive. + // Refresh the parent object list so group badges and children stay in sync. + // Clear the pin first — refresh rebuilds nodes and the old identity must not survive. connectionStore.removePinnedTreeNodes([node]); - if (node.type === "view" || node.type === "materialized_view") { - connectionStore.removeTreeNode(node.id); - releaseActiveNodeReference([node.id]); - } else { + try { await refreshTableList(node); + } catch (error: any) { + // DROP already succeeded; keep the sidebar consistent if metadata refresh fails. + connectionStore.removeTreeNode(node.id); + toast(t("contextMenu.objectDropRefreshFailed", { message: error?.message || String(error) }), 5000); } + releaseActiveNodeReference([node.id]); } catch (e: any) { toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000); } @@ -2134,6 +2136,7 @@ async function confirmBatchDrop() { showBatchDropConfirm.value = false; return; } + const refreshScopes = new Map(); for (const target of targets) { if (!target.connectionId || !target.database) continue; await connectionStore.ensureConnected(target.connectionId); @@ -2141,11 +2144,18 @@ async function confirmBatchDrop() { if (!sql) continue; await executeTreeNodeSqlWithProductionGuard(target, sql, { database: target.database, schema: target.schema }); closeDroppedTableObjectTabsForNode(target); + // Remove immediately so a later failure cannot leave dropped objects in the tree. connectionStore.removeTreeNode(target.id); releaseActiveNodeReference([target.id]); + refreshScopes.set(`${target.connectionId}:${target.database}:${target.schema ?? ""}`, target); } toast(t("contextMenu.batchDropSuccess", { count: targets.length }), 3000); showBatchDropConfirm.value = false; + const refreshResults = await Promise.allSettled([...refreshScopes.values()].map((target) => refreshTableList(target))); + const refreshFailure = refreshResults.find((result): result is PromiseRejectedResult => result.status === "rejected"); + if (refreshFailure) { + toast(t("contextMenu.objectDropRefreshFailed", { message: refreshFailure.reason?.message || String(refreshFailure.reason) }), 5000); + } } catch (e: any) { toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000); } diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 45bb9421a..c56992a2e 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2166,6 +2166,7 @@ export default { truncateTableSuccess: 'Table "{name}" truncated', duplicateStructureSuccess: 'Table cloned as "{name}"', tableOperationFailed: "Operation failed: {message}", + objectDropRefreshFailed: "Objects were deleted, but refreshing the sidebar failed: {message}", duplicateNameTitle: "Clone as New Table", duplicateNamePlaceholder: "New table name", copyTable: "Copy Table", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 264b00f0d..c2de43960 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2101,6 +2101,7 @@ export default withEnglishFallback({ truncateTableSuccess: 'Tabla "{name}" truncada', duplicateStructureSuccess: 'Tabla clonada como "{name}"', tableOperationFailed: "Error en la operación: {message}", + objectDropRefreshFailed: "Los objetos se eliminaron, pero no se pudo actualizar la barra lateral: {message}", duplicateNameTitle: "Clonar como tabla nueva", duplicateNamePlaceholder: "Nombre de la nueva tabla", copyTable: "Copiar tabla", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 2327653c6..dac3a288d 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2099,6 +2099,7 @@ export default withEnglishFallback({ truncateTableSuccess: 'Tabella "{name}" troncata', duplicateStructureSuccess: 'Tabella clonata come "{name}"', tableOperationFailed: "Operazione non riuscita: {message}", + objectDropRefreshFailed: "Gli oggetti sono stati eliminati, ma l'aggiornamento della barra laterale non è riuscito: {message}", duplicateNameTitle: "Clona as Nuova Tabella", duplicateNamePlaceholder: "Nuovo nome tabella", copyTable: "Copia tabella", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 9f751c29c..81e08eb83 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2096,6 +2096,7 @@ export default withEnglishFallback({ truncateTableSuccess: "テーブル「{name}」をトランケートしました", duplicateStructureSuccess: "テーブルを「{name}」として複製しました", tableOperationFailed: "操作に失敗しました: {message}", + objectDropRefreshFailed: "オブジェクトは削除されましたが、サイドバーの更新に失敗しました: {message}", duplicateNameTitle: "新しいテーブルとして複製", duplicateNamePlaceholder: "新しいテーブル名", copyTable: "テーブルをコピー", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 491a9867f..6e9c90451 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -2137,6 +2137,7 @@ export default withEnglishFallback({ truncateTableSuccess: '테이블 "{name}" TRUNCATE됨', duplicateStructureSuccess: '테이블이 "{name}"(으)로 복제됨', tableOperationFailed: "작업 실패: {message}", + objectDropRefreshFailed: "개체는 삭제되었지만 사이드바 새로 고침에 실패했습니다: {message}", duplicateNameTitle: "새 테이블로 복제", duplicateNamePlaceholder: "새 테이블 이름", copyTable: "테이블 복사", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 910d81a9e..1b2aedeab 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2101,6 +2101,7 @@ export default withEnglishFallback({ truncateTableSuccess: 'Tabela "{name}" truncada', duplicateStructureSuccess: 'Tabela clonada como "{name}"', tableOperationFailed: "Falha na operação: {message}", + objectDropRefreshFailed: "Os objetos foram excluídos, mas não foi possível atualizar a barra lateral: {message}", duplicateNameTitle: "Clonar como Nova Tabela", duplicateNamePlaceholder: "Nome da nova tabela", copyTable: "Copiar tabela", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index fa2df8ff1..a60ec4a29 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2167,6 +2167,7 @@ export default withEnglishFallback({ truncateTableSuccess: "表「{name}」已截断", duplicateStructureSuccess: "已克隆为新表「{name}」", tableOperationFailed: "操作失败:{message}", + objectDropRefreshFailed: "对象已删除,但侧边栏刷新失败:{message}", duplicateNameTitle: "克隆为新表", duplicateNamePlaceholder: "新表名", copyTable: "复制表", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 8168a086b..dd930d02b 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2100,6 +2100,7 @@ export default withEnglishFallback({ truncateTableSuccess: "資料表「{name}」已截斷", duplicateStructureSuccess: "已克隆為新資料表「{name}」", tableOperationFailed: "操作失敗:{message}", + objectDropRefreshFailed: "物件已刪除,但側邊欄重新整理失敗:{message}", duplicateNameTitle: "克隆為新資料表", duplicateNamePlaceholder: "新資料表名稱", copyTable: "複製資料表", diff --git a/apps/desktop/src/stores/__tests__/connectionStore.pinned.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.pinned.spec.ts index a2fb2db5c..573735266 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.pinned.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.pinned.spec.ts @@ -58,6 +58,60 @@ describe("connectionStore pinned tree node removal", () => { expect(store.isTreeNodePinned(replacement)).toBe(false); }); + it("recounts parent objectCount from remaining children when a child is removed", async () => { + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const view1 = { + id: "conn:db:public:v1", + label: "v1", + type: "view" as const, + connectionId: "conn", + database: "db", + schema: "public", + }; + const view2 = { + id: "conn:db:public:v2", + label: "v2", + type: "view" as const, + connectionId: "conn", + database: "db", + schema: "public", + }; + const loadMore = { + id: "conn:db:public:__views:__load_more", + label: "Load more", + type: "load-more" as const, + connectionId: "conn", + database: "db", + }; + const viewsGroup: TreeNode = { + id: "conn:db:public:__views", + label: "Views", + type: "group-views", + connectionId: "conn", + database: "db", + schema: "public", + objectCount: 99, + children: [view1, view2, loadMore], + }; + store.treeNodes = [ + { + id: "conn", + label: "Connection", + type: "connection", + connectionId: "conn", + children: [viewsGroup], + }, + ]; + + store.removeTreeNode(view1.id); + + expect(viewsGroup.children?.map((child) => child.id)).toEqual([view2.id, loadMore.id]); + expect(viewsGroup.objectCount).toBe(1); + }); + it("serializes desktop pin saves so an older reorder cannot overwrite the latest one", async () => { const savePinnedTreeNodeIds = vi.fn().mockResolvedValue(undefined); vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true })); diff --git a/apps/desktop/src/stores/__tests__/connectionStore.refresh.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.refresh.spec.ts new file mode 100644 index 000000000..0518ecee9 --- /dev/null +++ b/apps/desktop/src/stores/__tests__/connectionStore.refresh.spec.ts @@ -0,0 +1,134 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ConnectionConfig, TreeNode } from "@/types/database"; + +function installLocalStorage() { + const data = new Map(); + vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => data.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => data.set(key, value)), + removeItem: vi.fn((key: string) => data.delete(key)), + }); +} + +function postgresConnection(): ConnectionConfig { + return { + id: "pg-1", + name: "Postgres", + db_type: "postgres", + host: "127.0.0.1", + port: 5432, + username: "postgres", + password: "", + database: "app", + } as ConnectionConfig; +} + +function schemaNode(children: TreeNode[]): TreeNode { + return { + id: "pg-1:app:public", + label: "public", + type: "schema", + connectionId: "pg-1", + database: "app", + schema: "public", + isExpanded: true, + children, + }; +} + +function objectGroup(type: "group-views" | "group-procedures", key: string, child: TreeNode): TreeNode { + return { + id: `pg-1:app:public:${key}`, + label: type === "group-views" ? "tree.views" : "tree.procedures", + type, + connectionId: "pg-1", + database: "app", + schema: "public", + isExpanded: true, + objectCount: 7, + children: [child], + }; +} + +function installApiMocks(options?: { listTables?: ReturnType; listObjects?: ReturnType }) { + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + listInstalledAgents: vi.fn().mockResolvedValue([]), + listObjects: options?.listObjects ?? vi.fn().mockResolvedValue([]), + listTables: options?.listTables ?? vi.fn().mockResolvedValue([]), + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); +} + +async function createStore(root: TreeNode) { + const { useConnectionStore } = await import("@/stores/connectionStore"); + const { useSettingsStore } = await import("@/stores/settingsStore"); + const store = useConnectionStore(); + useSettingsStore().editorSettings.sidebarObjectDisplay = "grouped"; + const connection = postgresConnection(); + store.connections = [connection]; + store.connectedIds.add(connection.id); + store.treeNodes = [{ id: connection.id, label: connection.name, type: "connection", connectionId: connection.id, isExpanded: true, children: [root] }]; + return store; +} + +describe("connectionStore tree refresh state", () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); + installLocalStorage(); + setActivePinia(createPinia()); + }); + + it("preserves an object group count while rebuilding grouped placeholders", async () => { + installApiMocks(); + const oldView: TreeNode = { id: "pg-1:app:public:old_view", label: "old_view", type: "view", connectionId: "pg-1", database: "app", schema: "public" }; + const root = schemaNode([objectGroup("group-views", "__views", oldView)]); + root.children![0]!.isExpanded = false; + const store = await createStore(root); + + await store.loadTreeNodeChildren(root, { force: true }); + + expect(root.children?.find((child) => child.type === "group-views")?.objectCount).toBe(7); + }); + + it("rolls back tree data and loaded markers when an expanded child refresh fails", async () => { + const listTables = vi.fn().mockResolvedValue([{ name: "fresh_view", table_type: "VIEW", comment: null }]); + const listObjects = vi.fn().mockRejectedValue(new Error("metadata denied")); + installApiMocks({ listTables, listObjects }); + + const oldView: TreeNode = { id: "pg-1:app:public:old_view", label: "old_view", type: "view", connectionId: "pg-1", database: "app", schema: "public" }; + const oldProcedure: TreeNode = { id: "pg-1:app:public:old_procedure", label: "old_procedure", type: "procedure", connectionId: "pg-1", database: "app", schema: "public" }; + const root = schemaNode([objectGroup("group-views", "__views", oldView), objectGroup("group-procedures", "__procedures", oldProcedure)]); + const hiddenChildren: TreeNode[] = [{ id: "pg-1:app:public:hidden", label: "hidden", type: "view", connectionId: "pg-1", database: "app", schema: "public" }]; + root.hiddenChildren = hiddenChildren; + root.objectCount = 42; + const store = await createStore(root); + + await store.loadTreeNodeChildren(root, { force: true }); + const previousChildren = root.children; + const viewsGroupId = root.children!.find((child) => child.type === "group-views")!.id; + const proceduresGroupId = root.children!.find((child) => child.type === "group-procedures")!.id; + expect(store.isTreeNodeChildrenLoaded(root.id)).toBe(true); + expect(store.isTreeNodeChildrenLoaded(viewsGroupId)).toBe(false); + + await expect(store.refreshTreeNode(root)).rejects.toThrow("metadata denied"); + + expect(listTables).toHaveBeenCalledOnce(); + expect(listObjects).toHaveBeenCalledOnce(); + expect(root.children).toBe(previousChildren); + expect(root.hiddenChildren).toBe(hiddenChildren); + expect(root.objectCount).toBe(42); + expect(root.children?.find((child) => child.type === "group-views")?.children?.map((child) => child.label)).toEqual(["old_view"]); + expect(root.children?.find((child) => child.type === "group-procedures")?.children?.map((child) => child.label)).toEqual(["old_procedure"]); + expect(store.isTreeNodeChildrenLoaded(root.id)).toBe(true); + expect(store.isTreeNodeChildrenLoaded(viewsGroupId)).toBe(false); + expect(store.isTreeNodeChildrenLoaded(proceduresGroupId)).toBe(false); + }); +}); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 855a1b005..04b15b3f9 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -1244,14 +1244,19 @@ export const useConnectionStore = defineStore("connection", () => { const isExpanded = old.isExpanded; const isLoading = old.isLoading; const oldChildren = old.children; + const objectCount = child.objectCount ?? old.objectCount; Object.assign(old, child); old.isExpanded = isExpanded; old.isLoading = isLoading; old.children = oldChildren; + old.objectCount = objectCount; return old; } if (old?.isExpanded) { - return { ...child, isExpanded: true, children: old.children }; + return { ...child, isExpanded: true, children: old.children, objectCount: child.objectCount ?? old.objectCount }; + } + if (old && objectTypesForGroupNode(old.type)) { + return { ...child, objectCount: child.objectCount ?? old.objectCount }; } // Same-id collapsed database/schema shell replace (e.g. DDL → force loadDatabases): // prior confirmed-empty markers belong to the discarded instance and must not skip @@ -1304,6 +1309,13 @@ export const useConnectionStore = defineStore("connection", () => { const parent = findParentNode(treeNodes.value, nodeId); if (parent?.children) { parent.children = parent.children.filter((c) => c.id !== nodeId); + // Keep the group badge in sync with remaining real children (exclude load-more). + if (parent.objectCount != null) { + parent.objectCount = withoutLoadMoreNodes(parent.children).length; + } + } + if (parent?.hiddenChildren) { + parent.hiddenChildren = parent.hiddenChildren.filter((child) => child.id !== nodeId); } if (selectedTreeNodeId.value === nodeId) selectedTreeNodeId.value = null; selectedTreeNodeIds.value = selectedTreeNodeIds.value.filter((id) => id !== nodeId); @@ -5116,13 +5128,28 @@ export const useConnectionStore = defineStore("connection", () => { if (node.connectionId && !connectedIds.value.has(node.connectionId)) return; const expandedIds = collectExpandedNodeIds([node]); expandedIds.add(node.id); + const previousChildren = node.children; + const previousHiddenChildren = node.hiddenChildren; + const previousObjectCount = node.objectCount; + const previousLoadedIds = [...loadedTreeNodeChildrenIds.value].filter((id) => id === node.id || id.startsWith(`${node.id}:`)); + const previousConfirmedEmptyIds = [...confirmedEmptyTreeNodeIds.value].filter((id) => id === node.id || id.startsWith(`${node.id}:`)); await clearPersistedTreeCacheForNode(node); clearLoadedChildrenCache(node.id); if (node.type !== "connection-group") { node.children = []; } - await loadTreeNodeChildren(node, { force: true }); - await restoreExpandedChildren(node, expandedIds, { force: true }); + try { + await loadTreeNodeChildren(node, { force: true }); + await restoreExpandedChildren(node, expandedIds, { force: true }); + } catch (error) { + node.children = previousChildren; + node.hiddenChildren = previousHiddenChildren; + node.objectCount = previousObjectCount; + clearLoadedChildrenCache(node.id, { deletePersisted: false }); + for (const id of previousLoadedIds) loadedTreeNodeChildrenIds.value.add(id); + for (const id of previousConfirmedEmptyIds) confirmedEmptyTreeNodeIds.value.add(id); + throw error; + } } async function refreshTreeNodeForTableNameFilter(node: TreeNode, scopeKey: string, revision: number) { diff --git a/packages/app-tests/sidebarMutationRuntime.test.ts b/packages/app-tests/sidebarMutationRuntime.test.ts index c05008c7e..a09753bd3 100644 --- a/packages/app-tests/sidebarMutationRuntime.test.ts +++ b/packages/app-tests/sidebarMutationRuntime.test.ts @@ -6,6 +6,7 @@ const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTre const connectionMutationRuntime = readFileSync("apps/desktop/src/composables/useSidebarConnectionMutationRuntime.ts", "utf8"); const databaseSpecificMutationRuntime = readFileSync("apps/desktop/src/composables/useSidebarDatabaseSpecificMutationRuntime.ts", "utf8"); const tableMutationRuntime = readFileSync("apps/desktop/src/composables/useSidebarTableMutationRuntime.ts", "utf8"); +const localeSources = Object.fromEntries(["en", "es", "it", "ja", "ko", "pt-BR", "zh-CN", "zh-TW"].map((locale) => [locale, readFileSync(`apps/desktop/src/i18n/locales/${locale}.ts`, "utf8")])); function functionBody(name: string): string { const source = [runtimeHost, connectionMutationRuntime, databaseSpecificMutationRuntime, tableMutationRuntime].find((candidate) => candidate.includes(`function ${name}(`)); @@ -48,7 +49,6 @@ test("mutation families retain accepted targets, failures, and refresh work", () assert.match(dropMongoCollection, /onError:\s*toastMutationError/); assert.match(dropMongoCollection, /api\.mongoDropCollection/); - const redis = functionBody("confirmFlushRedisDb"); assert.match(redis, /updateRedisDbKeyStats/); assert.match(redis, /dbx-redis-db-flushed/); @@ -66,3 +66,29 @@ test("menu and dialog mutations resolve immutable accepted targets", () => { assert.match(runtimeHost, /routedRequest\.confirm = async \(\) => \{[\s\S]*?activateActionTarget\(target\)/); assert.match(runtimeHost, /createRoutedSidebarDialogController\(controller, \{[\s\S]*?wrapAction: \(action\) => \{[\s\S]*?activateActionTarget\(target\)/); }); + +test("object drops distinguish completed deletes from sidebar refresh failures", () => { + const dropObject = functionBody("confirmDropObject"); + assert.match(dropObject, /removePinnedTreeNodes\(\[node\]\)[\s\S]*?try \{[\s\S]*?await refreshTableList\(node\)[\s\S]*?catch \(error: any\) \{[\s\S]*?removeTreeNode\(node\.id\)[\s\S]*?objectDropRefreshFailed[\s\S]*?releaseActiveNodeReference\(\[node\.id\]\)/); + + const batchDrop = functionBody("confirmBatchDrop"); + const refreshBranch = batchDrop.slice(batchDrop.indexOf("const refreshScopes = new Map")); + const removeIndex = refreshBranch.indexOf("connectionStore.removeTreeNode(target.id)"); + const successIndex = refreshBranch.indexOf('toast(t("contextMenu.batchDropSuccess"'); + const closeIndex = refreshBranch.indexOf("showBatchDropConfirm.value = false", successIndex); + const refreshIndex = refreshBranch.indexOf("Promise.allSettled"); + const warningIndex = refreshBranch.indexOf('toast(t("contextMenu.objectDropRefreshFailed"', refreshIndex); + + assert.ok(removeIndex >= 0 && successIndex > removeIndex, "successful deletes must be reflected before the success toast"); + assert.ok(closeIndex > successIndex && refreshIndex > closeIndex, "the completed delete UI must close before metadata refreshes settle"); + assert.ok(warningIndex > refreshIndex, "refresh failures must be reported separately after deletion succeeds"); + assert.doesNotMatch(refreshBranch, /for \(const target of refreshScopes\.values\(\)\) \{\s*await refreshTableList/); +}); + +test("all locales define the object drop refresh warning with its message placeholder", () => { + for (const [locale, source] of Object.entries(localeSources)) { + const keys = source.match(/\bobjectDropRefreshFailed\s*:/g) ?? []; + assert.equal(keys.length, 1, `${locale} must define objectDropRefreshFailed exactly once`); + assert.match(source, /objectDropRefreshFailed\s*:\s*["'][^\n]*\{message\}/, `${locale} must preserve the message placeholder`); + } +});