diff --git a/apps/desktop/src/features/workspace/RoleSwitcher.test.tsx b/apps/desktop/src/features/workspace/RoleSwitcher.test.tsx index 575684c50..e56c4fff7 100644 --- a/apps/desktop/src/features/workspace/RoleSwitcher.test.tsx +++ b/apps/desktop/src/features/workspace/RoleSwitcher.test.tsx @@ -3,23 +3,22 @@ import { describe, expect, it, vi } from "vitest"; import { RoleSwitcher, tabValueToRoleId } from "./RoleSwitcher"; vi.mock("../../i18n", () => ({ - createTranslator: () => (key: string) => + createTranslator: () => (translationKey: string) => ({ allRoles: "All Roles", roleSwitcherTitle: "Role-specific View" - })[key] ?? key, + })[translationKey] ?? translationKey, detectPreferredLocale: () => "en" })); describe("RoleSwitcher", () => { - it("renders the title and role options", () => { - const roles = [ - { id: "bass-guitar", name: "Bass Guitar" }, - { id: "lead-vocal", name: "Lead Vocal" } + const roleOptions = [ + { roleId: "bass-guitar", roleName: "Bass Guitar" }, + { roleId: "lead-vocal", roleName: "Lead Vocal" } ]; - render(); + render(); expect(screen.getByText("Role-specific View")).toBeInTheDocument(); expect(screen.getByRole("tab", { name: "All Roles" })).toBeInTheDocument(); @@ -28,48 +27,63 @@ describe("RoleSwitcher", () => { }); it("keeps the all-roles control distinct from a real role whose id is all", () => { - const onRoleChange = vi.fn(); + const roleChangeHandler = vi.fn(); render( ); fireEvent.click(screen.getByRole("tab", { name: "Alloy Synth" })); - expect(onRoleChange).toHaveBeenLastCalledWith("all"); + expect(roleChangeHandler).toHaveBeenLastCalledWith("all"); fireEvent.click(screen.getByRole("tab", { name: "All Roles" })); - expect(onRoleChange).toHaveBeenLastCalledWith(null); + expect(roleChangeHandler).toHaveBeenLastCalledWith(null); }); it("uses the project-standard active tab data selector", () => { render( ); - const allRolesTrigger = screen.getByRole("tab", { name: "All Roles" }); - expect(allRolesTrigger.className).toContain("data-active:bg-cyan-300"); - expect(allRolesTrigger.className).not.toContain("data-[state=active]:"); + const allRolesTab = screen.getByRole("tab", { name: "All Roles" }); + expect(allRolesTab.className).toContain("data-active:bg-cyan-300"); + expect(allRolesTab.className).not.toContain("data-[state=active]:"); }); it("ignores tab values that are not in the rendered role allowlist", () => { - const roles = [ - { id: "bass-guitar", name: "Bass Guitar" }, - { id: "lead-vocal", name: "Lead Vocal" } + const roleOptions = [ + { roleId: "bass-guitar", roleName: "Bass Guitar" }, + { roleId: "lead-vocal", roleName: "Lead Vocal" } ]; - expect(tabValueToRoleId("role:bass-guitar", roles)).toBe("bass-guitar"); - expect(tabValueToRoleId("role:unknown-role", roles)).toBeNull(); - expect(tabValueToRoleId("raw-unknown-role", roles)).toBeNull(); + expect(tabValueToRoleId("role:bass-guitar", roleOptions)).toBe("bass-guitar"); + expect(tabValueToRoleId("role:unknown-role", roleOptions)).toBeNull(); + expect(tabValueToRoleId("raw-unknown-role", roleOptions)).toBeNull(); + }); + + it("keeps the previous role projection behind the compatibility boundary", () => { + const roleChangeHandler = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByRole("tab", { name: "Legacy Bass" })); + expect(roleChangeHandler).toHaveBeenLastCalledWith("legacy-bass"); }); }); diff --git a/apps/desktop/src/features/workspace/RoleSwitcher.tsx b/apps/desktop/src/features/workspace/RoleSwitcher.tsx index f5275964d..e35f57c27 100644 --- a/apps/desktop/src/features/workspace/RoleSwitcher.tsx +++ b/apps/desktop/src/features/workspace/RoleSwitcher.tsx @@ -2,53 +2,95 @@ import { createTranslator, detectPreferredLocale } from "../../i18n"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Users } from "lucide-react"; -/** Renderable role option accepted by the role tab allowlist. */ +/** A selectable rehearsal-role projection owned by the workspace role switcher. */ export interface RehearsalRoleOption { + /** Stable role identity used by selection and tab-value mapping. */ + roleId: string; + /** Human-readable role name rendered in the role switcher. */ + roleName: string; +} + +/** Compatibility-only projection for the pre-naming-contract component API. */ +interface LegacyRehearsalRoleOption { id: string; name: string; } -interface RoleSwitcherProps { - roles: RehearsalRoleOption[]; +interface RoleSwitcherSharedProps { activeRole: string | null; onRoleChange: (roleId: string | null) => void; } +type RoleSwitcherProps = RoleSwitcherSharedProps & + ( + | { + roleOptions: RehearsalRoleOption[]; + roles?: never; + } + | { + roleOptions?: never; + /** @deprecated Use roleOptions with roleId/roleName. */ + roles: LegacyRehearsalRoleOption[]; + } + ); + const ALL_ROLES_VALUE = "__bandscope_all_roles__"; const ROLE_VALUE_PREFIX = "role:"; +/** Translate the legacy component projection at one compatibility boundary. */ +function normalizeLegacyRoleOptions( + legacyRoleOptions: LegacyRehearsalRoleOption[] +): RehearsalRoleOption[] { + return legacyRoleOptions.map((legacyRoleOption) => ({ + roleId: legacyRoleOption.id, + roleName: legacyRoleOption.name + })); +} + /** Documented. */ function roleTabValue(roleId: string): string { return `${ROLE_VALUE_PREFIX}${roleId}`; } /** Documented. */ -export function tabValueToRoleId(value: string, roles: RehearsalRoleOption[]): string | null { - if (value === ALL_ROLES_VALUE) { +export function tabValueToRoleId( + tabValue: string, + roleOptions: RehearsalRoleOption[] +): string | null { + if (tabValue === ALL_ROLES_VALUE) { return null; } - if (!value.startsWith(ROLE_VALUE_PREFIX)) { + if (!tabValue.startsWith(ROLE_VALUE_PREFIX)) { return null; } - const roleId = value.slice(ROLE_VALUE_PREFIX.length); - return roles.some((role) => role.id === roleId) ? roleId : null; + const roleId = tabValue.slice(ROLE_VALUE_PREFIX.length); + return roleOptions.some((roleOption) => roleOption.roleId === roleId) ? roleId : null; } /** Documented. */ -export function RoleSwitcher({ roles, activeRole, onRoleChange }: RoleSwitcherProps) { - const t = createTranslator(detectPreferredLocale()); +export function RoleSwitcher({ + roleOptions, + roles: legacyRoleOptions, + activeRole, + onRoleChange +}: RoleSwitcherProps) { + const resolvedRoleOptions = + roleOptions ?? normalizeLegacyRoleOptions(legacyRoleOptions ?? []); + const translatedText = createTranslator(detectPreferredLocale()); return (
onRoleChange(tabValueToRoleId(val, roles))} + onValueChange={(tabValue) => + onRoleChange(tabValueToRoleId(tabValue, resolvedRoleOptions)) + } className="w-full sm:w-auto" > @@ -56,15 +98,15 @@ export function RoleSwitcher({ roles, activeRole, onRoleChange }: RoleSwitcherPr value={ALL_ROLES_VALUE} className="rounded-md px-4 text-slate-300 data-active:bg-cyan-300 data-active:text-slate-950 data-active:shadow-[0_8px_24px_rgba(34,211,238,0.24)]" > - {t("allRoles")} + {translatedText("allRoles")} - {roles.map((role) => ( + {resolvedRoleOptions.map((roleOption) => ( - {role.name} + {roleOption.roleName} ))}