Repository navigation
feat(workspace): name tonight's first tacet on the map #1097
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from 1 commit
17db347
8638c76
81ca884
8b99511
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; | ||
| import { describe, expect, it } from "vitest"; | ||
| import { fillRangeCopy } from "./firstRangeSqueeze"; | ||
| import { firstTacet } from "./firstTacet"; | ||
|
|
||
| function withSitOut( | ||
| song: RehearsalSong, | ||
| roleId: string, | ||
| isActive: boolean | undefined = false | ||
| ): RehearsalSong { | ||
| return { | ||
| ...song, | ||
| sections: song.sections.map((section, index) => | ||
| index === 0 | ||
| ? { | ||
| ...section, | ||
| partGraph: section.partGraph.map((node) => { | ||
| if (node.role_id !== roleId) { | ||
| return node; | ||
| } | ||
| if (isActive === undefined) { | ||
| const rest: Record<string, unknown> = { | ||
| role_id: node.role_id, | ||
| handoff_to: node.handoff_to, | ||
| handoff_from: node.handoff_from | ||
| }; | ||
| return rest as RehearsalSong["sections"][number]["partGraph"][number]; | ||
| } | ||
| return { ...node, is_active: isActive }; | ||
| }) | ||
| } | ||
| : section | ||
| ) | ||
| }; | ||
| } | ||
|
|
||
| describe("firstTacet", () => { | ||
| it("returns null on the demo song where every graph node is active", () => { | ||
| expect(firstTacet(createDemoRehearsalSong())).toBeNull(); | ||
| }); | ||
|
|
||
| it("names the first explicit sit-out from existing part-graph evidence", () => { | ||
| expect(firstTacet(withSitOut(createDemoRehearsalSong(), "keys-right"))).toEqual({ | ||
| sectionLabel: "verse", | ||
| roleName: "Keyboard 1 Right Hand" | ||
| }); | ||
| }); | ||
|
|
||
| it("skips blank labels until a named sit-out exists", () => { | ||
| const song = withSitOut(createDemoRehearsalSong(), "keys-right"); | ||
| song.sections[0] = { ...song.sections[0]!, label: "none" as RehearsalSong["sections"][number]["label"] }; | ||
| expect(firstTacet(song)).toBeNull(); | ||
| }); | ||
|
|
||
| it("keeps the selected active part on tonight's first tacet", () => { | ||
| const song = withSitOut(createDemoRehearsalSong(), "lead-vocal"); | ||
| expect(firstTacet(song, "lead-vocal")).toEqual({ | ||
| sectionLabel: "verse", | ||
| roleName: "Lead Vocal" | ||
| }); | ||
| expect(firstTacet(song, "bass-guitar")).toBeNull(); | ||
| }); | ||
|
|
||
| it("ignores inherited is_active evidence", () => { | ||
| const song = createDemoRehearsalSong(); | ||
| const inherited = Object.create({ is_active: false, role_id: "keys-right" }) as RehearsalSong["sections"][number]["partGraph"][number]; | ||
| song.sections[0] = { | ||
| ...song.sections[0]!, | ||
| partGraph: [inherited, ...song.sections[0]!.partGraph] | ||
| }; | ||
| expect(firstTacet(song)).toBeNull(); | ||
| }); | ||
|
|
||
| it("does not treat a missing is_active flag as a sit-out", () => { | ||
| expect(firstTacet(withSitOut(createDemoRehearsalSong(), "keys-right", undefined))).toBeNull(); | ||
|
Check failure on line 75 in apps/desktop/src/features/workspace/firstTacet.test.ts
|
||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| }); | ||
|
|
||
| it("fails closed on malformed runtime roots", () => { | ||
| for (const malformed of [null, {}, { sections: {} }, { sections: [null] }]) { | ||
| expect(firstTacet(malformed as unknown as RehearsalSong)).toBeNull(); | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
| describe("tacet copy filling", () => { | ||
| it("keeps rehearsal values literal", () => { | ||
| expect( | ||
| fillRangeCopy("{roleName} sits out of {sectionLabel}.", { | ||
| roleName: "Bass {sectionLabel}", | ||
| sectionLabel: "verse" | ||
| }) | ||
| ).toBe("Bass {sectionLabel} sits out of verse."); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| import type { RehearsalSong } from "@bandscope/shared-types"; | ||
| import { meaningfulRangeText } from "./firstRangeSqueeze"; | ||
|
|
||
| /** Tonight's first named sit-out the selected part should take. */ | ||
| export type FirstTacet = { | ||
| sectionLabel: string; | ||
| roleName: string; | ||
| }; | ||
|
|
||
| /** Return whether an untrusted runtime value is a plain object record. */ | ||
| function isRuntimeObject(value: unknown): value is Record<string, unknown> { | ||
| return typeof value === "object" && value !== null && !Array.isArray(value); | ||
| } | ||
|
|
||
| /** Admit an own-property boolean `is_active` flag. Inherited evidence is isolated. */ | ||
| function ownActiveFlag(value: Record<string, unknown>): boolean | null { | ||
| if (!Object.prototype.hasOwnProperty.call(value, "is_active")) { | ||
| return null; | ||
| } | ||
| if (value.is_active === true) { | ||
| return true; | ||
| } | ||
| if (value.is_active === false) { | ||
| return false; | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| /** | ||
| * Resolve a named role from a section's own-property `roles` list. | ||
| * | ||
| * Blank, `none`, inherited, or non-string names are not rehearsal authority. | ||
| */ | ||
| function namedRoleOnSection( | ||
| sectionValue: Record<string, unknown>, | ||
| roleId: string | ||
| ): string | undefined { | ||
| if (!Array.isArray(sectionValue.roles)) { | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| return undefined; | ||
| } | ||
| for (const roleValue of sectionValue.roles) { | ||
| if (!isRuntimeObject(roleValue) || !Object.prototype.hasOwnProperty.call(roleValue, "id")) { | ||
| continue; | ||
| } | ||
| if (meaningfulRangeText(roleValue.id) !== roleId) { | ||
| continue; | ||
| } | ||
| return meaningfulRangeText( | ||
| Object.prototype.hasOwnProperty.call(roleValue, "name") ? roleValue.name : undefined | ||
| ); | ||
| } | ||
| return undefined; | ||
| } | ||
|
|
||
| /** | ||
| * Pick the first explicit sit-out a player should honor before playing. | ||
| * | ||
| * Uses existing `partGraph` `is_active: false` authority already produced by | ||
| * analysis. This is a tacet: the part does not play the named section. It is | ||
| * not a dropout (leaving after playing), handoff, Fine, or first breath. | ||
| * Inherited `is_active`, missing graph nodes, blank labels, and malformed | ||
| * roots fail closed. When a role is selected, only that part's own-property | ||
| * sit-out is named. | ||
| */ | ||
| export function firstTacet( | ||
| song: RehearsalSong | unknown, | ||
| activeRole: string | null = null | ||
| ): FirstTacet | null { | ||
| if (!isRuntimeObject(song) || !Array.isArray(song.sections)) { | ||
| return null; | ||
| } | ||
|
|
||
| for (const sectionValue of song.sections) { | ||
| if (!isRuntimeObject(sectionValue)) { | ||
| continue; | ||
| } | ||
| const sectionLabel = meaningfulRangeText(sectionValue.label); | ||
| if (!sectionLabel) { | ||
| continue; | ||
| } | ||
| if (!Array.isArray(sectionValue.partGraph)) { | ||
| continue; | ||
| } | ||
|
|
||
| for (const nodeValue of sectionValue.partGraph) { | ||
| if ( | ||
| !isRuntimeObject(nodeValue) || | ||
| !Object.prototype.hasOwnProperty.call(nodeValue, "role_id") | ||
| ) { | ||
| continue; | ||
| } | ||
| const roleId = meaningfulRangeText(nodeValue.role_id); | ||
| if (!roleId) { | ||
| continue; | ||
| } | ||
| if (activeRole && roleId !== activeRole) { | ||
| continue; | ||
| } | ||
| if (ownActiveFlag(nodeValue) !== false) { | ||
| continue; | ||
| } | ||
| const roleName = namedRoleOnSection(sectionValue, roleId); | ||
| if (!roleName) { | ||
| continue; | ||
|
github-actions[bot] marked this conversation as resolved.
Outdated
|
||
| } | ||
|
seonghobae marked this conversation as resolved.
|
||
| return { sectionLabel, roleName }; | ||
| } | ||
|
seonghobae marked this conversation as resolved.
|
||
| } | ||
|
|
||
| return null; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| # Tonight's first tacet | ||
|
|
||
| The ready rehearsal map names the first explicit sit-out from existing `partGraph` `is_active: false` evidence. This is the section a player should not play so the band does not fill a rest. It is not a dropout, handoff, Fine, or last-line breath. | ||
|
|
||
| ## Next action | ||
|
|
||
| - Named: stay out of the named section until the next named section. | ||
| - Missing: confirm who sits before the first section. | ||
|
|
||
| ## Security Notes | ||
|
|
||
| - Untrusted inputs: `RehearsalSong` JSON, section labels, `partGraph` nodes, `is_active`, role ids, and role names from analysis or a reopened project. | ||
| - Trust boundary: this helper never opens files, URLs, IPC, WebView, subprocesses, model artifacts, or export paths. It only admits own-property `is_active: false` on a named section. | ||
| - Allowlist: section labels and role names must be meaningful text. A missing graph node is not a tacet. Inherited `is_active` is isolated. When a role is selected, only that part's own-property sit-out is named. | ||
| - Safe failure: inherited flags, blank labels, missing names, and malformed roots return `null` so the workspace shows the missing-copy next action instead of crashing or inventing a sit-out. | ||
| - Logging/privacy: rejected or accepted sit-outs are not logged. Copy interpolation keeps rehearsal values literal. | ||
| - Tests: `firstTacet.test.ts` and the Workspace callout cover the demo all-active case, an explicit keys sit-out, selected-role scoping, inherited flags, missing `is_active`, and literal copy filling. |
Uh oh!
There was an error while loading. Please reload this page.