-
Notifications
You must be signed in to change notification settings - Fork 0
feat(workspace): guide tonight's first tag on the rehearsal map #989
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
Open
seonghobae
wants to merge
33
commits into
develop
Choose a base branch
from
feat/workspace-first-tag
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
33 commits
Select commit
Hold shift + click to select a range
b2aa330
feat(workspace): guide tonight's first tag on the rehearsal map
seonghobae d17a93b
test(workspace): reject throwing tag metadata accessors
seonghobae 30552b6
test(workspace): reject cross-surface tag target
seonghobae e731168
fix(workspace): contain hostile tag metadata accessors
seonghobae bad3e4e
fix(workspace): scope first-tag navigation to map renderer
seonghobae d2d71b3
fix(workspace): anchor first-tag navigation to structure grid
seonghobae dcc13db
test(workspace): model scoped tag renderer in reduced-motion fixture
seonghobae 22acea8
test(workspace): model scoped tag renderer in particle fixture
seonghobae 7d3ffa9
fix(workspace): restore canonical workspace surface
seonghobae 93f6ae3
fix(workspace): bind first-tag navigation to rendered structure root
seonghobae 7dbdd01
test(workspace): scope first-tag fixture to rendered structure root
seonghobae 36c1962
test(workspace): mirror rendered structure root for reduced motion
seonghobae 9a96a02
test(workspace): mirror rendered structure root for Korean copy
seonghobae 047b3d9
docs(i18n): describe exported translation helpers
seonghobae 523ac83
test(workspace): reject accessor-backed tag identity
seonghobae 9ac1ad5
fix(workspace): require data-backed tag metadata
seonghobae 4e73ea3
docs(changelog): record first-tag metadata hardening
seonghobae 47c4541
test(workspace): contain hostile song identity accessor
seonghobae 041f702
fix(workspace): contain hostile song identity metadata
seonghobae 98b8605
test(coverage): include first-tag production files
seonghobae 8dc1528
docs(changelog): restore released 0.1.0 history
seonghobae 40556d1
test(workspace): require stable first-tag renderer hook
seonghobae 978b5f8
test(workspace): target first-tag navigation by semantic region
seonghobae d342f7b
fix(workspace): navigate first tag through semantic timeline
seonghobae afef071
test(workspace): reset first-tag state across hostile song ids
seonghobae ce69ca9
fix(workspace): key first-tag state by song instance
seonghobae 7969484
fix(workspace): narrow opened tag state
seonghobae b8d70dd
test(workspace): mount first-tag timeline region
seonghobae d054d9a
test(workspace): mirror first-tag renderer contract
seonghobae b567215
test(workspace): cover localized tag navigation target
seonghobae 86c80bc
fix(workspace): decouple tag navigation from accessible copy
seonghobae 6b83256
test(workspace): cover missing matchMedia fallback
seonghobae c52c0a8
Merge branch 'develop' into feat/workspace-first-tag
seonghobae File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
48 changes: 48 additions & 0 deletions
48
apps/desktop/src/features/workspace/FirstTagCallout.particle.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import { fireEvent, render, screen } from "@testing-library/react"; | ||
| import { createDemoRehearsalSong } from "@bandscope/shared-types"; | ||
| import { afterEach, describe, expect, it, vi } from "vitest"; | ||
| import { FirstTagCallout } from "./FirstTagCallout"; | ||
|
|
||
| describe("FirstTagCallout Korean role copy", () => { | ||
| afterEach(() => { | ||
| vi.unstubAllGlobals(); | ||
| }); | ||
|
|
||
| it("keeps vowel-ending dynamic role names particle-safe before and after the tag action", () => { | ||
| vi.stubGlobal("navigator", { language: "ko-KR" }); | ||
| const song = createDemoRehearsalSong(); | ||
| const seed = song.sections[0]!; | ||
| const tag = structuredClone(seed); | ||
| tag.id = "tag-particle"; | ||
| tag.label = "tag"; | ||
| tag.timeRange = { start: 200, end: 208 }; | ||
| tag.roles = [{ ...seed.roles[0]!, id: "piano", name: "피아노", rehearsalPriority: "high" }]; | ||
| tag.partGraph = [{ role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }]; | ||
| song.sections = [tag]; | ||
|
|
||
| const grid = document.createElement("div"); | ||
| grid.dataset.testid = "song-structure-grid"; | ||
| grid.setAttribute("role", "region"); | ||
| grid.setAttribute("aria-label", "Scrollable song structure timeline"); | ||
| const target = document.createElement("div"); | ||
| target.dataset.sectionIndex = "0"; | ||
| Object.defineProperty(target, "scrollIntoView", { | ||
| configurable: true, | ||
| value: vi.fn() | ||
| }); | ||
| grid.appendChild(target); | ||
| document.body.appendChild(grid); | ||
|
|
||
| render(<FirstTagCallout song={song} />); | ||
|
|
||
| expect(screen.getByText("3:20 태그에서 피아노 파트가 마지막 한 줄을 잡습니다.")).toBeTruthy(); | ||
| expect(screen.queryByText(/피아노이/)).toBeNull(); | ||
|
|
||
| fireEvent.click(screen.getByRole("button", { name: "3:20 피아노 태그 위치 열기" })); | ||
|
|
||
| expect(screen.getByText("3:20에서 피아노 파트와 함께 마지막 한 줄을 잡으세요. 같이 끝내세요.")).toBeTruthy(); | ||
| expect(screen.queryByText(/피아노과/)).toBeNull(); | ||
|
|
||
| grid.remove(); | ||
| }); | ||
| }); | ||
88 changes: 88 additions & 0 deletions
88
apps/desktop/src/features/workspace/FirstTagCallout.reduced-motion.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| import { fireEvent, render, screen } from "@testing-library/react"; | ||
| import { createDemoRehearsalSong } from "@bandscope/shared-types"; | ||
| import { afterEach, describe, expect, it, vi } from "vitest"; | ||
| import { FirstTagCallout } from "./FirstTagCallout"; | ||
|
|
||
| function songWithTag() { | ||
| const song = createDemoRehearsalSong(); | ||
| const verse = song.sections[0]!; | ||
| const tag = structuredClone(verse); | ||
| tag.id = "tag-1"; | ||
| tag.label = "tag"; | ||
| tag.timeRange = { start: 200, end: 208 }; | ||
| tag.roles = [ | ||
| { | ||
| ...verse.roles[0]!, | ||
| id: "lead-vocal", | ||
| name: "Lead Vocal", | ||
| rehearsalPriority: "high" | ||
| } | ||
| ]; | ||
| tag.partGraph = [ | ||
| { | ||
| role_id: "lead-vocal", | ||
| is_active: true, | ||
| handoff_to: [], | ||
| handoff_from: [] | ||
| } | ||
| ]; | ||
| song.sections = [verse, tag]; | ||
| return song; | ||
| } | ||
|
|
||
| function appendTagTarget() { | ||
| const grid = document.createElement("div"); | ||
| grid.dataset.testid = "song-structure-grid"; | ||
| grid.setAttribute("role", "region"); | ||
| grid.setAttribute("aria-label", "Scrollable song structure timeline"); | ||
| const first = document.createElement("div"); | ||
| first.dataset.sectionIndex = "0"; | ||
| const target = document.createElement("div"); | ||
| target.dataset.sectionIndex = "1"; | ||
| const scrollIntoView = vi.fn(); | ||
| Object.defineProperty(target, "scrollIntoView", { | ||
| configurable: true, | ||
| value: scrollIntoView | ||
| }); | ||
| grid.appendChild(first); | ||
| grid.appendChild(target); | ||
| document.body.appendChild(grid); | ||
| return { grid, scrollIntoView }; | ||
| } | ||
|
|
||
| describe("FirstTagCallout reduced motion", () => { | ||
| afterEach(() => { | ||
| vi.unstubAllGlobals(); | ||
| }); | ||
|
|
||
| it("scrolls immediately when the operating system requests reduced motion", () => { | ||
| vi.stubGlobal("matchMedia", (query: string) => ({ | ||
| matches: query === "(prefers-reduced-motion: reduce)", | ||
| media: query, | ||
| onchange: null, | ||
| addListener: vi.fn(), | ||
| removeListener: vi.fn(), | ||
| addEventListener: vi.fn(), | ||
| removeEventListener: vi.fn(), | ||
| dispatchEvent: vi.fn() | ||
| })); | ||
| const { grid, scrollIntoView } = appendTagTarget(); | ||
|
|
||
| render(<FirstTagCallout song={songWithTag()} />); | ||
| fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal tag at 3:20" })); | ||
| expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); | ||
|
|
||
| grid.remove(); | ||
| }); | ||
|
|
||
| it("uses smooth scrolling when matchMedia is unavailable", () => { | ||
| vi.stubGlobal("matchMedia", undefined); | ||
| const { grid, scrollIntoView } = appendTagTarget(); | ||
|
|
||
| render(<FirstTagCallout song={songWithTag()} />); | ||
| fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal tag at 3:20" })); | ||
| expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); | ||
|
|
||
| grid.remove(); | ||
| }); | ||
| }); |
225 changes: 225 additions & 0 deletions
225
apps/desktop/src/features/workspace/FirstTagCallout.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,225 @@ | ||
| import { fireEvent, render, screen } from "@testing-library/react"; | ||
| import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; | ||
| import { afterEach, describe, expect, it, vi } from "vitest"; | ||
| import { FirstTagCallout } from "./FirstTagCallout"; | ||
|
|
||
| function songWithTag() { | ||
| const song = createDemoRehearsalSong(); | ||
| const verse = song.sections[0]!; | ||
| const tag = structuredClone(verse); | ||
| tag.id = "tag-1"; | ||
| tag.label = "tag"; | ||
| tag.timeRange = { start: 200, end: 208 }; | ||
| tag.roles = [ | ||
| { | ||
| ...verse.roles[0]!, | ||
| id: "lead-vocal", | ||
| name: "Lead Vocal", | ||
| rehearsalPriority: "high" | ||
| } | ||
| ]; | ||
| tag.partGraph = [ | ||
| { | ||
| role_id: "lead-vocal", | ||
| is_active: true, | ||
| handoff_to: [], | ||
| handoff_from: [] | ||
| } | ||
| ]; | ||
| song.sections = [verse, tag]; | ||
| return song; | ||
| } | ||
|
|
||
| function appendSongStructureTarget(ariaLabel = "Scrollable song structure timeline") { | ||
| const timeline = document.createElement("div"); | ||
| timeline.setAttribute("role", "region"); | ||
| timeline.setAttribute("aria-label", ariaLabel); | ||
| const grid = document.createElement("div"); | ||
| grid.dataset.testid = "song-structure-grid"; | ||
| const first = document.createElement("div"); | ||
| first.dataset.sectionIndex = "0"; | ||
| const unrelatedSibling = document.createElement("div"); | ||
| const target = document.createElement("div"); | ||
| target.dataset.sectionIndex = "1"; | ||
| const scrollIntoView = vi.fn(); | ||
| Object.defineProperty(target, "scrollIntoView", { | ||
| configurable: true, | ||
| value: scrollIntoView | ||
| }); | ||
| grid.appendChild(first); | ||
| grid.appendChild(unrelatedSibling); | ||
| grid.appendChild(target); | ||
| timeline.appendChild(grid); | ||
| document.body.appendChild(timeline); | ||
| return { grid: timeline, scrollIntoView }; | ||
| } | ||
|
|
||
| describe("FirstTagCallout", () => { | ||
| afterEach(() => { | ||
| vi.unstubAllGlobals(); | ||
| }); | ||
|
|
||
| it("contains a malformed runtime song root instead of crashing the callout", () => { | ||
| render(<FirstTagCallout song={null as unknown as RehearsalSong} />); | ||
|
|
||
| expect( | ||
| screen.getByText("No tag yet. Stay on tonight's map until the last line is labeled.") | ||
| ).toBeTruthy(); | ||
| }); | ||
|
|
||
| it("contains a hostile song identity accessor instead of crashing the callout", () => { | ||
| const song = songWithTag(); | ||
| Object.defineProperty(song, "id", { | ||
| configurable: true, | ||
| enumerable: true, | ||
| get() { | ||
| throw new Error("hostile song id getter"); | ||
| } | ||
| }); | ||
|
|
||
| expect(() => render(<FirstTagCallout song={song} />)).not.toThrow(); | ||
| expect(screen.getByRole("button", { name: "Open Lead Vocal tag at 3:20" })).toBeTruthy(); | ||
| }); | ||
|
|
||
| it("resets armed guidance when accessor-id songs change with the same tag signature", () => { | ||
| const firstSong = songWithTag(); | ||
| const nextSong = songWithTag(); | ||
| for (const song of [firstSong, nextSong]) { | ||
| Object.defineProperty(song, "id", { | ||
| configurable: true, | ||
| enumerable: true, | ||
| get() { | ||
| throw new Error("hostile song id getter"); | ||
| } | ||
| }); | ||
| } | ||
| const { grid } = appendSongStructureTarget(); | ||
| const { rerender } = render(<FirstTagCallout song={firstSong} />); | ||
|
|
||
| fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal tag at 3:20" })); | ||
| expect(screen.getByText(/Catch the last line with Lead Vocal at 3:20. End together./)).toBeTruthy(); | ||
|
|
||
| rerender(<FirstTagCallout song={nextSong} />); | ||
|
|
||
| expect(screen.getByText("Lead Vocal holds the tag at 3:20.")).toBeTruthy(); | ||
| expect(screen.queryByText(/Catch the last line with Lead Vocal at 3:20. End together./)).toBeNull(); | ||
|
|
||
| grid.remove(); | ||
| }); | ||
|
|
||
| it("names the first tag as map navigation, scrolls to its rendered section, and arms that action", () => { | ||
| const { grid, scrollIntoView } = appendSongStructureTarget(); | ||
|
|
||
| render(<FirstTagCallout song={songWithTag()} />); | ||
|
|
||
| const action = screen.getByRole("button", { | ||
| name: "Open Lead Vocal tag at 3:20" | ||
| }); | ||
| expect(action).toBeTruthy(); | ||
| fireEvent.click(action); | ||
| expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); | ||
| expect(screen.getByText(/Catch the last line with Lead Vocal at 3:20. End together./)).toBeTruthy(); | ||
|
|
||
| grid.remove(); | ||
| }); | ||
|
|
||
| it("keeps map navigation stable when the renderer accessible name is localized", () => { | ||
| const { grid, scrollIntoView } = appendSongStructureTarget("스크롤 가능한 곡 구조 타임라인"); | ||
|
|
||
| render(<FirstTagCallout song={songWithTag()} />); | ||
|
|
||
| fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal tag at 3:20" })); | ||
|
|
||
| expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); | ||
| expect(screen.getByText(/Catch the last line with Lead Vocal at 3:20. End together./)).toBeTruthy(); | ||
|
|
||
| grid.remove(); | ||
| }); | ||
|
|
||
| it("does not claim map navigation completed when the rendered section target is missing", () => { | ||
| render(<FirstTagCallout song={songWithTag()} />); | ||
|
|
||
| fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal tag at 3:20" })); | ||
|
|
||
| expect(screen.getByText("Lead Vocal holds the tag at 3:20.")).toBeTruthy(); | ||
| expect(screen.queryByText(/Catch the last line with Lead Vocal at 3:20. End together./)).toBeNull(); | ||
| }); | ||
|
|
||
| it("navigates by renderer-owned section position instead of untrusted analysis ids", () => { | ||
| const song = songWithTag(); | ||
| song.sections[1]!.id = "analysis section / duplicate"; | ||
| const { grid, scrollIntoView } = appendSongStructureTarget(); | ||
|
|
||
| render(<FirstTagCallout song={song} />); | ||
|
|
||
| fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal tag at 3:20" })); | ||
| expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); | ||
|
|
||
| grid.remove(); | ||
| }); | ||
|
|
||
| it("scopes map navigation to the song-structure renderer when another surface reuses an index", () => { | ||
| const decoy = document.createElement("div"); | ||
| decoy.dataset.sectionIndex = "1"; | ||
| const decoyScrollIntoView = vi.fn(); | ||
| Object.defineProperty(decoy, "scrollIntoView", { | ||
| configurable: true, | ||
| value: decoyScrollIntoView | ||
| }); | ||
| document.body.appendChild(decoy); | ||
| const { grid, scrollIntoView } = appendSongStructureTarget(); | ||
|
|
||
| render(<FirstTagCallout song={songWithTag()} />); | ||
|
|
||
| fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal tag at 3:20" })); | ||
|
|
||
| expect(decoyScrollIntoView).not.toHaveBeenCalled(); | ||
| expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); | ||
|
|
||
| decoy.remove(); | ||
| grid.remove(); | ||
| }); | ||
|
|
||
| it("shows fresh guidance when the first tag changes or returns later", () => { | ||
| const initialSong = songWithTag(); | ||
| const { grid } = appendSongStructureTarget(); | ||
| const { rerender } = render(<FirstTagCallout song={initialSong} />); | ||
| fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal tag at 3:20" })); | ||
| expect(screen.getByText(/Catch the last line with Lead Vocal at 3:20. End together./)).toBeTruthy(); | ||
|
|
||
| const nextSong = songWithTag(); | ||
| nextSong.id = "next-song"; | ||
| nextSong.sections[1]!.timeRange = { start: 220, end: 228 }; | ||
| rerender(<FirstTagCallout song={nextSong} />); | ||
| expect(screen.getByText("Lead Vocal holds the tag at 3:40.")).toBeTruthy(); | ||
|
|
||
| grid.remove(); | ||
| }); | ||
|
|
||
| it("keeps an unavailable tag guidance-only", () => { | ||
| render(<FirstTagCallout song={createDemoRehearsalSong()} />); | ||
| expect(screen.queryByRole("button")).toBeNull(); | ||
| expect( | ||
| screen.getByText("No tag yet. Stay on tonight's map until the last line is labeled.") | ||
| ).toBeTruthy(); | ||
| }); | ||
|
|
||
| it("names a band-wide last line when no part holds the tag", () => { | ||
| const song = songWithTag(); | ||
| song.sections[1]!.partGraph[0]!.is_active = false; | ||
| render(<FirstTagCallout song={song} />); | ||
| expect(screen.getByRole("button", { name: "Open the first tag at 3:20" })).toBeTruthy(); | ||
| expect(screen.getByText("The band catches the tag at 3:20.")).toBeTruthy(); | ||
| }); | ||
|
|
||
| it("localizes the tag form label instead of exposing its raw enum in Korean copy", () => { | ||
| vi.stubGlobal("navigator", { language: "ko-KR" }); | ||
| const song = songWithTag(); | ||
| song.sections[1]!.roles[0]!.name = "리드 보컬"; | ||
|
|
||
| render(<FirstTagCallout song={song} />); | ||
|
|
||
| expect(screen.getByText("3:20 태그에서 리드 보컬 파트가 마지막 한 줄을 잡습니다.")).toBeTruthy(); | ||
| expect(screen.queryByText(/tag에서/)).toBeNull(); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.