feat(web): UI components — ParamsPanel, TagBar, ModelPicker, DeviceBadge, VoiceLibrary, HistoryList, VoiceComposer
Browse files- web/src/components/DeviceBadge.tsx +16 -0
- web/src/components/HistoryList.tsx +41 -0
- web/src/components/ModelPicker.tsx +29 -0
- web/src/components/ParamsPanel.tsx +70 -0
- web/src/components/TagBar.tsx +41 -0
- web/src/components/VoiceComposer.tsx +95 -0
- web/src/components/VoiceLibrary.tsx +63 -0
- web/src/test/ParamsPanel.test.tsx +26 -0
- web/src/test/TagBar.test.tsx +30 -0
web/src/components/DeviceBadge.tsx
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from "react";
|
| 2 |
+
|
| 3 |
+
export default function DeviceBadge() {
|
| 4 |
+
const [device, setDevice] = useState<string>("?");
|
| 5 |
+
useEffect(() => {
|
| 6 |
+
fetch("/api/health")
|
| 7 |
+
.then((r) => r.json())
|
| 8 |
+
.then((d) => setDevice(d.device))
|
| 9 |
+
.catch(() => setDevice("offline"));
|
| 10 |
+
}, []);
|
| 11 |
+
return (
|
| 12 |
+
<span className="text-xs px-2 py-0.5 rounded-md border border-border text-muted-foreground">
|
| 13 |
+
{device}
|
| 14 |
+
</span>
|
| 15 |
+
);
|
| 16 |
+
}
|
web/src/components/HistoryList.tsx
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from "react";
|
| 2 |
+
import { listHistory, type HistoryRecord } from "@/lib/idb";
|
| 3 |
+
|
| 4 |
+
type Props = {
|
| 5 |
+
refreshKey?: number;
|
| 6 |
+
onRegenerate: (h: HistoryRecord) => void;
|
| 7 |
+
};
|
| 8 |
+
|
| 9 |
+
export default function HistoryList({ refreshKey, onRegenerate }: Props) {
|
| 10 |
+
const [items, setItems] = useState<HistoryRecord[]>([]);
|
| 11 |
+
useEffect(() => {
|
| 12 |
+
listHistory().then(setItems);
|
| 13 |
+
}, [refreshKey]);
|
| 14 |
+
|
| 15 |
+
if (items.length === 0) {
|
| 16 |
+
return <p className="text-sm text-muted-foreground">No generations yet.</p>;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
return (
|
| 20 |
+
<ul className="space-y-2">
|
| 21 |
+
{items.map((h) => {
|
| 22 |
+
const url = URL.createObjectURL(h.audioBlob);
|
| 23 |
+
return (
|
| 24 |
+
<li key={h.id} className="rounded-md border border-border p-2 space-y-2">
|
| 25 |
+
<div className="text-sm line-clamp-2">{h.text}</div>
|
| 26 |
+
<div className="text-xs text-muted-foreground">
|
| 27 |
+
{h.modelId} · {h.language ?? "—"} · {new Date(h.createdAt).toLocaleTimeString()}
|
| 28 |
+
</div>
|
| 29 |
+
<audio controls src={url} className="w-full" />
|
| 30 |
+
<div className="flex justify-end gap-2">
|
| 31 |
+
<a href={url} download={`${h.id}.wav`} className="text-xs underline">download</a>
|
| 32 |
+
<button type="button" className="text-xs underline" onClick={() => onRegenerate(h)}>
|
| 33 |
+
regenerate
|
| 34 |
+
</button>
|
| 35 |
+
</div>
|
| 36 |
+
</li>
|
| 37 |
+
);
|
| 38 |
+
})}
|
| 39 |
+
</ul>
|
| 40 |
+
);
|
| 41 |
+
}
|
web/src/components/ModelPicker.tsx
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ModelInfo } from "@/lib/api";
|
| 2 |
+
|
| 3 |
+
type Props = {
|
| 4 |
+
models: ModelInfo[];
|
| 5 |
+
activeId: string | null;
|
| 6 |
+
loading: boolean;
|
| 7 |
+
onPick: (id: string) => void;
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
export default function ModelPicker({ models, activeId, loading, onPick }: Props) {
|
| 11 |
+
return (
|
| 12 |
+
<select
|
| 13 |
+
aria-label="Model"
|
| 14 |
+
disabled={loading || models.length === 0}
|
| 15 |
+
value={activeId ?? ""}
|
| 16 |
+
onChange={(e) => onPick(e.target.value)}
|
| 17 |
+
className="rounded-md border border-border bg-background px-2 py-1 text-sm"
|
| 18 |
+
>
|
| 19 |
+
<option value="" disabled>
|
| 20 |
+
Choose model…
|
| 21 |
+
</option>
|
| 22 |
+
{models.map((m) => (
|
| 23 |
+
<option key={m.id} value={m.id}>
|
| 24 |
+
{m.label}
|
| 25 |
+
</option>
|
| 26 |
+
))}
|
| 27 |
+
</select>
|
| 28 |
+
);
|
| 29 |
+
}
|
web/src/components/ParamsPanel.tsx
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ParamSpec } from "@/lib/api";
|
| 2 |
+
|
| 3 |
+
type Props = {
|
| 4 |
+
specs: ParamSpec[];
|
| 5 |
+
values: Record<string, unknown>;
|
| 6 |
+
onChange: (next: Record<string, unknown>) => void;
|
| 7 |
+
};
|
| 8 |
+
|
| 9 |
+
export default function ParamsPanel({ specs, values, onChange }: Props) {
|
| 10 |
+
function set(name: string, v: unknown) {
|
| 11 |
+
onChange({ ...values, [name]: v });
|
| 12 |
+
}
|
| 13 |
+
return (
|
| 14 |
+
<div className="space-y-4">
|
| 15 |
+
{specs.map((s) => {
|
| 16 |
+
const id = `param-${s.name}`;
|
| 17 |
+
const current = (values[s.name] ?? s.default) as never;
|
| 18 |
+
if (s.type === "float" || s.type === "int") {
|
| 19 |
+
return (
|
| 20 |
+
<label key={s.name} htmlFor={id} className="block space-y-1">
|
| 21 |
+
<span className="text-sm">{s.label}</span>
|
| 22 |
+
<input
|
| 23 |
+
id={id}
|
| 24 |
+
aria-label={s.label}
|
| 25 |
+
type="range"
|
| 26 |
+
min={s.min}
|
| 27 |
+
max={s.max}
|
| 28 |
+
step={s.step ?? 0.01}
|
| 29 |
+
value={current as number}
|
| 30 |
+
onChange={(e) => set(s.name, Number(e.target.value))}
|
| 31 |
+
className="w-full"
|
| 32 |
+
/>
|
| 33 |
+
<span className="text-xs text-muted-foreground">{String(current)}</span>
|
| 34 |
+
</label>
|
| 35 |
+
);
|
| 36 |
+
}
|
| 37 |
+
if (s.type === "bool") {
|
| 38 |
+
return (
|
| 39 |
+
<label key={s.name} htmlFor={id} className="flex items-center justify-between text-sm">
|
| 40 |
+
<span>{s.label}</span>
|
| 41 |
+
<input
|
| 42 |
+
id={id}
|
| 43 |
+
aria-label={s.label}
|
| 44 |
+
type="checkbox"
|
| 45 |
+
checked={!!current}
|
| 46 |
+
onChange={(e) => set(s.name, e.target.checked)}
|
| 47 |
+
/>
|
| 48 |
+
</label>
|
| 49 |
+
);
|
| 50 |
+
}
|
| 51 |
+
return (
|
| 52 |
+
<label key={s.name} htmlFor={id} className="block space-y-1">
|
| 53 |
+
<span className="text-sm">{s.label}</span>
|
| 54 |
+
<select
|
| 55 |
+
id={id}
|
| 56 |
+
aria-label={s.label}
|
| 57 |
+
value={current as string}
|
| 58 |
+
onChange={(e) => set(s.name, e.target.value)}
|
| 59 |
+
className="w-full rounded-md border border-border bg-background px-2 py-1"
|
| 60 |
+
>
|
| 61 |
+
{(s.choices ?? []).map((c) => (
|
| 62 |
+
<option key={c} value={c}>{c}</option>
|
| 63 |
+
))}
|
| 64 |
+
</select>
|
| 65 |
+
</label>
|
| 66 |
+
);
|
| 67 |
+
})}
|
| 68 |
+
</div>
|
| 69 |
+
);
|
| 70 |
+
}
|
web/src/components/TagBar.tsx
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { RefObject } from "react";
|
| 2 |
+
|
| 3 |
+
type Props = {
|
| 4 |
+
tags: string[];
|
| 5 |
+
targetRef: RefObject<HTMLTextAreaElement>;
|
| 6 |
+
};
|
| 7 |
+
|
| 8 |
+
export default function TagBar({ tags, targetRef }: Props) {
|
| 9 |
+
if (tags.length === 0) return null;
|
| 10 |
+
function insert(tag: string) {
|
| 11 |
+
const el = targetRef.current;
|
| 12 |
+
if (!el) return;
|
| 13 |
+
const start = el.selectionStart ?? el.value.length;
|
| 14 |
+
const end = el.selectionEnd ?? start;
|
| 15 |
+
const before = el.value.slice(0, start);
|
| 16 |
+
const after = el.value.slice(end);
|
| 17 |
+
const native = Object.getOwnPropertyDescriptor(
|
| 18 |
+
window.HTMLTextAreaElement.prototype,
|
| 19 |
+
"value",
|
| 20 |
+
)?.set;
|
| 21 |
+
native?.call(el, before + tag + after);
|
| 22 |
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
| 23 |
+
const cursor = start + tag.length;
|
| 24 |
+
el.setSelectionRange(cursor, cursor);
|
| 25 |
+
el.focus();
|
| 26 |
+
}
|
| 27 |
+
return (
|
| 28 |
+
<div className="flex flex-wrap gap-1.5">
|
| 29 |
+
{tags.map((t) => (
|
| 30 |
+
<button
|
| 31 |
+
key={t}
|
| 32 |
+
type="button"
|
| 33 |
+
onClick={() => insert(t)}
|
| 34 |
+
className="text-xs px-2 py-0.5 rounded-md border border-border hover:bg-muted"
|
| 35 |
+
>
|
| 36 |
+
{t}
|
| 37 |
+
</button>
|
| 38 |
+
))}
|
| 39 |
+
</div>
|
| 40 |
+
);
|
| 41 |
+
}
|
web/src/components/VoiceComposer.tsx
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useRef, useState } from "react";
|
| 2 |
+
import { Recorder } from "@/lib/audio";
|
| 3 |
+
import { addVoice } from "@/lib/idb";
|
| 4 |
+
|
| 5 |
+
type Props = {
|
| 6 |
+
onSaved: () => void;
|
| 7 |
+
};
|
| 8 |
+
|
| 9 |
+
export default function VoiceComposer({ onSaved }: Props) {
|
| 10 |
+
const fileRef = useRef<HTMLInputElement>(null);
|
| 11 |
+
const recorderRef = useRef<Recorder | null>(null);
|
| 12 |
+
const [recState, setRecState] = useState<"idle" | "recording" | "stopping" | "error">("idle");
|
| 13 |
+
const [name, setName] = useState("");
|
| 14 |
+
|
| 15 |
+
async function importBlob(blob: Blob, defaultName: string) {
|
| 16 |
+
const arr = new Uint8Array(await blob.arrayBuffer());
|
| 17 |
+
const ctx = new AudioContext();
|
| 18 |
+
const buf = await ctx.decodeAudioData(arr.buffer.slice(0));
|
| 19 |
+
await addVoice({
|
| 20 |
+
name: name || defaultName || `voice-${Date.now()}`,
|
| 21 |
+
blob,
|
| 22 |
+
sampleRate: buf.sampleRate,
|
| 23 |
+
durationMs: Math.round(buf.duration * 1000),
|
| 24 |
+
});
|
| 25 |
+
setName("");
|
| 26 |
+
onSaved();
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
| 30 |
+
const f = e.target.files?.[0];
|
| 31 |
+
if (!f) return;
|
| 32 |
+
await importBlob(f, f.name.replace(/\.[^.]+$/, ""));
|
| 33 |
+
e.target.value = "";
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
async function startRec() {
|
| 37 |
+
const r = new Recorder();
|
| 38 |
+
recorderRef.current = r;
|
| 39 |
+
try {
|
| 40 |
+
await r.start();
|
| 41 |
+
setRecState("recording");
|
| 42 |
+
} catch {
|
| 43 |
+
setRecState("error");
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
async function stopRec() {
|
| 48 |
+
setRecState("stopping");
|
| 49 |
+
const blob = await recorderRef.current?.stop();
|
| 50 |
+
setRecState("idle");
|
| 51 |
+
if (blob) await importBlob(blob, "recorded");
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
return (
|
| 55 |
+
<div className="space-y-2">
|
| 56 |
+
<input
|
| 57 |
+
type="text"
|
| 58 |
+
placeholder="Voice name (optional)"
|
| 59 |
+
value={name}
|
| 60 |
+
onChange={(e) => setName(e.target.value)}
|
| 61 |
+
className="w-full rounded-md border border-border bg-background px-2 py-1 text-sm"
|
| 62 |
+
/>
|
| 63 |
+
<div className="flex gap-2">
|
| 64 |
+
<button
|
| 65 |
+
type="button"
|
| 66 |
+
onClick={() => fileRef.current?.click()}
|
| 67 |
+
className="rounded-md border border-border px-3 py-1.5 text-sm"
|
| 68 |
+
>
|
| 69 |
+
Upload .wav/.mp3
|
| 70 |
+
</button>
|
| 71 |
+
<input ref={fileRef} type="file" accept="audio/*" hidden onChange={onFile} />
|
| 72 |
+
{recState === "recording" ? (
|
| 73 |
+
<button
|
| 74 |
+
type="button"
|
| 75 |
+
onClick={stopRec}
|
| 76 |
+
className="rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-sm"
|
| 77 |
+
>
|
| 78 |
+
Stop & save
|
| 79 |
+
</button>
|
| 80 |
+
) : (
|
| 81 |
+
<button
|
| 82 |
+
type="button"
|
| 83 |
+
onClick={startRec}
|
| 84 |
+
className="rounded-md border border-border px-3 py-1.5 text-sm"
|
| 85 |
+
>
|
| 86 |
+
Record
|
| 87 |
+
</button>
|
| 88 |
+
)}
|
| 89 |
+
</div>
|
| 90 |
+
{recState === "error" && (
|
| 91 |
+
<p className="text-xs text-red-500">Microphone permission denied.</p>
|
| 92 |
+
)}
|
| 93 |
+
</div>
|
| 94 |
+
);
|
| 95 |
+
}
|
web/src/components/VoiceLibrary.tsx
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from "react";
|
| 2 |
+
import { deleteVoice, listVoices, setFavorite, type VoiceRecord } from "@/lib/idb";
|
| 3 |
+
import { cn } from "@/lib/utils";
|
| 4 |
+
|
| 5 |
+
type Props = {
|
| 6 |
+
selectedId?: number;
|
| 7 |
+
onSelect: (v: VoiceRecord) => void;
|
| 8 |
+
refreshKey?: number;
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
export default function VoiceLibrary({ selectedId, onSelect, refreshKey }: Props) {
|
| 12 |
+
const [voices, setVoices] = useState<VoiceRecord[]>([]);
|
| 13 |
+
useEffect(() => {
|
| 14 |
+
listVoices().then(setVoices);
|
| 15 |
+
}, [refreshKey]);
|
| 16 |
+
|
| 17 |
+
if (voices.length === 0) {
|
| 18 |
+
return <p className="text-sm text-muted-foreground">No saved voices yet.</p>;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
return (
|
| 22 |
+
<ul className="space-y-2">
|
| 23 |
+
{voices.map((v) => (
|
| 24 |
+
<li
|
| 25 |
+
key={v.id}
|
| 26 |
+
className={cn(
|
| 27 |
+
"flex items-center justify-between rounded-md border border-border p-2",
|
| 28 |
+
selectedId === v.id && "ring-1 ring-primary",
|
| 29 |
+
)}
|
| 30 |
+
>
|
| 31 |
+
<button
|
| 32 |
+
className="flex-1 text-left text-sm"
|
| 33 |
+
onClick={() => onSelect(v)}
|
| 34 |
+
type="button"
|
| 35 |
+
>
|
| 36 |
+
<div className="font-medium">{v.name}</div>
|
| 37 |
+
<div className="text-xs text-muted-foreground">
|
| 38 |
+
{(v.durationMs / 1000).toFixed(1)}s · {v.sampleRate} Hz
|
| 39 |
+
</div>
|
| 40 |
+
</button>
|
| 41 |
+
<div className="flex items-center gap-1">
|
| 42 |
+
<button
|
| 43 |
+
type="button"
|
| 44 |
+
aria-label={v.isFavorite ? "Unfavorite" : "Favorite"}
|
| 45 |
+
onClick={() => setFavorite(v.id!, !v.isFavorite).then(() => listVoices().then(setVoices))}
|
| 46 |
+
className="text-xs px-1"
|
| 47 |
+
>
|
| 48 |
+
{v.isFavorite ? "★" : "☆"}
|
| 49 |
+
</button>
|
| 50 |
+
<button
|
| 51 |
+
type="button"
|
| 52 |
+
aria-label="Delete"
|
| 53 |
+
onClick={() => deleteVoice(v.id!).then(() => listVoices().then(setVoices))}
|
| 54 |
+
className="text-xs px-1 text-muted-foreground"
|
| 55 |
+
>
|
| 56 |
+
✕
|
| 57 |
+
</button>
|
| 58 |
+
</div>
|
| 59 |
+
</li>
|
| 60 |
+
))}
|
| 61 |
+
</ul>
|
| 62 |
+
);
|
| 63 |
+
}
|
web/src/test/ParamsPanel.test.tsx
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { fireEvent, render, screen } from "@testing-library/react";
|
| 2 |
+
import { describe, expect, it, vi } from "vitest";
|
| 3 |
+
import ParamsPanel from "@/components/ParamsPanel";
|
| 4 |
+
import type { ParamSpec } from "@/lib/api";
|
| 5 |
+
|
| 6 |
+
const specs: ParamSpec[] = [
|
| 7 |
+
{ name: "exaggeration", label: "Exaggeration", type: "float", default: 0.5, min: 0, max: 2, step: 0.05 },
|
| 8 |
+
{ name: "is_fast", label: "Fast mode", type: "bool", default: false },
|
| 9 |
+
{ name: "lang", label: "Lang", type: "enum", default: "en", choices: ["en", "fr"] },
|
| 10 |
+
];
|
| 11 |
+
|
| 12 |
+
describe("ParamsPanel", () => {
|
| 13 |
+
it("renders one control per spec", () => {
|
| 14 |
+
render(<ParamsPanel specs={specs} values={{}} onChange={() => {}} />);
|
| 15 |
+
expect(screen.getByLabelText(/exaggeration/i)).toBeInTheDocument();
|
| 16 |
+
expect(screen.getByLabelText(/fast mode/i)).toBeInTheDocument();
|
| 17 |
+
expect(screen.getByLabelText(/^lang$/i)).toBeInTheDocument();
|
| 18 |
+
});
|
| 19 |
+
|
| 20 |
+
it("emits onChange with merged values", () => {
|
| 21 |
+
const onChange = vi.fn();
|
| 22 |
+
render(<ParamsPanel specs={specs} values={{}} onChange={onChange} />);
|
| 23 |
+
fireEvent.change(screen.getByLabelText(/exaggeration/i), { target: { value: "1.2" } });
|
| 24 |
+
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ exaggeration: 1.2 }));
|
| 25 |
+
});
|
| 26 |
+
});
|
web/src/test/TagBar.test.tsx
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { fireEvent, render, screen } from "@testing-library/react";
|
| 2 |
+
import { describe, expect, it } from "vitest";
|
| 3 |
+
import { useRef } from "react";
|
| 4 |
+
import TagBar from "@/components/TagBar";
|
| 5 |
+
|
| 6 |
+
function Host({ tags }: { tags: string[] }) {
|
| 7 |
+
const ref = useRef<HTMLTextAreaElement>(null);
|
| 8 |
+
return (
|
| 9 |
+
<>
|
| 10 |
+
<textarea ref={ref} aria-label="text" defaultValue="hello world" />
|
| 11 |
+
<TagBar tags={tags} targetRef={ref} />
|
| 12 |
+
</>
|
| 13 |
+
);
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
describe("TagBar", () => {
|
| 17 |
+
it("inserts tag at cursor position", () => {
|
| 18 |
+
render(<Host tags={["[laugh]"]} />);
|
| 19 |
+
const ta = screen.getByLabelText("text") as HTMLTextAreaElement;
|
| 20 |
+
ta.focus();
|
| 21 |
+
ta.setSelectionRange(5, 5);
|
| 22 |
+
fireEvent.click(screen.getByRole("button", { name: /\[laugh\]/i }));
|
| 23 |
+
expect(ta.value).toBe("hello[laugh] world");
|
| 24 |
+
});
|
| 25 |
+
|
| 26 |
+
it("renders nothing when tags is empty", () => {
|
| 27 |
+
const { container } = render(<Host tags={[]} />);
|
| 28 |
+
expect(container.querySelectorAll("button").length).toBe(0);
|
| 29 |
+
});
|
| 30 |
+
});
|