From ae669464bff3f5de2324737935564432ccd0b719 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Fri, 5 Jun 2026 20:15:07 +0100 Subject: [PATCH 1/5] Add merge characters feature Adds a merge operation to the Characters tab of show config. Clicking "Merge" on any character opens a modal with a searchable dropdown to select a destination character. On confirm, the backend reassigns all ScriptLinePart rows to the destination, updates CharacterGroup memberships (with deduplication), and deletes the source character (cascading its mic allocations). - POST /api/v1/show/character/merge endpoint with full validation - 11 new backend tests covering all error cases and merge side-effects - Merge button + modal in both client/ (Vue 2) and client-v3/ (Vue 3) - 5 new Playwright E2E tests in 06-show-config-characters.spec.ts Co-Authored-By: Claude Sonnet 4.6 --- .../tests/06-show-config-characters.spec.ts | 46 ++++ client-v3/src/stores/show.ts | 16 ++ .../views/show/config/ConfigCharacters.vue | 84 ++++++- client/src/store/modules/show.ts | 17 ++ .../views/show/config/ConfigCharacters.vue | 76 +++++- server/controllers/api/constants.py | 1 + server/controllers/api/show/characters.py | 97 +++++++- .../controllers/api/show/test_characters.py | 221 +++++++++++++++++- 8 files changed, 550 insertions(+), 8 deletions(-) diff --git a/client-v3/e2e/tests/06-show-config-characters.spec.ts b/client-v3/e2e/tests/06-show-config-characters.spec.ts index 94978a15..c8f4da98 100644 --- a/client-v3/e2e/tests/06-show-config-characters.spec.ts +++ b/client-v3/e2e/tests/06-show-config-characters.spec.ts @@ -82,6 +82,52 @@ test('deletes a character', async () => { }); }); +// ── Character Merge ──────────────────────────────────────────────────────── + +test('Merge button is visible for each character row', async () => { + const row = page.locator('tr', { has: page.locator('td:has-text("Hamlet")') }); + await expect(row.locator('button:has-text("Merge")')).toBeVisible(); +}); + +test('creates a character for merge testing', async () => { + await page.getByRole('button', { name: 'New Character', exact: true }).click(); + await waitForModal(page, 'New Character'); + await page.fill('.modal.show input[type="text"]', 'Horatio'); + await confirmModal(page); + await waitForModalClosed(page); + await expect(page.locator('td:has-text("Horatio")').first()).toBeVisible(); +}); + +test('merge modal opens with correct title', async () => { + const row = page.locator('tr', { has: page.locator('td:has-text("Horatio")') }); + await row.locator('button:has-text("Merge")').click(); + await waitForModal(page, /Merge Horatio/); + await cancelModal(page); + await waitForModalClosed(page); +}); + +test('merge OK button is disabled with no destination selected', async () => { + const row = page.locator('tr', { has: page.locator('td:has-text("Horatio")') }); + await row.locator('button:has-text("Merge")').click(); + await waitForModal(page, /Merge Horatio/); + const okBtn = page.locator('.modal.show .modal-footer button.btn-primary'); + await expect(okBtn).toBeDisabled(); + await cancelModal(page); + await waitForModalClosed(page); +}); + +test('merges a character into another', async () => { + const row = page.locator('tr', { has: page.locator('td:has-text("Horatio")') }); + await row.locator('button:has-text("Merge")').click(); + await waitForModal(page, /Merge Horatio/); + await page.locator('.modal.show .multiselect__input').fill('Hamlet'); + await page.locator('.modal.show .multiselect__option', { hasText: 'Hamlet' }).click(); + await confirmModal(page); + await waitForModalClosed(page); + await expect(page.locator('td:has-text("Horatio")')).not.toBeVisible({ timeout: 5_000 }); + await expect(page.locator('td:has-text("Hamlet")')).toBeVisible(); +}); + // ── Character Groups ────────────────────────────────────────────────────── test('switches to Character Groups sub-tab', async () => { diff --git a/client-v3/src/stores/show.ts b/client-v3/src/stores/show.ts index 319e8872..20df1639 100644 --- a/client-v3/src/stores/show.ts +++ b/client-v3/src/stores/show.ts @@ -301,6 +301,22 @@ export const useShowStore = defineStore('show', { } }, + async mergeCharacter(sourceId: number, destinationId: number): Promise { + const response = await fetch(makeURL('/api/v1/show/character/merge'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source_id: sourceId, destination_id: destinationId }), + }); + if (response.ok) { + // getCharacterGroupList calls getCharacterList internally + await this.getCharacterGroupList(); + toast.success('Merged character!'); + } else { + log.error('Unable to merge characters'); + toast.error('Unable to merge characters'); + } + }, + // Character Groups async getCharacterGroupList(): Promise { const response = await fetch(makeURL('/api/v1/show/character/group')); diff --git a/client-v3/src/views/show/config/ConfigCharacters.vue b/client-v3/src/views/show/config/ConfigCharacters.vue index 4a37dfd8..5fe5d89e 100644 --- a/client-v3/src/views/show/config/ConfigCharacters.vue +++ b/client-v3/src/views/show/config/ConfigCharacters.vue @@ -33,14 +33,21 @@ Edit + + Merge + Delete @@ -99,6 +106,33 @@ + +

+ Select a destination character. All script lines and group memberships from + {{ mergeSourceCharacter?.name }} will be transferred to the selected + character, and {{ mergeSourceCharacter?.name }} will be deleted. +

+ + + +
+ >(); const editCharacterModal = ref>(); +const mergeCharacterModal = ref>(); + +const mergingCharacter = ref(false); +const mergeSourceCharacter = ref(null); +const mergeDestinationObject = ref(null); const characterFields = [ 'name', @@ -194,6 +235,12 @@ const editRules = { editFormState: { name: { required } } }; const newV$ = useVuelidate(newRules, { newFormState }); const editV$ = useVuelidate(editRules, { editFormState }); +const mergeDestinationOptions = computed(() => + showStore.characterList.filter( + (c) => mergeSourceCharacter.value === null || c.id !== mergeSourceCharacter.value.id, + ), +); + const castOptions = computed(() => [ { value: null, text: 'Please select an option', disabled: true }, ...showStore.castList.map((c) => ({ @@ -274,6 +321,39 @@ async function onSubmitEdit(event: Event): Promise { } } +function openMergeForm(character: Character): void { + mergeSourceCharacter.value = character; + mergeDestinationObject.value = null; + mergeCharacterModal.value?.show(); +} + +function resetMergeForm(): void { + mergeSourceCharacter.value = null; + mergeDestinationObject.value = null; + mergingCharacter.value = false; +} + +async function onSubmitMerge(event: Event): Promise { + if (!mergeDestinationObject.value || mergingCharacter.value) { + event.preventDefault(); + return; + } + mergingCharacter.value = true; + try { + await showStore.mergeCharacter( + mergeSourceCharacter.value!.id, + mergeDestinationObject.value.id, + ); + mergeCharacterModal.value?.hide(); + resetMergeForm(); + } catch (error) { + log.error('Error merging character:', error); + event.preventDefault(); + } finally { + mergingCharacter.value = false; + } +} + async function deleteCharacter(character: Character): Promise { if (deletingCharacter.value) return; const ok = await confirm(`Are you sure you want to delete ${character.name}?`); diff --git a/client/src/store/modules/show.ts b/client/src/store/modules/show.ts index 462b1a2a..4d5069b3 100644 --- a/client/src/store/modules/show.ts +++ b/client/src/store/modules/show.ts @@ -212,6 +212,23 @@ const module: Module = { VueToast.$toast.error('Unable to delete character'); } }, + async MERGE_CHARACTER( + context, + { source_id, destination_id }: { source_id: number; destination_id: number }, + ) { + const response = await fetch(makeURL('/api/v1/show/character/merge'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source_id, destination_id }), + }); + if (response.ok) { + context.dispatch('GET_CHARACTER_GROUP_LIST'); + VueToast.$toast.success('Merged character!'); + } else { + log.error('Unable to merge characters'); + VueToast.$toast.error('Unable to merge characters'); + } + }, async UPDATE_CHARACTER(context, character: Partial) { const response = await fetch(`${makeURL('/api/v1/show/character')}`, { method: 'PATCH', diff --git a/client/src/views/show/config/ConfigCharacters.vue b/client/src/views/show/config/ConfigCharacters.vue index 8d57b93b..e8b7e689 100644 --- a/client/src/views/show/config/ConfigCharacters.vue +++ b/client/src/views/show/config/ConfigCharacters.vue @@ -29,14 +29,21 @@ Edit + + Merge + Delete @@ -62,6 +69,32 @@ + +

+ Select a destination character. All script lines and group memberships from + {{ mergeSourceCharacter ? mergeSourceCharacter.name : '' }} + will be transferred to the selected character, and it will be deleted. +

+ + + +
!(this as any).mergeSourceCharacter || c.id !== (this as any).mergeSourceCharacter.id, + ); + }, }, async mounted(): Promise { await Promise.all([(this as any).GET_CHARACTER_LIST(), (this as any).GET_CAST_LIST()]); @@ -308,12 +349,43 @@ export default defineComponent({ } } }, + openMergeForm(character: any): void { + this.mergeSourceCharacter = character.item; + this.mergeDestinationCharacter = null; + (this as any).$bvModal.show('merge-character'); + }, + resetMergeForm(): void { + this.mergeSourceCharacter = null; + this.mergeDestinationCharacter = null; + this.mergingCharacter = false; + }, + async onSubmitMerge(event: Event): Promise { + if (!this.mergeDestinationCharacter || this.mergingCharacter) { + event.preventDefault(); + return; + } + this.mergingCharacter = true; + try { + await (this as any).MERGE_CHARACTER({ + source_id: this.mergeSourceCharacter.id, + destination_id: this.mergeDestinationCharacter.id, + }); + (this as any).$bvModal.hide('merge-character'); + this.resetMergeForm(); + } catch (error) { + log.error('Error merging character:', error); + event.preventDefault(); + } finally { + this.mergingCharacter = false; + } + }, ...mapActions([ 'GET_CHARACTER_LIST', 'GET_CAST_LIST', 'ADD_CHARACTER', 'UPDATE_CHARACTER', 'DELETE_CHARACTER', + 'MERGE_CHARACTER', ]), }, }); diff --git a/server/controllers/api/constants.py b/server/controllers/api/constants.py index b487758e..df32ebe0 100644 --- a/server/controllers/api/constants.py +++ b/server/controllers/api/constants.py @@ -111,3 +111,4 @@ ERROR_NAME_ALREADY_TAKEN = "Name already taken" ERROR_TAG_NAME_EXISTS = "Tag name already exists (case-insensitive)" +ERROR_CANNOT_MERGE_SAME_CHARACTER = "Source and destination characters must be different" diff --git a/server/controllers/api/show/characters.py b/server/controllers/api/show/characters.py index 67258218..5bb35a2d 100644 --- a/server/controllers/api/show/characters.py +++ b/server/controllers/api/show/characters.py @@ -1,9 +1,10 @@ from collections import defaultdict -from sqlalchemy import select +from sqlalchemy import select, update from tornado import escape from controllers.api.constants import ( + ERROR_CANNOT_MERGE_SAME_CHARACTER, ERROR_CAST_MEMBER_NOT_FOUND, ERROR_CHARACTER_GROUP_NOT_FOUND, ERROR_CHARACTER_NOT_FOUND, @@ -12,7 +13,13 @@ ERROR_NAME_MISSING, ERROR_SHOW_NOT_FOUND, ) -from models.script import Script, ScriptLine, ScriptLineType, ScriptRevision +from models.script import ( + Script, + ScriptLine, + ScriptLinePart, + ScriptLineType, + ScriptRevision, +) from models.show import Cast, Character, CharacterGroup, Show from rbac.role import Role from schemas.schemas import CharacterGroupSchema, CharacterSchema @@ -186,6 +193,92 @@ async def delete(self): await self.finish({"message": ERROR_SHOW_NOT_FOUND}) +@ApiRoute("show/character/merge", ApiVersion.V1) +class CharacterMergeController(BaseAPIController): + @requires_show + @no_live_session + async def post(self): + """Merge a source character into a destination character. + + Reassigns all ScriptLinePart rows referencing the source character to + the destination character across all revisions, then adds the destination + to every CharacterGroup that contained the source (if not already a + member), and finally deletes the source character (which cascades away + mic allocations and secondary-table group memberships automatically). + + :raises HTTPError 400: If source_id or destination_id are missing, or + if the source and destination are the same character. + :raises HTTPError 404: If either character cannot be found, or if + either character does not belong to the current show. + """ + current_show = self.get_current_show() + show_id = current_show["id"] + + with self.make_session() as session: + show: Show = session.get(Show, show_id) + if not show: + self.set_status(404) + await self.finish({"message": ERROR_SHOW_NOT_FOUND}) + return + + self.requires_role(show, Role.WRITE) + data = escape.json_decode(self.request.body) + + source_id = data.get("source_id", None) + destination_id = data.get("destination_id", None) + + if not source_id or not destination_id: + self.set_status(400) + await self.finish({"message": ERROR_ID_MISSING}) + return + + if source_id == destination_id: + self.set_status(400) + await self.finish({"message": ERROR_CANNOT_MERGE_SAME_CHARACTER}) + return + + source: Character = session.get(Character, source_id) + if not source or source.show_id != show_id: + self.set_status(404) + await self.finish({"message": ERROR_CHARACTER_NOT_FOUND}) + return + + destination: Character = session.get(Character, destination_id) + if not destination or destination.show_id != show_id: + self.set_status(404) + await self.finish({"message": ERROR_CHARACTER_NOT_FOUND}) + return + + # Step 1: Bulk-update all ScriptLinePart rows across all revisions. + # synchronize_session=False is safe: we do not re-read these objects + # after the update within this transaction. + session.execute( + update(ScriptLinePart) + .where(ScriptLinePart.character_id == source_id) + .values(character_id=destination_id) + .execution_options(synchronize_session=False) + ) + + # Step 2: Add destination to every group the source belongs to, + # skipping groups where destination is already a member to avoid + # a composite-PK collision on the association table. + for group in source.character_groups: + if destination not in group.characters: + group.characters.append(destination) + + # Step 3: Delete source. SQLAlchemy cascades: + # - mic_allocations: via cascade="all, delete-orphan" + # - character_group_association rows: via secondary= M2M management + session.delete(source) + session.commit() + + self.set_status(200) + await self.finish({"message": "Successfully merged character"}) + + await self.application.ws_send_to_all("NOOP", "GET_CHARACTER_LIST", {}) + await self.application.ws_send_to_all("NOOP", "GET_CHARACTER_GROUP_LIST", {}) + + @ApiRoute("show/character/stats", ApiVersion.V1) class CharacterStatsController(BaseAPIController): async def get(self): diff --git a/server/test/controllers/api/show/test_characters.py b/server/test/controllers/api/show/test_characters.py index 0b2d213c..d4674d25 100644 --- a/server/test/controllers/api/show/test_characters.py +++ b/server/test/controllers/api/show/test_characters.py @@ -1,7 +1,15 @@ import tornado.escape +from sqlalchemy import select -from models.script import Script, ScriptRevision -from models.show import Show, ShowScriptType +from models.mics import Microphone, MicrophoneAllocation +from models.script import ( + Script, + ScriptLine, + ScriptLinePart, + ScriptLineType, + ScriptRevision, +) +from models.show import Act, Character, CharacterGroup, Scene, Show, ShowScriptType from test.conftest import DigiScriptTestCase @@ -59,3 +67,212 @@ def test_get_character_stats_no_script(self): response = self.fetch("/api/v1/show/character/stats") # Should get an error because there's no script self.assertNotEqual(200, response.code) + + +class TestCharacterMergeController(DigiScriptTestCase): + """Test suite for POST /api/v1/show/character/merge endpoint.""" + + def setUp(self): + super().setUp() + self.token = self._create_and_login_admin() + + with self._app.get_db().sessionmaker() as session: + show = Show(name="Test Show", script_mode=ShowScriptType.FULL) + session.add(show) + session.flush() + self.show_id = show.id + + source = Character(show_id=show.id, name="Source") + destination = Character(show_id=show.id, name="Destination") + session.add(source) + session.add(destination) + session.flush() + self.source_id = source.id + self.destination_id = destination.id + session.commit() + + self._app.digi_settings.settings["current_show"].set_value(self.show_id) + + def _merge(self, body: dict) -> object: + return self.fetch( + "/api/v1/show/character/merge", + method="POST", + body=tornado.escape.json_encode(body), + headers={"Authorization": f"Bearer {self.token}"}, + ) + + def test_merge_missing_source_id(self): + """POST with no source_id returns 400.""" + response = self._merge({"destination_id": self.destination_id}) + self.assertEqual(400, response.code) + + def test_merge_missing_destination_id(self): + """POST with no destination_id returns 400.""" + response = self._merge({"source_id": self.source_id}) + self.assertEqual(400, response.code) + + def test_merge_same_character(self): + """POST with identical source and destination returns 400.""" + response = self._merge( + {"source_id": self.source_id, "destination_id": self.source_id} + ) + self.assertEqual(400, response.code) + + def test_merge_source_not_found(self): + """POST with a nonexistent source_id returns 404.""" + response = self._merge( + {"source_id": 99999, "destination_id": self.destination_id} + ) + self.assertEqual(404, response.code) + + def test_merge_destination_not_found(self): + """POST with a nonexistent destination_id returns 404.""" + response = self._merge({"source_id": self.source_id, "destination_id": 99999}) + self.assertEqual(404, response.code) + + def test_merge_cross_show_source(self): + """POST with a source character from a different show returns 404.""" + with self._app.get_db().sessionmaker() as session: + other_show = Show(name="Other Show", script_mode=ShowScriptType.FULL) + session.add(other_show) + session.flush() + foreign_char = Character(show_id=other_show.id, name="Foreign") + session.add(foreign_char) + session.flush() + foreign_id = foreign_char.id + session.commit() + + response = self._merge( + {"source_id": foreign_id, "destination_id": self.destination_id} + ) + self.assertEqual(404, response.code) + + def test_merge_deletes_source(self): + """Successful merge removes the source character.""" + response = self._merge( + {"source_id": self.source_id, "destination_id": self.destination_id} + ) + self.assertEqual(200, response.code) + + with self._app.get_db().sessionmaker() as session: + remaining = session.scalars( + select(Character).where(Character.show_id == self.show_id) + ).all() + names = [c.name for c in remaining] + self.assertNotIn("Source", names) + self.assertIn("Destination", names) + + def test_merge_transfers_script_line_parts(self): + """Merge updates all ScriptLinePart rows for the source to the destination.""" + with self._app.get_db().sessionmaker() as session: + act = Act(show_id=self.show_id, name="Act 1") + session.add(act) + session.flush() + scene = Scene(show_id=self.show_id, act_id=act.id, name="Scene 1") + session.add(scene) + session.flush() + line = ScriptLine( + act_id=act.id, + scene_id=scene.id, + page=1, + line_type=ScriptLineType.DIALOGUE, + ) + session.add(line) + session.flush() + part = ScriptLinePart( + line_id=line.id, + part_index=0, + character_id=self.source_id, + ) + session.add(part) + session.flush() + part_id = part.id + session.commit() + + response = self._merge( + {"source_id": self.source_id, "destination_id": self.destination_id} + ) + self.assertEqual(200, response.code) + + with self._app.get_db().sessionmaker() as session: + updated_part = session.get(ScriptLinePart, part_id) + self.assertEqual(self.destination_id, updated_part.character_id) + + def test_merge_updates_character_groups(self): + """Merge adds destination to groups that contained the source.""" + with self._app.get_db().sessionmaker() as session: + source = session.get(Character, self.source_id) + group = CharacterGroup(show_id=self.show_id, name="Ensemble") + group.characters.append(source) + session.add(group) + session.flush() + group_id = group.id + session.commit() + + response = self._merge( + {"source_id": self.source_id, "destination_id": self.destination_id} + ) + self.assertEqual(200, response.code) + + with self._app.get_db().sessionmaker() as session: + group = session.get(CharacterGroup, group_id) + member_ids = [c.id for c in group.characters] + self.assertIn(self.destination_id, member_ids) + self.assertNotIn(self.source_id, member_ids) + + def test_merge_group_deduplication(self): + """Merge does not create a duplicate when destination is already in the group.""" + with self._app.get_db().sessionmaker() as session: + source = session.get(Character, self.source_id) + destination = session.get(Character, self.destination_id) + group = CharacterGroup(show_id=self.show_id, name="Royalty") + group.characters.append(source) + group.characters.append(destination) + session.add(group) + session.flush() + group_id = group.id + session.commit() + + response = self._merge( + {"source_id": self.source_id, "destination_id": self.destination_id} + ) + self.assertEqual(200, response.code) + + with self._app.get_db().sessionmaker() as session: + group = session.get(CharacterGroup, group_id) + member_ids = [c.id for c in group.characters] + self.assertEqual(1, len(member_ids)) + self.assertIn(self.destination_id, member_ids) + + def test_merge_deletes_mic_allocations(self): + """Merge deletes all mic allocations belonging to the source character.""" + with self._app.get_db().sessionmaker() as session: + mic = Microphone(show_id=self.show_id, name="Radio Mic 1") + session.add(mic) + session.flush() + act = Act(show_id=self.show_id, name="Act 1") + session.add(act) + session.flush() + scene = Scene(show_id=self.show_id, act_id=act.id, name="Scene 1") + session.add(scene) + session.flush() + alloc = MicrophoneAllocation( + mic_id=mic.id, + scene_id=scene.id, + character_id=self.source_id, + ) + session.add(alloc) + session.commit() + + response = self._merge( + {"source_id": self.source_id, "destination_id": self.destination_id} + ) + self.assertEqual(200, response.code) + + with self._app.get_db().sessionmaker() as session: + remaining = session.scalars( + select(MicrophoneAllocation).where( + MicrophoneAllocation.character_id == self.source_id + ) + ).all() + self.assertEqual(0, len(remaining)) From 6ee4b2f9371e01057eb074bb6036815694ac555a Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Fri, 5 Jun 2026 20:17:19 +0100 Subject: [PATCH 2/5] Fix linting and formatting issues Co-Authored-By: Claude Sonnet 4.6 --- client-v3/src/views/show/config/ConfigCharacters.vue | 9 +++------ client/src/store/modules/show.ts | 2 +- client/src/views/show/config/ConfigCharacters.vue | 3 ++- server/controllers/api/constants.py | 4 +++- server/controllers/api/show/characters.py | 4 +++- 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/client-v3/src/views/show/config/ConfigCharacters.vue b/client-v3/src/views/show/config/ConfigCharacters.vue index 5fe5d89e..be36dbd0 100644 --- a/client-v3/src/views/show/config/ConfigCharacters.vue +++ b/client-v3/src/views/show/config/ConfigCharacters.vue @@ -237,8 +237,8 @@ const editV$ = useVuelidate(editRules, { editFormState }); const mergeDestinationOptions = computed(() => showStore.characterList.filter( - (c) => mergeSourceCharacter.value === null || c.id !== mergeSourceCharacter.value.id, - ), + (c) => mergeSourceCharacter.value === null || c.id !== mergeSourceCharacter.value.id + ) ); const castOptions = computed(() => [ @@ -340,10 +340,7 @@ async function onSubmitMerge(event: Event): Promise { } mergingCharacter.value = true; try { - await showStore.mergeCharacter( - mergeSourceCharacter.value!.id, - mergeDestinationObject.value.id, - ); + await showStore.mergeCharacter(mergeSourceCharacter.value!.id, mergeDestinationObject.value.id); mergeCharacterModal.value?.hide(); resetMergeForm(); } catch (error) { diff --git a/client/src/store/modules/show.ts b/client/src/store/modules/show.ts index 4d5069b3..7c38f310 100644 --- a/client/src/store/modules/show.ts +++ b/client/src/store/modules/show.ts @@ -214,7 +214,7 @@ const module: Module = { }, async MERGE_CHARACTER( context, - { source_id, destination_id }: { source_id: number; destination_id: number }, + { source_id, destination_id }: { source_id: number; destination_id: number } ) { const response = await fetch(makeURL('/api/v1/show/character/merge'), { method: 'POST', diff --git a/client/src/views/show/config/ConfigCharacters.vue b/client/src/views/show/config/ConfigCharacters.vue index e8b7e689..4be8756a 100644 --- a/client/src/views/show/config/ConfigCharacters.vue +++ b/client/src/views/show/config/ConfigCharacters.vue @@ -263,7 +263,8 @@ export default defineComponent({ }, mergeDestinationOptions(): any[] { return (this as any).CHARACTER_LIST.filter( - (c: any) => !(this as any).mergeSourceCharacter || c.id !== (this as any).mergeSourceCharacter.id, + (c: any) => + !(this as any).mergeSourceCharacter || c.id !== (this as any).mergeSourceCharacter.id ); }, }, diff --git a/server/controllers/api/constants.py b/server/controllers/api/constants.py index df32ebe0..c171cc87 100644 --- a/server/controllers/api/constants.py +++ b/server/controllers/api/constants.py @@ -111,4 +111,6 @@ ERROR_NAME_ALREADY_TAKEN = "Name already taken" ERROR_TAG_NAME_EXISTS = "Tag name already exists (case-insensitive)" -ERROR_CANNOT_MERGE_SAME_CHARACTER = "Source and destination characters must be different" +ERROR_CANNOT_MERGE_SAME_CHARACTER = ( + "Source and destination characters must be different" +) diff --git a/server/controllers/api/show/characters.py b/server/controllers/api/show/characters.py index 5bb35a2d..d9b78188 100644 --- a/server/controllers/api/show/characters.py +++ b/server/controllers/api/show/characters.py @@ -276,7 +276,9 @@ async def post(self): await self.finish({"message": "Successfully merged character"}) await self.application.ws_send_to_all("NOOP", "GET_CHARACTER_LIST", {}) - await self.application.ws_send_to_all("NOOP", "GET_CHARACTER_GROUP_LIST", {}) + await self.application.ws_send_to_all( + "NOOP", "GET_CHARACTER_GROUP_LIST", {} + ) @ApiRoute("show/character/stats", ApiVersion.V1) From 47eb1fcf7235a78ec30d0a83d4bb50cc476d4944 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Fri, 5 Jun 2026 20:22:05 +0100 Subject: [PATCH 3/5] Fix missing cancelModal import in E2E spec; revert accidental modal change Co-Authored-By: Claude Sonnet 4.6 --- client-v3/e2e/tests/06-show-config-characters.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/client-v3/e2e/tests/06-show-config-characters.spec.ts b/client-v3/e2e/tests/06-show-config-characters.spec.ts index c8f4da98..0126c70d 100644 --- a/client-v3/e2e/tests/06-show-config-characters.spec.ts +++ b/client-v3/e2e/tests/06-show-config-characters.spec.ts @@ -8,6 +8,7 @@ import { waitForAppReady, waitForModal, confirmModal, + cancelModal, waitForModalClosed, confirmDialog, } from '../helpers.js'; From d05654fd23118be5808c3fb1c2414960db4f8e8b Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Fri, 5 Jun 2026 20:43:28 +0100 Subject: [PATCH 4/5] Fix E2E merge test: open multiselect before selecting, scope table assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The multiselect input is hidden until the dropdown is opened — click the container first. Scope the post-merge assertions to #character-table to avoid strict-mode violations from matching cells in the Line Counts tab, which BVN keeps in the DOM even when inactive. Co-Authored-By: Claude Sonnet 4.6 --- client-v3/e2e/tests/06-show-config-characters.spec.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/client-v3/e2e/tests/06-show-config-characters.spec.ts b/client-v3/e2e/tests/06-show-config-characters.spec.ts index 0126c70d..6162ce2a 100644 --- a/client-v3/e2e/tests/06-show-config-characters.spec.ts +++ b/client-v3/e2e/tests/06-show-config-characters.spec.ts @@ -121,12 +121,14 @@ test('merges a character into another', async () => { const row = page.locator('tr', { has: page.locator('td:has-text("Horatio")') }); await row.locator('button:has-text("Merge")').click(); await waitForModal(page, /Merge Horatio/); - await page.locator('.modal.show .multiselect__input').fill('Hamlet'); + // Open the dropdown by clicking the multiselect container, then select the option. + await page.locator('.modal.show .multiselect').click(); await page.locator('.modal.show .multiselect__option', { hasText: 'Hamlet' }).click(); await confirmModal(page); await waitForModalClosed(page); - await expect(page.locator('td:has-text("Horatio")')).not.toBeVisible({ timeout: 5_000 }); - await expect(page.locator('td:has-text("Hamlet")')).toBeVisible(); + // Scope to the character table to avoid matching cells in the Line Counts tab (always in DOM). + await expect(page.locator('#character-table td:has-text("Horatio")')).not.toBeVisible({ timeout: 5_000 }); + await expect(page.locator('#character-table td:has-text("Hamlet")')).toBeVisible(); }); // ── Character Groups ────────────────────────────────────────────────────── From c9158c4a884255804a15b41fda6e31ee60a607c0 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Fri, 5 Jun 2026 20:50:36 +0100 Subject: [PATCH 5/5] Fix Prettier formatting on E2E spec Co-Authored-By: Claude Sonnet 4.6 --- client-v3/e2e/tests/06-show-config-characters.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client-v3/e2e/tests/06-show-config-characters.spec.ts b/client-v3/e2e/tests/06-show-config-characters.spec.ts index 6162ce2a..234271b1 100644 --- a/client-v3/e2e/tests/06-show-config-characters.spec.ts +++ b/client-v3/e2e/tests/06-show-config-characters.spec.ts @@ -127,7 +127,9 @@ test('merges a character into another', async () => { await confirmModal(page); await waitForModalClosed(page); // Scope to the character table to avoid matching cells in the Line Counts tab (always in DOM). - await expect(page.locator('#character-table td:has-text("Horatio")')).not.toBeVisible({ timeout: 5_000 }); + await expect(page.locator('#character-table td:has-text("Horatio")')).not.toBeVisible({ + timeout: 5_000, + }); await expect(page.locator('#character-table td:has-text("Hamlet")')).toBeVisible(); });