From 95a748b31be7fb055019f5aa0257bb598dd77a0d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 09:43:57 +0900 Subject: [PATCH 1/5] test(workspace): require semantic role option identifiers --- .../features/workspace/RoleSwitcher.test.tsx | 45 +++++++++---------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/features/workspace/RoleSwitcher.test.tsx b/apps/desktop/src/features/workspace/RoleSwitcher.test.tsx index 575684c50..bad046190 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,48 @@ 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(); }); }); From 597feb6f9e205a3730ba59c4103775c2c6855600 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 09:45:48 +0900 Subject: [PATCH 2/5] test(workspace): require semantic role option prop --- apps/desktop/src/features/workspace/RoleSwitcher.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/RoleSwitcher.test.tsx b/apps/desktop/src/features/workspace/RoleSwitcher.test.tsx index bad046190..7a9bd71b8 100644 --- a/apps/desktop/src/features/workspace/RoleSwitcher.test.tsx +++ b/apps/desktop/src/features/workspace/RoleSwitcher.test.tsx @@ -18,7 +18,7 @@ describe("RoleSwitcher", () => { { roleId: "lead-vocal", roleName: "Lead Vocal" } ]; - render(); + render(); expect(screen.getByText("Role-specific View")).toBeInTheDocument(); expect(screen.getByRole("tab", { name: "All Roles" })).toBeInTheDocument(); @@ -31,7 +31,7 @@ describe("RoleSwitcher", () => { render( { it("uses the project-standard active tab data selector", () => { render( From 5711224cf41503fc08719989952ac6f4e632ad3b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 09:47:15 +0900 Subject: [PATCH 3/5] fix(workspace): use semantic role option fields --- .../src/features/workspace/RoleSwitcher.tsx | 72 ++++++++++++++----- 1 file changed, 56 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/features/workspace/RoleSwitcher.tsx b/apps/desktop/src/features/workspace/RoleSwitcher.tsx index f5275964d..becff6da4 100644 --- a/apps/desktop/src/features/workspace/RoleSwitcher.tsx +++ b/apps/desktop/src/features/workspace/RoleSwitcher.tsx @@ -4,51 +4,91 @@ import { Users } from "lucide-react"; /** Renderable role option accepted by the role tab allowlist. */ export interface RehearsalRoleOption { + roleId: string; + 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 +96,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} ))} From f06521e126c3af9bc2e56a7f53a3884636414822 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 09:48:03 +0900 Subject: [PATCH 4/5] test(workspace): cover legacy role option adapter --- .../src/features/workspace/RoleSwitcher.test.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/desktop/src/features/workspace/RoleSwitcher.test.tsx b/apps/desktop/src/features/workspace/RoleSwitcher.test.tsx index 7a9bd71b8..e56c4fff7 100644 --- a/apps/desktop/src/features/workspace/RoleSwitcher.test.tsx +++ b/apps/desktop/src/features/workspace/RoleSwitcher.test.tsx @@ -71,4 +71,19 @@ describe("RoleSwitcher", () => { 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"); + }); }); From 724dd0445039b6e99863b46535a8497c784699ab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 09:56:15 +0900 Subject: [PATCH 5/5] docs(workspace): describe semantic role option contract --- apps/desktop/src/features/workspace/RoleSwitcher.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/RoleSwitcher.tsx b/apps/desktop/src/features/workspace/RoleSwitcher.tsx index becff6da4..e35f57c27 100644 --- a/apps/desktop/src/features/workspace/RoleSwitcher.tsx +++ b/apps/desktop/src/features/workspace/RoleSwitcher.tsx @@ -2,9 +2,11 @@ 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; }