2026-07-29 21:23:00 +08:00
|
|
|
// @vitest-environment jsdom
|
|
|
|
|
|
|
|
|
|
import * as React from "react"
|
2026-07-30 14:47:42 +08:00
|
|
|
import {
|
|
|
|
|
act,
|
|
|
|
|
cleanup,
|
|
|
|
|
fireEvent,
|
|
|
|
|
render,
|
|
|
|
|
screen,
|
|
|
|
|
waitFor,
|
|
|
|
|
} from "@testing-library/react"
|
|
|
|
|
import { afterEach, describe, expect, it, vi } from "vitest"
|
2026-07-29 21:23:00 +08:00
|
|
|
|
|
|
|
|
import { MessagePanel } from "./message-panel"
|
|
|
|
|
import type { DevtoolMessage, MessageRepository } from "./types"
|
|
|
|
|
|
|
|
|
|
const message: DevtoolMessage = {
|
|
|
|
|
catalog: "messages",
|
|
|
|
|
comments: ["Navigation title"],
|
2026-07-30 14:47:42 +08:00
|
|
|
editable: true,
|
2026-07-29 21:23:00 +08:00
|
|
|
id: "navigation.home",
|
|
|
|
|
missing: true,
|
|
|
|
|
obsolete: false,
|
|
|
|
|
origins: [{ file: "src/navigation.tsx", line: 12 }],
|
2026-07-30 14:47:42 +08:00
|
|
|
packageName: "@workspace/ui",
|
2026-07-29 21:23:00 +08:00
|
|
|
source: "Home",
|
2026-07-30 14:47:42 +08:00
|
|
|
sourceLocale: "en",
|
2026-07-29 21:23:00 +08:00
|
|
|
translation: "",
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-30 14:47:42 +08:00
|
|
|
afterEach(() => {
|
|
|
|
|
cleanup()
|
|
|
|
|
vi.restoreAllMocks()
|
|
|
|
|
Reflect.deleteProperty(window.navigator, "clipboard")
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-29 21:23:00 +08:00
|
|
|
describe("MessagePanel", () => {
|
|
|
|
|
it("loads, filters, and saves messages through the repository", async () => {
|
|
|
|
|
const updateMessage = vi.fn(async (input) => ({
|
|
|
|
|
...message,
|
|
|
|
|
missing: false,
|
|
|
|
|
translation: input.translation,
|
|
|
|
|
}))
|
|
|
|
|
const repository: MessageRepository = {
|
|
|
|
|
getMessages: vi.fn(async () => [message]),
|
|
|
|
|
updateMessage,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
render(<MessagePanel locale="zh-Hans" repository={repository} />)
|
|
|
|
|
|
|
|
|
|
expect(await screen.findByText("navigation.home")).toBeTruthy()
|
2026-07-30 14:47:42 +08:00
|
|
|
expect(screen.getByRole("region", { name: "@workspace/ui" })).toBeTruthy()
|
2026-07-29 21:23:00 +08:00
|
|
|
|
|
|
|
|
const textarea = screen.getByLabelText(
|
|
|
|
|
"navigation.home translation for zh-Hans"
|
|
|
|
|
)
|
2026-07-30 14:47:42 +08:00
|
|
|
expect(screen.queryByRole("button", { name: "Save" })).toBeNull()
|
|
|
|
|
|
2026-07-29 21:23:00 +08:00
|
|
|
fireEvent.change(textarea, { target: { value: "首页" } })
|
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Save" }))
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(updateMessage).toHaveBeenCalledWith({
|
|
|
|
|
catalog: "messages",
|
|
|
|
|
id: "navigation.home",
|
|
|
|
|
locale: "zh-Hans",
|
|
|
|
|
translation: "首页",
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
expect(await screen.findByText("Saved")).toBeTruthy()
|
|
|
|
|
|
2026-07-30 14:47:42 +08:00
|
|
|
const missingOnly = screen.getByRole("checkbox", {
|
|
|
|
|
name: "Missing only",
|
|
|
|
|
})
|
|
|
|
|
const checkboxIcon = missingOnly.nextElementSibling
|
|
|
|
|
|
|
|
|
|
expect(checkboxIcon?.querySelector("svg")).toBeTruthy()
|
|
|
|
|
fireEvent.click(missingOnly)
|
|
|
|
|
expect(missingOnly).toHaveProperty("checked", true)
|
|
|
|
|
|
2026-07-29 21:23:00 +08:00
|
|
|
fireEvent.change(screen.getByPlaceholderText("Search messages"), {
|
|
|
|
|
target: { value: "does not exist" },
|
|
|
|
|
})
|
2026-07-30 14:47:42 +08:00
|
|
|
expect(screen.getByText("No messages match your search.")).toBeTruthy()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("keeps the current content visible during a background refresh", async () => {
|
|
|
|
|
let resolveRefresh:
|
|
|
|
|
((messages: readonly DevtoolMessage[]) => void) | undefined
|
|
|
|
|
const repository: MessageRepository = {
|
|
|
|
|
getMessages: vi
|
|
|
|
|
.fn()
|
|
|
|
|
.mockResolvedValueOnce([message])
|
|
|
|
|
.mockImplementationOnce(
|
|
|
|
|
() =>
|
|
|
|
|
new Promise<readonly DevtoolMessage[]>((resolve) => {
|
|
|
|
|
resolveRefresh = resolve
|
|
|
|
|
})
|
|
|
|
|
),
|
|
|
|
|
updateMessage: vi.fn(),
|
|
|
|
|
}
|
|
|
|
|
const view = render(
|
|
|
|
|
<MessagePanel locale="zh-Hans" refreshToken={0} repository={repository} />
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
expect(await screen.findByText("navigation.home")).toBeTruthy()
|
|
|
|
|
fireEvent.change(screen.getByPlaceholderText("Search messages"), {
|
|
|
|
|
target: { value: "home" },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
view.rerender(
|
|
|
|
|
<MessagePanel locale="zh-Hans" refreshToken={1} repository={repository} />
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
expect(screen.queryByText("Loading zh-Hans…")).toBeNull()
|
|
|
|
|
expect(screen.getByText("navigation.home")).toBeTruthy()
|
|
|
|
|
expect(screen.getByPlaceholderText("Search messages")).toHaveProperty(
|
|
|
|
|
"value",
|
|
|
|
|
"home"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
resolveRefresh?.([])
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-29 21:23:00 +08:00
|
|
|
expect(
|
2026-07-30 14:47:42 +08:00
|
|
|
await screen.findByText(
|
|
|
|
|
"No messages are available yet. Extract the project to populate this catalog."
|
|
|
|
|
)
|
2026-07-29 21:23:00 +08:00
|
|
|
).toBeTruthy()
|
|
|
|
|
})
|
2026-07-30 14:47:42 +08:00
|
|
|
|
|
|
|
|
it("collapses a package without discarding an unsaved translation", async () => {
|
|
|
|
|
const repository: MessageRepository = {
|
|
|
|
|
getMessages: vi.fn(async () => [message]),
|
|
|
|
|
updateMessage: vi.fn(),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
render(<MessagePanel locale="zh-Hans" repository={repository} />)
|
|
|
|
|
|
|
|
|
|
const textarea = await screen.findByLabelText(
|
|
|
|
|
"navigation.home translation for zh-Hans"
|
|
|
|
|
)
|
|
|
|
|
fireEvent.change(textarea, { target: { value: "未保存的首页" } })
|
|
|
|
|
|
|
|
|
|
const collapseButton = screen.getByRole("button", {
|
|
|
|
|
name: "Collapse @workspace/ui",
|
|
|
|
|
})
|
|
|
|
|
const packageContent = screen
|
|
|
|
|
.getByRole("region", { name: "@workspace/ui" })
|
|
|
|
|
.querySelector(".i18n-message-package__messages")
|
|
|
|
|
fireEvent.click(collapseButton)
|
|
|
|
|
|
|
|
|
|
expect(collapseButton.getAttribute("aria-expanded")).toBe("false")
|
|
|
|
|
expect(textarea).toHaveProperty("value", "未保存的首页")
|
|
|
|
|
expect(packageContent).toHaveProperty("hidden", true)
|
|
|
|
|
|
|
|
|
|
fireEvent.click(
|
|
|
|
|
screen.getByRole("button", {
|
|
|
|
|
name: "Expand @workspace/ui",
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
expect(textarea).toHaveProperty("value", "未保存的首页")
|
|
|
|
|
expect(packageContent).toHaveProperty("hidden", false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("shows the application package even when it has no messages", async () => {
|
|
|
|
|
const repository: MessageRepository = {
|
|
|
|
|
getMessages: vi.fn(async () => [message]),
|
|
|
|
|
getPackages: vi.fn(async () => [
|
|
|
|
|
{
|
|
|
|
|
kind: "application" as const,
|
|
|
|
|
name: "web",
|
|
|
|
|
},
|
|
|
|
|
]),
|
|
|
|
|
updateMessage: vi.fn(),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
render(<MessagePanel locale="zh-Hans" repository={repository} />)
|
|
|
|
|
|
|
|
|
|
expect(await screen.findByRole("region", { name: "web" })).toBeTruthy()
|
|
|
|
|
expect(
|
|
|
|
|
screen
|
|
|
|
|
.getAllByRole("region")
|
|
|
|
|
.map((region) => region.getAttribute("aria-label"))
|
|
|
|
|
.filter((label) => label !== "Message editor")
|
|
|
|
|
).toEqual(["web", "@workspace/ui"])
|
|
|
|
|
expect(
|
|
|
|
|
screen.getByText("No messages were found in this package.")
|
|
|
|
|
).toBeTruthy()
|
|
|
|
|
expect(screen.getByRole("region", { name: "web" }).textContent).toContain(
|
|
|
|
|
"0 messages"
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("builds an ID tree and preserves visited editor drafts", async () => {
|
|
|
|
|
const activeMessage: DevtoolMessage = {
|
|
|
|
|
...message,
|
|
|
|
|
id: "blocks.appearance.active",
|
|
|
|
|
source: "Active",
|
|
|
|
|
}
|
|
|
|
|
const darkMessage: DevtoolMessage = {
|
|
|
|
|
...message,
|
|
|
|
|
id: "blocks.theme.dark",
|
|
|
|
|
source: "Dark",
|
|
|
|
|
}
|
|
|
|
|
const repository: MessageRepository = {
|
|
|
|
|
getMessages: vi.fn(async () => [activeMessage, darkMessage]),
|
|
|
|
|
updateMessage: vi.fn(),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
render(<MessagePanel locale="zh-Hans" repository={repository} />)
|
|
|
|
|
|
|
|
|
|
const activeEditor = await screen.findByLabelText(
|
|
|
|
|
"blocks.appearance.active translation for zh-Hans"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
expect(screen.getByTitle("blocks")).toBeTruthy()
|
|
|
|
|
expect(screen.getByTitle("blocks.appearance")).toBeTruthy()
|
|
|
|
|
expect(screen.getByTitle("blocks.theme")).toBeTruthy()
|
|
|
|
|
expect(screen.queryByTitle("blocks.appearance.active")).toBeNull()
|
|
|
|
|
expect(screen.queryByTitle("blocks.theme.dark")).toBeNull()
|
|
|
|
|
fireEvent.change(activeEditor, { target: { value: "使用中" } })
|
|
|
|
|
fireEvent.click(screen.getByTitle("blocks.theme"))
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
await screen.findByLabelText("blocks.theme.dark translation for zh-Hans")
|
|
|
|
|
).toBeTruthy()
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getByTitle("blocks.appearance"))
|
|
|
|
|
expect(
|
|
|
|
|
await screen.findByLabelText(
|
|
|
|
|
"blocks.appearance.active translation for zh-Hans"
|
|
|
|
|
)
|
|
|
|
|
).toHaveProperty("value", "使用中")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("uses the final ID segment as right-pane content instead of a tree leaf", async () => {
|
|
|
|
|
const parentMessage: DevtoolMessage = {
|
|
|
|
|
...message,
|
|
|
|
|
id: "a.b",
|
|
|
|
|
source: "B",
|
|
|
|
|
}
|
|
|
|
|
const childMessage: DevtoolMessage = {
|
|
|
|
|
...message,
|
|
|
|
|
id: "a.b.c",
|
|
|
|
|
source: "C",
|
|
|
|
|
}
|
|
|
|
|
const repository: MessageRepository = {
|
|
|
|
|
getMessages: vi.fn(async () => [parentMessage, childMessage]),
|
|
|
|
|
updateMessage: vi.fn(),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
render(<MessagePanel locale="zh-Hans" repository={repository} />)
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
await screen.findByLabelText("a.b translation for zh-Hans")
|
|
|
|
|
).toBeTruthy()
|
|
|
|
|
expect(screen.getByTitle("a")).toBeTruthy()
|
|
|
|
|
expect(screen.getByTitle("a.b")).toBeTruthy()
|
|
|
|
|
expect(screen.queryByTitle("a.b.c")).toBeNull()
|
|
|
|
|
expect(screen.queryByLabelText("a.b.c translation for zh-Hans")).toBeNull()
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getByTitle("a.b"))
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
await screen.findByLabelText("a.b.c translation for zh-Hans")
|
|
|
|
|
).toBeTruthy()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("resets an unsaved translation to the repository value", async () => {
|
|
|
|
|
const updateMessage = vi.fn()
|
|
|
|
|
const repository: MessageRepository = {
|
|
|
|
|
getMessages: vi.fn(async () => [message]),
|
|
|
|
|
updateMessage,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
render(<MessagePanel locale="zh-Hans" repository={repository} />)
|
|
|
|
|
|
|
|
|
|
const textarea = await screen.findByLabelText(
|
|
|
|
|
"navigation.home translation for zh-Hans"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
expect(screen.getByText("src/navigation.tsx:12")).toBeTruthy()
|
|
|
|
|
fireEvent.change(textarea, { target: { value: "未保存的首页" } })
|
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Reset" }))
|
|
|
|
|
|
|
|
|
|
expect(textarea).toHaveProperty("value", "")
|
|
|
|
|
expect(screen.queryByRole("button", { name: "Save" })).toBeNull()
|
|
|
|
|
expect(updateMessage).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("keeps application source messages read-only", async () => {
|
|
|
|
|
const updateMessage = vi.fn()
|
|
|
|
|
const repository: MessageRepository = {
|
|
|
|
|
getMessages: vi.fn(async () => [
|
|
|
|
|
{
|
|
|
|
|
...message,
|
|
|
|
|
editable: false,
|
|
|
|
|
missing: false,
|
|
|
|
|
packageName: "test-app",
|
|
|
|
|
translation: "Home",
|
|
|
|
|
},
|
|
|
|
|
]),
|
|
|
|
|
getPackages: vi.fn(async () => [
|
|
|
|
|
{ kind: "application" as const, name: "test-app" },
|
|
|
|
|
]),
|
|
|
|
|
updateMessage,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
render(<MessagePanel locale="en" repository={repository} />)
|
|
|
|
|
|
|
|
|
|
const textarea = await screen.findByLabelText(
|
|
|
|
|
"navigation.home translation for en"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
expect(textarea).toHaveProperty("readOnly", true)
|
|
|
|
|
fireEvent.change(textarea, { target: { value: "Customized home" } })
|
|
|
|
|
|
|
|
|
|
expect(textarea).toHaveProperty("value", "Home")
|
|
|
|
|
expect(screen.queryByRole("button", { name: "Save" })).toBeNull()
|
|
|
|
|
expect(updateMessage).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("copies an explicitly selected message value", async () => {
|
|
|
|
|
const writeText = vi.fn(async () => undefined)
|
|
|
|
|
Object.defineProperty(window.navigator, "clipboard", {
|
|
|
|
|
configurable: true,
|
|
|
|
|
value: { writeText },
|
|
|
|
|
})
|
|
|
|
|
const repository: MessageRepository = {
|
|
|
|
|
getMessages: vi.fn(async () => [message]),
|
|
|
|
|
updateMessage: vi.fn(),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
render(<MessagePanel locale="zh-Hans" repository={repository} />)
|
|
|
|
|
|
|
|
|
|
await screen.findByText("navigation.home")
|
|
|
|
|
fireEvent.click(
|
|
|
|
|
screen.getByRole("button", {
|
|
|
|
|
name: "Choose content to copy",
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
screen.getByRole("menuitem", { name: "Copy current translation" })
|
|
|
|
|
).toHaveProperty("disabled", true)
|
|
|
|
|
expect(
|
|
|
|
|
screen.getByRole("menuitem", {
|
|
|
|
|
name: /Copy source location/,
|
|
|
|
|
})
|
|
|
|
|
).toBeTruthy()
|
|
|
|
|
|
|
|
|
|
fireEvent.click(
|
|
|
|
|
screen.getByRole("menuitem", {
|
|
|
|
|
name: "Copy message ID",
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(writeText).toHaveBeenCalledWith("navigation.home")
|
|
|
|
|
})
|
|
|
|
|
expect(screen.getByRole("button", { name: "Copied" })).toBeTruthy()
|
|
|
|
|
expect(screen.queryByRole("menu")).toBeNull()
|
|
|
|
|
expect(screen.getByRole("status").textContent).toContain("Copied")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("distinguishes an empty catalog from a fully translated catalog", async () => {
|
|
|
|
|
const translatedMessage = {
|
|
|
|
|
...message,
|
|
|
|
|
missing: false,
|
|
|
|
|
translation: "首页",
|
|
|
|
|
}
|
|
|
|
|
const repository: MessageRepository = {
|
|
|
|
|
getMessages: vi.fn(async () => [translatedMessage]),
|
|
|
|
|
updateMessage: vi.fn(),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
render(<MessagePanel locale="zh-Hans" repository={repository} />)
|
|
|
|
|
|
|
|
|
|
expect(await screen.findByText("navigation.home")).toBeTruthy()
|
|
|
|
|
fireEvent.click(
|
|
|
|
|
screen.getByRole("checkbox", {
|
|
|
|
|
name: "Missing only",
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
expect(screen.getByText("All messages have translations.")).toBeTruthy()
|
|
|
|
|
})
|
2026-07-29 21:23:00 +08:00
|
|
|
})
|