Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
69 changes: 69 additions & 0 deletions src/components/ai-edition/AudioTrackPane.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";

vi.mock("@/contexts/I18nContext", () => ({
useScopedT: (scope: string) => (key: string) => `${scope}.${key}`,
}));

import { AudioTrackPane } from "./RightPanes";

describe("AudioTrackPane", () => {
const createTl = () => ({
selectedAudioTrackId: "track_1",
audioTracks: [
{
id: "track_1",
clipId: "clip_1",
assetId: "asset_audio_1",
startSec: 0,
durationSec: 5,
gainDb: 0,
fadeInMs: 0,
fadeOutMs: 0,
offsetSec: 0,
},
],
assets: [
{
id: "asset_audio_1",
kind: "audio" as const,
label: "voice.mp3",
originalPath: "/path/voice.mp3",
durationSec: 5,
},
],
clearSelection: vi.fn(),
selectAudioTrack: vi.fn(),
setAudioTrackGain: vi.fn(),
setAudioTrackFade: vi.fn(),
removeAudioTrack: vi.fn(),
});

it("renders close button and closes audio track by default", () => {
const tl = createTl();
render(<AudioTrackPane tl={tl as never} />);

const closeBtn = screen.getByRole("button", { name: "common.actions.close" });
expect(closeBtn).toBeInTheDocument();
const header = closeBtn.closest("header");
expect(header).toHaveStyle({ paddingRight: "var(--sp-4)" });
const svg = closeBtn.querySelector("svg");
expect(svg?.classList.contains("lucide-x")).toBe(true);

fireEvent.click(closeBtn);
expect(tl.clearSelection).toHaveBeenCalledTimes(1);
});

it("calls custom onClose if provided", () => {
const tl = createTl();
const onClose = vi.fn();
render(<AudioTrackPane tl={tl as never} onClose={onClose} />);

const closeBtn = screen.getByRole("button", { name: "common.actions.close" });
fireEvent.click(closeBtn);
expect(onClose).toHaveBeenCalledTimes(1);
expect(tl.selectAudioTrack).not.toHaveBeenCalled();
});
});
69 changes: 53 additions & 16 deletions src/components/ai-edition/CaptionsPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
// translation is stored beside the transcript, keyed by segment id, and picking
// "Original" goes straight back to the SSOT text.

import { Captions as CaptionsIcon, Languages, Loader2, Trash2 } from "lucide-react";
import { Captions as CaptionsIcon, Languages, Loader2, Trash2, X } from "lucide-react";
import { useMemo, useState } from "react";
import { useScopedT } from "@/contexts/I18nContext";
import type { CaptionAnchorH, CaptionAnchorV } from "@/lib/ai-edition/captions";
Expand Down Expand Up @@ -73,9 +73,10 @@ const TRANSLATION_LANGUAGES: ReadonlyArray<{ code: string; label: string }> = [
{ code: "zh", label: "中文" },
];

export function CaptionsPane() {
export function CaptionsPane({ onClose }: { onClose?: () => void } = {}) {
const t = useScopedT("settings");
const te = useScopedT("editor");
const tc = useScopedT("common");
const {
settings,
translations,
Expand Down Expand Up @@ -193,14 +194,47 @@ export function CaptionsPane() {
};

return (
<div className={`${styles.pane} ${styles.isActive}`}>
<header className={styles.paneHead}>
<h2>{t("facets.captions")}</h2>
<span style={{ marginLeft: "auto", display: "inline-flex", alignItems: "center", gap: 6 }}>
<CaptionsIcon size={14} style={{ color: "var(--muted)" }} />
<div
className={`${styles.pane} ${styles.isActive}`}
style={{ minHeight: 0, display: "flex", flexDirection: "column" }}
>
<header
className={styles.paneHead}
style={{
position: "relative",
paddingRight: "var(--sp-4)",
flexShrink: 0,
}}
>
<span style={{ display: "inline-flex", alignItems: "center", color: "var(--muted)" }}>
<CaptionsIcon size={14} />
</span>
<h2>{t("facets.captions")}</h2>
{onClose ? (
<button
type="button"
className={styles.iconBtn}
style={{ marginLeft: "auto" }}
title={tc("actions.close")}
aria-label={tc("actions.close")}
onClick={onClose}
>
<X size={14} />
</button>
) : null}
</header>
<div className={styles.paneBody} style={{ padding: 0 }}>
<div
className={styles.paneBody}
style={{
padding: "8px 0 16px",
minHeight: 0,
flex: "1 1 auto",
overflowY: "auto",
overflowX: "hidden",
scrollbarWidth: "thin",
scrollbarColor: "var(--border) transparent",
}}
>
<div className={styles.paneRow}>
<span className={styles.label}>{t("captions.show")}</span>
<Toggle
Expand All @@ -217,6 +251,7 @@ export function CaptionsPane() {
padding: "14px",
border: "1px dashed var(--border-hi)",
borderRadius: 10,
background: "var(--surface-2)",
display: "flex",
flexDirection: "column",
gap: 10,
Expand Down Expand Up @@ -262,7 +297,7 @@ export function CaptionsPane() {
style={{
margin: "0 var(--sp-4) 12px",
font: "400 11.5px/1.5 var(--font-body)",
color: "var(--meta)",
color: "var(--muted)",
}}
>
{/* The cue count is only meaningful while the layer is on — deriving
Expand All @@ -284,7 +319,7 @@ export function CaptionsPane() {
padding: "12px 14px",
border: "1px solid var(--border)",
borderRadius: 10,
background: "var(--surface-warm)",
background: "var(--surface-2)",
display: "flex",
flexDirection: "column",
gap: 8,
Expand Down Expand Up @@ -335,7 +370,7 @@ export function CaptionsPane() {
value={target}
disabled={disabled || translating}
onChange={(e) => setTarget(e.target.value)}
style={{ ...selectStyle, flex: 1 }}
style={{ ...selectStyle, flex: 1, minWidth: 0 }}
>
{TRANSLATION_LANGUAGES.map((language) => (
<option key={language.code} value={language.code}>
Expand All @@ -346,6 +381,7 @@ export function CaptionsPane() {
<button
type="button"
className={`${styles.btn} ${styles.btnSecondary}`}
style={{ flexShrink: 0 }}
disabled={disabled || translating || !hasTranscript}
onClick={() => void handleTranslate()}
title={t("captions.translateHint")}
Expand Down Expand Up @@ -381,7 +417,7 @@ export function CaptionsPane() {
style={{
margin: "0 var(--sp-4) 14px",
font: "400 11px/1.5 var(--font-body)",
color: "var(--meta)",
color: "var(--muted)",
}}
>
{t("captions.translationIsNonDestructive")}
Expand Down Expand Up @@ -493,7 +529,7 @@ export function CaptionsPane() {
style={{
margin: "6px var(--sp-4) 10px",
font: "400 11px/1.5 var(--font-body)",
color: "var(--meta)",
color: "var(--muted)",
}}
>
{settings.anchorV === "bottom"
Expand Down Expand Up @@ -593,13 +629,14 @@ const WORD_COUNTS = Array.from({ length: 12 }, (_, i) => i + 1);

const selectStyle: React.CSSProperties = {
height: 32,
padding: "0 8px",
padding: "0 10px",
borderRadius: 8,
border: "1px solid var(--border)",
background: "var(--surface)",
background: "var(--surface-2)",
color: "var(--fg)",
font: "500 12.5px var(--font-body)",
maxWidth: 160,
minWidth: 120,
cursor: "pointer",
};

/**
Expand Down
152 changes: 152 additions & 0 deletions src/components/ai-edition/NewEditorShell.chatOpen.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EditorDialogsProvider } from "@/contexts/EditorDialogsContext";
import { useChatPromptBus } from "@/lib/ai-edition/store/useChatPromptBus";

vi.mock("@/contexts/ShortcutsContext", async () => {
const { DEFAULT_SHORTCUTS } = await import("@/lib/shortcuts");
return {
useShortcuts: () => ({
shortcuts: DEFAULT_SHORTCUTS,
isMac: false,
isConfigOpen: false,
openConfig: vi.fn(),
closeConfig: vi.fn(),
setShortcuts: vi.fn(),
persistShortcuts: () => Promise.resolve(true),
}),
};
});

vi.mock("@/contexts/I18nContext", () => ({
useI18n: () => ({
locale: "en",
setLocale: () => {},
}),
useScopedT: (scope: string) => (key: string) => `${scope}.${key}`,
}));

vi.mock("./LeftPanel", () => ({
ChatStripPanel: () => {
const pending = useChatPromptBus((s) => s.pending);
const consume = useChatPromptBus((s) => s.consume);
return (
<div data-testid="chat-strip-panel">
{pending ? (
<button type="button" data-testid="consume-prompt-btn" onClick={() => consume()}>
send:{pending}
</button>
) : null}
</div>
);
},
}));

import { NewEditorShell } from "./NewEditorShell";

function renderShell() {
return render(
<EditorDialogsProvider>
<NewEditorShell />
</EditorDialogsProvider>,
);
}

describe("NewEditorShell chatOpen behavior with useChatPromptBus", () => {
beforeEach(() => {
useChatPromptBus.setState({ pending: null });
(window as unknown as { electronAPI?: unknown }).electronAPI = {
onAiEditionChatEvent: () => () => {},
setTitleBarOverlay: () => {},
setHasUnsavedChanges: () => {},
onRequestCloseConfirm: () => () => {},
onRequestSaveBeforeClose: () => () => {},
sendCloseConfirmResponse: () => {},
findRecordingCamera: () => Promise.resolve(null),
preparePreviewAudioTrack: () => Promise.resolve(null),
};
Element.prototype.scrollTo = () => {};
(globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver = class {
observe() {
// noop
}
unobserve() {
// noop
}
disconnect() {
// noop
}
};
});

afterEach(() => {
cleanup();
useChatPromptBus.setState({ pending: null });
(window as unknown as { electronAPI?: unknown }).electronAPI = undefined;
});

it("initially starts with chat closed, and opens when useChatPromptBus receives a prompt", () => {
renderShell();

// Initially closed
expect(
screen.queryByRole("complementary", { name: "editor.shell.aiEditor" }),
).not.toBeInTheDocument();
const toggleBtn = screen.getByRole("button", { name: "editor.topbar.toggleChatPanel" });
expect(toggleBtn).toHaveAttribute("aria-pressed", "false");

// Submit a prompt via the bus
act(() => {
useChatPromptBus.getState().submit("smart cut prompt");
});

// Now open
expect(
screen.getByRole("complementary", { name: "editor.shell.aiEditor" }),
).toBeInTheDocument();
expect(toggleBtn).toHaveAttribute("aria-pressed", "true");
});

it("supports the prompt flow: submit prompt -> opens -> consume -> close -> submit reopens", () => {
renderShell();

const toggleBtn = screen.getByRole("button", { name: "editor.topbar.toggleChatPanel" });

// 1. Submit prompt opens the chat panel
act(() => {
useChatPromptBus.getState().submit("first prompt");
});
expect(
screen.getByRole("complementary", { name: "editor.shell.aiEditor" }),
).toBeInTheDocument();
expect(useChatPromptBus.getState().pending).toBe("first prompt");

// 2. Consume prompt via send
const consumeBtn = screen.getByTestId("consume-prompt-btn");
expect(consumeBtn).toHaveTextContent("send:first prompt");
act(() => {
fireEvent.click(consumeBtn);
});
expect(useChatPromptBus.getState().pending).toBeNull();

// 3. User closes the chat panel manually
act(() => {
fireEvent.click(toggleBtn);
});
expect(
screen.queryByRole("complementary", { name: "editor.shell.aiEditor" }),
).not.toBeInTheDocument();
expect(toggleBtn).toHaveAttribute("aria-pressed", "false");

// 4. A newly delivered prompt re-opens the chat panel
act(() => {
useChatPromptBus.getState().submit("second prompt");
});
expect(
screen.getByRole("complementary", { name: "editor.shell.aiEditor" }),
).toBeInTheDocument();
expect(toggleBtn).toHaveAttribute("aria-pressed", "true");
});
});
Loading
Loading