Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
5ee3736
fix(ux): customer-facing copy audit — hide implementation boundaries,…
seonghobae Aug 26, 2026
dce2203
test(workspace): cover singular timeline summary
seonghobae Aug 26, 2026
3ef661c
test(workspace): specify cardinal-safe timeline copy
seonghobae Aug 26, 2026
638ad78
fix(workspace): make timeline count grammar-safe
seonghobae Aug 26, 2026
010e747
test(i18n): expose localized source error regression
seonghobae Aug 26, 2026
716035f
test(i18n): cover localized analysis fallbacks
seonghobae Aug 26, 2026
375e41f
test(i18n): cover localized score errors
seonghobae Aug 26, 2026
13845c0
feat(i18n): add localized bridge guidance
seonghobae Aug 26, 2026
48ce16f
feat(i18n): add Korean bridge guidance
seonghobae Aug 26, 2026
25065ca
fix(i18n): localize score storage errors
seonghobae Aug 26, 2026
fcf6e23
feat(i18n): localize browser analysis progress
seonghobae Aug 26, 2026
9cdbb79
feat(i18n): localize Korean analysis progress
seonghobae Aug 26, 2026
f98f12c
fix(i18n): localize analysis bridge messages
seonghobae Aug 26, 2026
81fb426
test(i18n): cover localized safe-error branches
seonghobae Aug 26, 2026
f8b0fef
test(i18n): preserve distinct local failure guidance
seonghobae Aug 26, 2026
ff6d61f
feat(i18n): preserve specific analysis failure guidance
seonghobae Aug 26, 2026
41cca66
feat(i18n): preserve Korean analysis failure guidance
seonghobae Aug 26, 2026
537d267
fix(i18n): preserve specific safe analysis errors
seonghobae Aug 26, 2026
a37583a
test(app): isolate localized source error from pdfjs
seonghobae Aug 26, 2026
6f144f6
test(score): keep storage localization mock complete
seonghobae Aug 26, 2026
dba7f6b
test(app): align localized failure contracts
seonghobae Aug 26, 2026
6934dfa
test(analysis): reproduce admitted YouTube failure guidance
seonghobae Aug 26, 2026
8172227
fix(copy): guide admitted YouTube import failures
seonghobae Aug 26, 2026
65d4e6b
fix(copy): localize admitted YouTube failure action
seonghobae Aug 26, 2026
7cd1210
test(ux): reproduce pre-network YouTube guidance mismatch
seonghobae Aug 26, 2026
5ea3a4c
fix(ux): use format guidance before YouTube network admission
seonghobae Aug 26, 2026
a07d0d7
fix(copy): keep format rejection compatible with URL guidance tests
seonghobae Aug 26, 2026
042caed
test(copy): keep invalid YouTube guidance format-specific
seonghobae Aug 26, 2026
9912540
fix(copy): keep invalid YouTube guidance format-specific
seonghobae Aug 26, 2026
c1a2488
test(copy): label pending transcription for selected role
seonghobae Aug 26, 2026
ad82ef9
fix(copy): name pending transcription for selected role
seonghobae Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions apps/desktop/src/App.localized-source-error.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { App } from "./App";

// App mounts the Score surface, whose pdf.js bridge depends on browser canvas
// globals such as DOMMatrix that jsdom does not provide. This regression only
// exercises source-selection localization, so isolate that unrelated boundary
// exactly as the canonical App suite does.
vi.mock("./features/score/pdfjs", () => ({
configureScorePdfWorker: vi.fn(),
loadScorePdf: vi.fn(() => ({
promise: Promise.resolve({ numPages: 1, getPage: vi.fn() }),
destroy: vi.fn(() => Promise.resolve())
}))
}));

const originalLanguage = navigator.language;
const originalInternals = window.__TAURI_INTERNALS__;
const originalInvoke = window.__TAURI_INVOKE__;

function setNavigatorLanguage(language: string) {
Object.defineProperty(navigator, "language", {
configurable: true,
value: language
});
}

describe("App localized source errors", () => {
afterEach(() => {
setNavigatorLanguage(originalLanguage);
window.__TAURI_INTERNALS__ = originalInternals;
window.__TAURI_INVOKE__ = originalInvoke;
});

it("keeps the browser local-audio fallback in the selected Korean locale", async () => {
setNavigatorLanguage("ko-KR");
window.__TAURI_INTERNALS__ = undefined;
window.__TAURI_INVOKE__ = undefined;

render(<App />);
fireEvent.click(screen.getByRole("button", { name: "로컬 오디오 선택" }));

expect(await screen.findByRole("alert")).toHaveTextContent(
"분석을 시작하려면 WAV, MP3, FLAC 또는 M4A 파일을 선택하세요."
);
expect(screen.queryByText("Choose a WAV, MP3, FLAC, or M4A file to start analysis.")).toBeNull();
});
});
20 changes: 12 additions & 8 deletions apps/desktop/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ describe("App", () => {
expect(screen.getByText(/Song Timeline/i)).toBeTruthy();
});
expect(screen.getByText(/Roles & Harmony/i)).toBeTruthy();
expect(screen.getByText(/Stems/i)).toBeTruthy();
expect(screen.getByText("Stems")).toBeTruthy();
expect(screen.getByText(/Rehearsal Priorities/i)).toBeTruthy();
expect(screen.getByText(/Export Cue Sheet/i)).toBeTruthy();
});
Expand Down Expand Up @@ -453,16 +453,17 @@ describe("App", () => {
expect(screen.queryByText(/analysis failed during execution/i)).toBeNull();
});

it("preserves safe file-read failure copy from the intake bridge", async () => {
it("localizes safe file-read failure copy from the intake bridge", async () => {
tauriInvoke.mockRejectedValueOnce(new Error("Could not read the selected audio file."));

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));

await waitFor(() => {
expect(screen.getByText(/could not read the selected audio file/i)).toBeTruthy();
expect(screen.getByText(/selected audio file could not be read.*choose the file again/i)).toBeTruthy();
});
expect(screen.queryByText(/^Could not read the selected audio file\.$/i)).toBeNull();
expect(screen.queryByText(/analysis failed during execution/i)).toBeNull();
});

Expand Down Expand Up @@ -644,7 +645,7 @@ describe("App", () => {
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toMatch(/analysis could not start/i);
});
expect(screen.getAllByRole("status").some((status) => /analysis failed during execution/i.test(status.textContent ?? ""))).toBe(true);
expect(screen.getAllByRole("status").some((status) => /stopped partway through/i.test(status.textContent ?? ""))).toBe(true);
});

it("holds a terminal progress value immediately for pushed failed statuses", async () => {
Expand Down Expand Up @@ -1150,7 +1151,7 @@ describe("App", () => {
expect(input).not.toHaveAttribute("aria-describedby");
});

it("handles YouTube import failure with a message", async () => {
it("redacts bridge detail when YouTube import fails after URL admission", async () => {
tauriInvoke.mockRejectedValueOnce(new Error("This video is age restricted."));

render(<App />);
Expand All @@ -1163,14 +1164,15 @@ describe("App", () => {

await waitFor(() => {
const alert = screen.getByRole("alert");
expect(alert).toHaveTextContent(/This video is age restricted/i);
expect(alert).toHaveTextContent(/Failed to import YouTube URL/i);
expect(alert).not.toHaveTextContent(/This video is age restricted/i);
expect(alert).toHaveAttribute("id", "selection-error");
expect(input).toHaveAttribute("aria-invalid", "true");
expect(input).toHaveAttribute("aria-describedby", alert.id);
});
});

it("handles generic exception during YouTube import", async () => {
it("redacts generic bridge exceptions during YouTube import", async () => {
tauriInvoke.mockRejectedValueOnce(new Error("Network Error"));

render(<App />);
Expand All @@ -1182,7 +1184,9 @@ describe("App", () => {
fireEvent.click(button);

await waitFor(() => {
expect(screen.getByText(/Network Error/i)).toBeTruthy();
const alert = screen.getByRole("alert");
expect(alert).toHaveTextContent(/Failed to import YouTube URL/i);
expect(alert).not.toHaveTextContent(/Network Error/i);
});
});

Expand Down
11 changes: 8 additions & 3 deletions apps/desktop/src/App.tsx

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Localized import-failure copy survives safeErrorDetail redaction

The bridge-failure branch wraps youtubeImportFailed in safeErrorDetail (App.tsx:461), capped at 220 chars. Both locale strings are shorter and contain no URL/path/secret patterns, so redaction and truncation leave them intact.

(Refers to this code)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,11 @@ function sectionCountDetail(t: ReturnType<typeof createTranslator>, sectionCount
function ConfidenceMetric({ song, t }: { song: RehearsalSong | null; t: ReturnType<typeof createTranslator> }) {
const sectionCount = song?.sections.length ?? 0;
const confidenceOrder = { high: 3, medium: 2, low: 1 } as const;
const confidenceShortKeys = {
low: "confidenceShortLow",
medium: "confidenceShortMedium",
high: "confidenceShortHigh"
} as const satisfies Record<RehearsalSong["sections"][number]["confidence"]["level"], TranslationKey>;

// Performance: Avoid O(N) array scan with .reduce() to find minimum confidence.
// Instead use a for loop that can early exit (O(K)) as soon as the lowest bound ("low") is hit.
Expand All @@ -224,7 +229,7 @@ function ConfidenceMetric({ song, t }: { song: RehearsalSong | null; t: ReturnTy
}
}
}
const confidence = lowestConfidence ? `${lowestConfidence[0].toUpperCase()}${lowestConfidence.slice(1)}` : t("metricConfidenceReady");
const confidence = lowestConfidence ? t(confidenceShortKeys[lowestConfidence]) : t("metricConfidenceReady");
const detail = sectionCountDetail(t, sectionCount);

return (
Expand Down Expand Up @@ -435,13 +440,13 @@ export function App() {
setSelectionErrorSource(null);
const normalizedUrl = youtubeUrl.trim();
if (!normalizedUrl) {
setSelectionError(t("youtubeImportFailed"));
setSelectionError(t("youtubeLinkGuidance"));
setSelectionErrorSource("youtube");
return;
}

if (!isSupportedYoutubeUrl(normalizedUrl)) {
setSelectionError(t("youtubeImportFailed"));
setSelectionError(t("youtubeLinkGuidance"));
setSelectionErrorSource("youtube");
return;
}
Comment on lines 442 to 452

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Stale tests assert removed YouTube rejection copy

handleImportYoutube now shows youtubeLinkGuidance ("Use a standard YouTube video link...") for empty and unsupported URLs. Six existing tests still assert "Failed to import YouTube URL." (App.test.tsx:1204, 1216, 1228, 1240, 1253, 1266). That text no longer renders, so each getByText throws and the tests fail.

Prompt for agents
handleImportYoutube in apps/desktop/src/App.tsx now sets t("youtubeLinkGuidance") for the empty-URL branch and the unsupported-URL branch, replacing the old t("youtubeImportFailed") text. Six tests in apps/desktop/src/App.test.tsx still assert the old copy via screen.getByText(/Failed to import YouTube URL./i): 'rejects empty YouTube URL', 'rejects malformed YouTube URL', 'rejects non-http YouTube URL', 'rejects non-allowlisted YouTube URL intake before invoking the bridge', 'rejects downgraded YouTube URL intake before invoking the bridge', and 'rejects duplicate YouTube video parameters even when one is blank'. Update these assertions to expect the pre-import guidance copy ("Use a standard YouTube video link (youtube.com/watch or youtu.be).") so they match the new behavior, consistent with the new App.youtube-url-guidance.test.tsx.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Expand Down
48 changes: 48 additions & 0 deletions apps/desktop/src/App.youtube-url-guidance.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { App } from "./App";

// App mounts the Score surface, whose pdf.js bridge depends on browser canvas
// globals such as DOMMatrix that jsdom does not provide. This regression only
// exercises pre-network YouTube URL admission guidance.
vi.mock("./features/score/pdfjs", () => ({
configureScorePdfWorker: vi.fn(),
loadScorePdf: vi.fn(() => ({
promise: Promise.resolve({ numPages: 1, getPage: vi.fn() }),
destroy: vi.fn(() => Promise.resolve())
}))
}));

const originalLanguage = navigator.language;
const originalInternals = window.__TAURI_INTERNALS__;
const originalInvoke = window.__TAURI_INVOKE__;

function setNavigatorLanguage(language: string) {
Object.defineProperty(navigator, "language", {
configurable: true,
value: language
});
}

describe("App YouTube URL admission guidance", () => {
afterEach(() => {
setNavigatorLanguage(originalLanguage);
window.__TAURI_INTERNALS__ = originalInternals;
window.__TAURI_INVOKE__ = originalInvoke;
});

it("uses format guidance for a URL rejected before any import attempt", async () => {
setNavigatorLanguage("en-US");
window.__TAURI_INTERNALS__ = undefined;
window.__TAURI_INVOKE__ = undefined;

render(<App />);
const input = screen.getByRole("textbox", { name: /YouTube URL/i });
fireEvent.change(input, { target: { value: "https://example.com/watch?v=abc123DEF45" } });
fireEvent.click(screen.getByRole("button", { name: /Import YouTube/i }));

const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Use a standard YouTube video link (youtube.com/watch or youtu.be).");
expect(alert).not.toHaveTextContent(/check your connection/i);
});
});
8 changes: 5 additions & 3 deletions apps/desktop/src/features/score/ScoreView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ vi.mock("../../i18n", () => ({
scoreAttachFailed: "Could not attach the score PDF.",
scoreReadFailed: "Could not open the score PDF.",
scoreRemoveFailed: "Could not remove the score PDF.",
scoreDesktopOnly: "Score PDFs are only available in the desktop app.",
scoreInvalidResponse: "The score could not be prepared. Try adding it again.",
scoreRequiresProject: "Scores attach to the active analysis project."
})[key] ?? key,
detectPreferredLocale: () => "en"
Expand Down Expand Up @@ -153,7 +155,7 @@ describe("ScoreView", () => {

fireEvent.click(screen.getByRole("button", { name: "Add score" }));

expect(await screen.findByRole("alert")).toHaveTextContent("Invalid score bridge response");
expect(await screen.findByRole("alert")).toHaveTextContent("The score could not be prepared. Try adding it again.");
});

it("opens an existing attachment through the read command", async () => {
Expand Down Expand Up @@ -216,7 +218,7 @@ describe("ScoreView", () => {
fireEvent.click(screen.getByRole("button", { name: "Open score: opener.pdf" }));

expect(await screen.findByRole("alert")).toHaveTextContent(
"Could not open the score PDF. Invalid score bridge response"
"Could not open the score PDF. The score could not be prepared. Try adding it again."
);
});

Expand Down Expand Up @@ -289,7 +291,7 @@ describe("ScoreView", () => {

fireEvent.click(screen.getByRole("button", { name: "Remove: opener.pdf" }));

expect(await screen.findByRole("alert")).toHaveTextContent("Invalid score bridge response");
expect(await screen.findByRole("alert")).toHaveTextContent("The score could not be prepared. Try adding it again.");
});

it("fails closed when no desktop bridge is available", async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { afterEach, describe, expect, it } from "vitest";
import { attachScorePdf } from "./scoreStorage";

const originalLanguage = navigator.language;
const originalInternals = window.__TAURI_INTERNALS__;
const originalInvoke = window.__TAURI_INVOKE__;

function setNavigatorLanguage(language: string) {
Object.defineProperty(navigator, "language", {
configurable: true,
value: language
});
}

describe("score storage buyer-visible error localization", () => {
afterEach(() => {
setNavigatorLanguage(originalLanguage);
window.__TAURI_INTERNALS__ = originalInternals;
window.__TAURI_INVOKE__ = originalInvoke;
});

it("keeps the browser-only score message in Korean", async () => {
setNavigatorLanguage("ko-KR");
window.__TAURI_INTERNALS__ = undefined;
window.__TAURI_INVOKE__ = undefined;

await expect(attachScorePdf("project-1", "song-1")).rejects.toThrow(
"악보 PDF는 BandScope 데스크톱 앱에서만 사용할 수 있습니다."
);
});

it("keeps an invalid bridge response in Korean", async () => {
setNavigatorLanguage("ko-KR");
window.__TAURI_INTERNALS__ = undefined;
window.__TAURI_INVOKE__ = async () => ({});

await expect(attachScorePdf("project-1", "song-1")).rejects.toThrow(
"악보를 준비할 수 없습니다. 다시 추가해 주세요."
);
});
});
20 changes: 13 additions & 7 deletions apps/desktop/src/features/score/scoreStorage.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { invoke } from "@tauri-apps/api/core";
import type { ScoreAttachment } from "@bandscope/shared-types";
import { createTranslator, detectPreferredLocale, type TranslationKey } from "../../i18n";

type TauriInvoke = (command: string, args?: Record<string, unknown>) => Promise<unknown>;

Expand All @@ -14,8 +15,13 @@ type TauriBridgeWindow = Window & {
*/
export type ScoreAttachResult = ScoreAttachment & { fileSizeBytes: number };

const BRIDGE_UNAVAILABLE_MESSAGE = "Score PDFs are only available in the desktop app.";
const INVALID_RESPONSE_MESSAGE = "Invalid score bridge response";
const BRIDGE_UNAVAILABLE_KEY = "scoreDesktopOnly" satisfies TranslationKey;
const INVALID_RESPONSE_KEY = "scoreInvalidResponse" satisfies TranslationKey;

/** Resolve a buyer-visible score-storage message in the currently selected locale. */
function scoreMessage(key: TranslationKey): string {
return createTranslator(detectPreferredLocale())(key);
}

/**
* Resolve the desktop invoke bridge following the same detection rules as
Expand All @@ -41,12 +47,12 @@ function getInvoke(): TauriInvoke | null {

/**
* Invoke a score storage command on the desktop bridge, failing closed with
* a stable error when no bridge is available (browser preview builds).
* a stable localized error when no bridge is available (browser preview builds).
*/
async function invokeScoreCommand(command: string, args: Record<string, unknown>): Promise<unknown> {
const invokeCommand = getInvoke();
if (!invokeCommand) {
throw new Error(BRIDGE_UNAVAILABLE_MESSAGE);
throw new Error(scoreMessage(BRIDGE_UNAVAILABLE_KEY));
}

return invokeCommand(command, args);
Expand All @@ -67,7 +73,7 @@ export async function attachScorePdf(projectId: string, songId: string): Promise
typeof (response as Record<string, unknown>).fileName !== "string" ||
typeof (response as Record<string, unknown>).fileSizeBytes !== "number"
) {
throw new Error(INVALID_RESPONSE_MESSAGE);
throw new Error(scoreMessage(INVALID_RESPONSE_KEY));
}

const payload = response as { scoreId: string; fileName: string; fileSizeBytes: number };
Expand Down Expand Up @@ -95,7 +101,7 @@ export async function readScorePdf(projectId: string, scoreId: string): Promise<
return Uint8Array.from(response as number[]);
}

throw new Error(INVALID_RESPONSE_MESSAGE);
throw new Error(scoreMessage(INVALID_RESPONSE_KEY));
}

/**
Expand All @@ -105,7 +111,7 @@ export async function readScorePdf(projectId: string, scoreId: string): Promise<
export async function removeScorePdf(projectId: string, scoreId: string): Promise<boolean> {
const response = await invokeScoreCommand("remove_score_pdf", { projectId, scoreId });
if (typeof response !== "boolean") {
throw new Error(INVALID_RESPONSE_MESSAGE);
throw new Error(scoreMessage(INVALID_RESPONSE_KEY));
}

return response;
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/features/workspace/ConfidenceBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export function ConfidenceBadge({ level }: ConfidenceBadgeProps) {
<Badge
variant="outline"
className={`px-1.5 py-0 h-5 text-[0.65rem] font-bold uppercase tracking-wider ${colorClass}`}
title={`Confidence: ${level}`}
title={`${t("roleConfidence")}: ${label}`}
>
{label}
</Badge>
Expand Down
Loading
Loading