mirror of
https://github.com/iamlukethedev/Claw3D.git
synced 2026-08-14 09:02:38 +00:00
Add state animation mapping settings UI
Co-authored-by: Luke The Dev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
co-authored by
Luke The Dev
parent
8c1df5e18c
commit
d91aa35d22
@@ -3,6 +3,8 @@
|
||||
import { useState } from "react";
|
||||
import { CURATED_ELEVENLABS_VOICES } from "@/lib/voiceReply/catalog";
|
||||
import type { StudioGatewayAdapterType } from "@/lib/studio/settings";
|
||||
import type { OfficeStateAnimationMapping } from "@/lib/office/stateMappingConfig";
|
||||
import { StateAnimationMappingsEditor } from "@/features/office/components/panels/StateAnimationMappingsEditor";
|
||||
|
||||
type SettingsPanelProps = {
|
||||
gatewayStatus?: string;
|
||||
@@ -19,6 +21,8 @@ type SettingsPanelProps = {
|
||||
officeTitle: string;
|
||||
officeTitleLoaded: boolean;
|
||||
onOfficeTitleChange: (title: string) => void;
|
||||
stateAnimationMappings: OfficeStateAnimationMapping[];
|
||||
onStateAnimationMappingsChange: (mappings: OfficeStateAnimationMapping[]) => void;
|
||||
remoteOfficeEnabled: boolean;
|
||||
remoteOfficeSourceKind: "presence_endpoint" | "openclaw_gateway";
|
||||
remoteOfficeLabel: string;
|
||||
@@ -56,6 +60,8 @@ export function SettingsPanel({
|
||||
officeTitle,
|
||||
officeTitleLoaded,
|
||||
onOfficeTitleChange,
|
||||
stateAnimationMappings,
|
||||
onStateAnimationMappingsChange,
|
||||
remoteOfficeEnabled,
|
||||
remoteOfficeSourceKind,
|
||||
remoteOfficeLabel,
|
||||
@@ -226,6 +232,10 @@ export function SettingsPanel({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<StateAnimationMappingsEditor
|
||||
mappings={stateAnimationMappings}
|
||||
onChange={onStateAnimationMappingsChange}
|
||||
/>
|
||||
<div className="mt-3 rounded-lg border border-cyan-500/10 bg-black/20 px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
OFFICE_STATE_ANIMATION_TARGETS,
|
||||
OFFICE_STATE_EFFECT_IDS,
|
||||
normalizeOfficeStateToken,
|
||||
type OfficeStateAnimationMapping,
|
||||
type OfficeStateAnimationTarget,
|
||||
type OfficeStateEffectId,
|
||||
} from "@/lib/office/stateMappingConfig";
|
||||
|
||||
type StateAnimationMappingsEditorProps = {
|
||||
mappings: OfficeStateAnimationMapping[];
|
||||
onChange: (mappings: OfficeStateAnimationMapping[]) => void;
|
||||
};
|
||||
|
||||
const TARGET_LABELS: Record<OfficeStateAnimationTarget, string> = {
|
||||
none: "No movement",
|
||||
desk: "Desk",
|
||||
server_room: "Server room",
|
||||
gym: "Gym",
|
||||
jukebox: "Jukebox",
|
||||
qa_lab: "QA lab",
|
||||
sms_booth: "SMS booth",
|
||||
phone_booth: "Phone booth",
|
||||
};
|
||||
|
||||
const EFFECT_LABELS: Record<OfficeStateEffectId, string> = {
|
||||
none: "No effect",
|
||||
confetti: "Confetti",
|
||||
alarm: "Alarm",
|
||||
doorbell: "Doorbell",
|
||||
};
|
||||
|
||||
const createMappingId = (sourceState: string, index: number) =>
|
||||
`state-${sourceState}-${Date.now()}-${index}`;
|
||||
|
||||
const createMapping = (
|
||||
sourceState: string,
|
||||
animationTarget: OfficeStateAnimationTarget,
|
||||
params: Partial<OfficeStateAnimationMapping> = {},
|
||||
): OfficeStateAnimationMapping => ({
|
||||
id: createMappingId(sourceState, params.priority ?? 50),
|
||||
sourceState,
|
||||
label: params.label ?? sourceState.replace(/_/g, " "),
|
||||
animationTarget,
|
||||
effect: params.effect ?? "none",
|
||||
soundCueId: params.soundCueId ?? null,
|
||||
priority: params.priority ?? 50,
|
||||
enabled: params.enabled ?? true,
|
||||
});
|
||||
|
||||
const STARTER_MAPPINGS: OfficeStateAnimationMapping[] = [
|
||||
createMapping("writing", "desk", { label: "Writing", priority: 90 }),
|
||||
createMapping("executing", "server_room", { label: "Executing", priority: 80 }),
|
||||
createMapping("waiting", "phone_booth", {
|
||||
label: "Waiting",
|
||||
effect: "doorbell",
|
||||
soundCueId: "approval",
|
||||
priority: 70,
|
||||
}),
|
||||
createMapping("error", "server_room", {
|
||||
label: "Error",
|
||||
effect: "alarm",
|
||||
soundCueId: "alarm",
|
||||
priority: 100,
|
||||
}),
|
||||
];
|
||||
|
||||
export function StateAnimationMappingsEditor({
|
||||
mappings,
|
||||
onChange,
|
||||
}: StateAnimationMappingsEditorProps) {
|
||||
const updateMapping = (
|
||||
id: string,
|
||||
patch: Partial<OfficeStateAnimationMapping>,
|
||||
) => {
|
||||
onChange(
|
||||
mappings.map((mapping) =>
|
||||
mapping.id === id ? { ...mapping, ...patch } : mapping,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const removeMapping = (id: string) => {
|
||||
onChange(mappings.filter((mapping) => mapping.id !== id));
|
||||
};
|
||||
|
||||
const normalizeMappingsForSave = () => {
|
||||
onChange(
|
||||
mappings
|
||||
.map((mapping) => ({
|
||||
...mapping,
|
||||
sourceState: normalizeOfficeStateToken(mapping.sourceState),
|
||||
label: mapping.label.trim() || mapping.sourceState.trim(),
|
||||
soundCueId: mapping.soundCueId?.trim() || null,
|
||||
priority: Math.max(0, Math.min(100, Math.round(mapping.priority))),
|
||||
}))
|
||||
.filter((mapping) => mapping.sourceState.length > 0),
|
||||
);
|
||||
};
|
||||
|
||||
const addMapping = () => {
|
||||
onChange([...mappings, createMapping("writing", "desk", { label: "Writing" })]);
|
||||
};
|
||||
|
||||
const addStarterMappings = () => {
|
||||
onChange([
|
||||
...mappings,
|
||||
...STARTER_MAPPINGS.map((mapping, index) => ({
|
||||
...mapping,
|
||||
id: createMappingId(mapping.sourceState, mappings.length + index),
|
||||
})),
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-lg border border-cyan-500/10 bg-black/20 px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-medium text-white">
|
||||
State animation mappings
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] text-white/75">
|
||||
Map runtime states like writing, executing, waiting, or error to office movement.
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-cyan-200/70">
|
||||
{mappings.length} rules
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addMapping}
|
||||
className="rounded-md border border-cyan-500/20 bg-cyan-500/10 px-3 py-1.5 text-[10px] font-medium uppercase tracking-[0.14em] text-cyan-50 transition-colors hover:border-cyan-400/40 hover:bg-cyan-500/15"
|
||||
>
|
||||
Add mapping
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addStarterMappings}
|
||||
className="rounded-md border border-emerald-500/20 bg-emerald-500/10 px-3 py-1.5 text-[10px] font-medium uppercase tracking-[0.14em] text-emerald-100 transition-colors hover:border-emerald-400/40 hover:bg-emerald-500/15"
|
||||
>
|
||||
Use starter set
|
||||
</button>
|
||||
{mappings.length > 0 ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={normalizeMappingsForSave}
|
||||
className="rounded-md border border-cyan-500/20 bg-black/20 px-3 py-1.5 text-[10px] font-medium uppercase tracking-[0.14em] text-cyan-100 transition-colors hover:border-cyan-400/40 hover:bg-cyan-500/10"
|
||||
>
|
||||
Normalize
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange([])}
|
||||
className="rounded-md border border-rose-500/20 bg-rose-500/10 px-3 py-1.5 text-[10px] font-medium uppercase tracking-[0.14em] text-rose-100 transition-colors hover:border-rose-400/40 hover:bg-rose-500/15"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{mappings.length === 0 ? (
|
||||
<div className="mt-3 rounded-md border border-dashed border-cyan-500/15 bg-black/15 px-3 py-3 text-[10px] text-white/60">
|
||||
No custom mappings yet. Add one rule or load the starter set for common fleet states.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 grid gap-3">
|
||||
{mappings.map((mapping, index) => (
|
||||
<div
|
||||
key={mapping.id}
|
||||
className="rounded-lg border border-cyan-500/10 bg-black/15 px-3 py-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-cyan-100/65">
|
||||
Rule {index + 1}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-label={`Enable mapping ${mapping.label || mapping.sourceState}`}
|
||||
aria-checked={mapping.enabled}
|
||||
className={`ui-switch self-center ${mapping.enabled ? "ui-switch--on" : ""}`}
|
||||
onClick={() =>
|
||||
updateMapping(mapping.id, { enabled: !mapping.enabled })
|
||||
}
|
||||
>
|
||||
<span className="ui-switch-thumb" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMapping(mapping.id)}
|
||||
className="rounded-md border border-rose-500/20 bg-rose-500/10 px-2 py-1 text-[10px] font-medium uppercase tracking-[0.14em] text-rose-100 transition-colors hover:border-rose-400/40 hover:bg-rose-500/15"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-3">
|
||||
<label className="grid gap-1">
|
||||
<span className="text-[10px] uppercase tracking-[0.14em] text-cyan-100/65">
|
||||
Label
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={mapping.label}
|
||||
onChange={(event) =>
|
||||
updateMapping(mapping.id, {
|
||||
label: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Writing"
|
||||
className="w-full rounded-md border border-cyan-500/10 bg-black/25 px-3 py-2 text-[11px] text-cyan-100 outline-none transition-colors placeholder:text-cyan-100/30 focus:border-cyan-400/30"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1">
|
||||
<span className="text-[10px] uppercase tracking-[0.14em] text-cyan-100/65">
|
||||
Source state
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={mapping.sourceState}
|
||||
onChange={(event) =>
|
||||
updateMapping(mapping.id, {
|
||||
sourceState: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="writing"
|
||||
className="w-full rounded-md border border-cyan-500/10 bg-black/25 px-3 py-2 font-mono text-[11px] text-cyan-100 outline-none transition-colors placeholder:text-cyan-100/30 focus:border-cyan-400/30"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="grid gap-1">
|
||||
<span className="text-[10px] uppercase tracking-[0.14em] text-cyan-100/65">
|
||||
Target
|
||||
</span>
|
||||
<select
|
||||
value={mapping.animationTarget}
|
||||
onChange={(event) =>
|
||||
updateMapping(mapping.id, {
|
||||
animationTarget: event.target
|
||||
.value as OfficeStateAnimationTarget,
|
||||
})
|
||||
}
|
||||
className="w-full rounded-md border border-cyan-500/10 bg-black/25 px-3 py-2 text-[11px] text-cyan-100 outline-none transition-colors focus:border-cyan-400/30"
|
||||
>
|
||||
{OFFICE_STATE_ANIMATION_TARGETS.map((target) => (
|
||||
<option key={target} value={target}>
|
||||
{TARGET_LABELS[target]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1">
|
||||
<span className="text-[10px] uppercase tracking-[0.14em] text-cyan-100/65">
|
||||
Effect
|
||||
</span>
|
||||
<select
|
||||
value={mapping.effect}
|
||||
onChange={(event) =>
|
||||
updateMapping(mapping.id, {
|
||||
effect: event.target.value as OfficeStateEffectId,
|
||||
})
|
||||
}
|
||||
className="w-full rounded-md border border-cyan-500/10 bg-black/25 px-3 py-2 text-[11px] text-cyan-100 outline-none transition-colors focus:border-cyan-400/30"
|
||||
>
|
||||
{OFFICE_STATE_EFFECT_IDS.map((effect) => (
|
||||
<option key={effect} value={effect}>
|
||||
{EFFECT_LABELS[effect]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[1fr_88px] gap-2">
|
||||
<label className="grid gap-1">
|
||||
<span className="text-[10px] uppercase tracking-[0.14em] text-cyan-100/65">
|
||||
Sound cue
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={mapping.soundCueId ?? ""}
|
||||
onChange={(event) =>
|
||||
updateMapping(mapping.id, {
|
||||
soundCueId: event.target.value || null,
|
||||
})
|
||||
}
|
||||
placeholder="alarm"
|
||||
className="w-full rounded-md border border-cyan-500/10 bg-black/25 px-3 py-2 font-mono text-[11px] text-cyan-100 outline-none transition-colors placeholder:text-cyan-100/30 focus:border-cyan-400/30"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1">
|
||||
<span className="text-[10px] uppercase tracking-[0.14em] text-cyan-100/65">
|
||||
Priority
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={mapping.priority}
|
||||
onChange={(event) =>
|
||||
updateMapping(mapping.id, {
|
||||
priority: Number.parseInt(event.target.value, 10) || 0,
|
||||
})
|
||||
}
|
||||
className="w-full rounded-md border border-cyan-500/10 bg-black/25 px-3 py-2 font-mono text-[11px] text-cyan-100 outline-none transition-colors focus:border-cyan-400/30"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 text-[10px] text-white/50">
|
||||
Source states are normalized on save, so "Writing" becomes "writing" and "approval pending" becomes "approval_pending".
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1329,6 +1329,7 @@ export function OfficeScreen({
|
||||
remoteOfficeGatewayUrl,
|
||||
remoteOfficeTokenConfigured,
|
||||
setTitle: setOfficeTitle,
|
||||
setStateAnimationMappings,
|
||||
setRemoteOfficeEnabled,
|
||||
setRemoteOfficeSourceKind,
|
||||
setRemoteOfficeLabel,
|
||||
@@ -4835,11 +4836,13 @@ export function OfficeScreen({
|
||||
remoteOfficeStatusText={remoteOfficeStatusText}
|
||||
remoteLayoutSnapshot={remoteOfficeLayoutSnapshot}
|
||||
remoteOfficeTokenConfigured={remoteOfficeTokenConfigured}
|
||||
stateAnimationMappings={stateAnimationMappings}
|
||||
voiceRepliesEnabled={voiceRepliesEnabled}
|
||||
voiceRepliesVoiceId={voiceRepliesVoiceId}
|
||||
voiceRepliesSpeed={voiceRepliesSpeed}
|
||||
voiceRepliesLoaded={voiceRepliesLoaded}
|
||||
onOfficeTitleChange={setOfficeTitle}
|
||||
onStateAnimationMappingsChange={setStateAnimationMappings}
|
||||
onRemoteOfficeEnabledChange={setRemoteOfficeEnabled}
|
||||
onRemoteOfficeSourceKindChange={setRemoteOfficeSourceKind}
|
||||
onRemoteOfficeLabelChange={setRemoteOfficeLabel}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||
import { Environment, OrbitControls } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
import { SettingsPanel } from "@/features/office/components/panels/SettingsPanel";
|
||||
import type { OfficeStateAnimationMapping } from "@/lib/office/stateMappingConfig";
|
||||
import { AtmImmersiveScreen } from "@/features/office/screens/AtmImmersiveScreen";
|
||||
import { GithubImmersiveScreen } from "@/features/office/screens/GithubImmersiveScreen";
|
||||
import { KanbanImmersiveScreen } from "@/features/office/screens/KanbanImmersiveScreen";
|
||||
@@ -2276,6 +2277,7 @@ export function RetroOffice3D({
|
||||
remoteOfficeStatusText = "Remote office disabled.",
|
||||
remoteLayoutSnapshot = null,
|
||||
remoteOfficeTokenConfigured = false,
|
||||
stateAnimationMappings = [],
|
||||
voiceRepliesEnabled = false,
|
||||
voiceRepliesVoiceId = null,
|
||||
voiceRepliesSpeed = 1,
|
||||
@@ -2287,6 +2289,7 @@ export function RetroOffice3D({
|
||||
onRemoteOfficePresenceUrlChange,
|
||||
onRemoteOfficeGatewayUrlChange,
|
||||
onRemoteOfficeTokenChange,
|
||||
onStateAnimationMappingsChange,
|
||||
onVoiceRepliesToggle,
|
||||
onVoiceRepliesVoiceChange,
|
||||
onVoiceRepliesSpeedChange,
|
||||
@@ -2390,6 +2393,7 @@ export function RetroOffice3D({
|
||||
remoteOfficeStatusText?: string;
|
||||
remoteLayoutSnapshot?: OfficeLayoutSnapshot | null;
|
||||
remoteOfficeTokenConfigured?: boolean;
|
||||
stateAnimationMappings?: OfficeStateAnimationMapping[];
|
||||
voiceRepliesEnabled?: boolean;
|
||||
voiceRepliesVoiceId?: string | null;
|
||||
voiceRepliesSpeed?: number;
|
||||
@@ -2403,6 +2407,7 @@ export function RetroOffice3D({
|
||||
onRemoteOfficePresenceUrlChange?: (url: string) => void;
|
||||
onRemoteOfficeGatewayUrlChange?: (url: string) => void;
|
||||
onRemoteOfficeTokenChange?: (token: string) => void;
|
||||
onStateAnimationMappingsChange?: (mappings: OfficeStateAnimationMapping[]) => void;
|
||||
onVoiceRepliesToggle?: (enabled: boolean) => void;
|
||||
onVoiceRepliesVoiceChange?: (voiceId: string | null) => void;
|
||||
onVoiceRepliesSpeedChange?: (speed: number) => void;
|
||||
@@ -7132,6 +7137,10 @@ export function RetroOffice3D({
|
||||
onRemoteOfficeTokenChange={(token) =>
|
||||
onRemoteOfficeTokenChange?.(token)
|
||||
}
|
||||
stateAnimationMappings={stateAnimationMappings}
|
||||
onStateAnimationMappingsChange={(mappings) =>
|
||||
onStateAnimationMappingsChange?.(mappings)
|
||||
}
|
||||
voiceRepliesEnabled={voiceRepliesEnabled}
|
||||
voiceRepliesVoiceId={voiceRepliesVoiceId}
|
||||
voiceRepliesSpeed={voiceRepliesSpeed}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { createElement } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { StateAnimationMappingsEditor } from "@/features/office/components/panels/StateAnimationMappingsEditor";
|
||||
import type { OfficeStateAnimationMapping } from "@/lib/office/stateMappingConfig";
|
||||
|
||||
const baseMapping: OfficeStateAnimationMapping = {
|
||||
id: "writing-desk",
|
||||
sourceState: "writing",
|
||||
label: "Writing",
|
||||
animationTarget: "desk",
|
||||
effect: "none",
|
||||
soundCueId: null,
|
||||
priority: 90,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
describe("StateAnimationMappingsEditor", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("adds starter mappings", () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
createElement(StateAnimationMappingsEditor, {
|
||||
mappings: [],
|
||||
onChange,
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Use starter set" }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
sourceState: "writing",
|
||||
animationTarget: "desk",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
sourceState: "error",
|
||||
animationTarget: "server_room",
|
||||
effect: "alarm",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("edits and removes a mapping", () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
createElement(StateAnimationMappingsEditor, {
|
||||
mappings: [baseMapping],
|
||||
onChange,
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Source state"), {
|
||||
target: { value: "syncing" },
|
||||
});
|
||||
expect(onChange).toHaveBeenLastCalledWith([
|
||||
expect.objectContaining({ sourceState: "syncing" }),
|
||||
]);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Target"), {
|
||||
target: { value: "qa_lab" },
|
||||
});
|
||||
expect(onChange).toHaveBeenLastCalledWith([
|
||||
expect.objectContaining({ animationTarget: "qa_lab" }),
|
||||
]);
|
||||
|
||||
const rule = screen.getByText("Rule 1").closest("div")?.parentElement?.parentElement;
|
||||
expect(rule).toBeTruthy();
|
||||
fireEvent.click(within(rule as HTMLElement).getByRole("button", { name: "Remove" }));
|
||||
expect(onChange).toHaveBeenLastCalledWith([]);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -10,7 +10,7 @@ export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: "./tests/setup.ts",
|
||||
include: ["tests/unit/**/*.test.ts"],
|
||||
include: ["tests/unit/**/*.test.{ts,tsx}"],
|
||||
exclude: ["tests/e2e/**"],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user