feat(i18n): expand catalog runtime and developer tooling
- add Intl-backed locale definitions, formatters, catalog loaders, and runtime hooks\n- compile hashed production catalogs from package-scoped catalog sources\n- drive Lingui extraction and compilation directly from project JSON config\n- redesign the devtool with localized controls, draggable persistence, settings, navigation, copy actions, ANSI output, and fullscreen support\n- extend repository APIs, CLI coverage, documentation, and runtime tests
This commit is contained in:
@@ -1,8 +1,15 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import * as React from "react"
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { MessagePanel } from "./message-panel"
|
||||
import type { DevtoolMessage, MessageRepository } from "./types"
|
||||
@@ -10,14 +17,23 @@ import type { DevtoolMessage, MessageRepository } from "./types"
|
||||
const message: DevtoolMessage = {
|
||||
catalog: "messages",
|
||||
comments: ["Navigation title"],
|
||||
editable: true,
|
||||
id: "navigation.home",
|
||||
missing: true,
|
||||
obsolete: false,
|
||||
origins: [{ file: "src/navigation.tsx", line: 12 }],
|
||||
packageName: "@workspace/ui",
|
||||
source: "Home",
|
||||
sourceLocale: "en",
|
||||
translation: "",
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
Reflect.deleteProperty(window.navigator, "clipboard")
|
||||
})
|
||||
|
||||
describe("MessagePanel", () => {
|
||||
it("loads, filters, and saves messages through the repository", async () => {
|
||||
const updateMessage = vi.fn(async (input) => ({
|
||||
@@ -33,10 +49,13 @@ describe("MessagePanel", () => {
|
||||
render(<MessagePanel locale="zh-Hans" repository={repository} />)
|
||||
|
||||
expect(await screen.findByText("navigation.home")).toBeTruthy()
|
||||
expect(screen.getByRole("region", { name: "@workspace/ui" })).toBeTruthy()
|
||||
|
||||
const textarea = screen.getByLabelText(
|
||||
"navigation.home translation for zh-Hans"
|
||||
)
|
||||
expect(screen.queryByRole("button", { name: "Save" })).toBeNull()
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "首页" } })
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }))
|
||||
|
||||
@@ -50,11 +69,323 @@ describe("MessagePanel", () => {
|
||||
})
|
||||
expect(await screen.findByText("Saved")).toBeTruthy()
|
||||
|
||||
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)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Search messages"), {
|
||||
target: { value: "does not exist" },
|
||||
})
|
||||
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?.([])
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.getByText("No messages match the current filters.")
|
||||
await screen.findByText(
|
||||
"No messages are available yet. Extract the project to populate this catalog."
|
||||
)
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user