Skip to content

Commit 9ce3e65

Browse files
author
DavidQ
committed
Allow empty Text to Speech V2 payloads and persist deleted items - PR_26130_028-text-to-speech-v2-empty-array-save-fix
1 parent b97f707 commit 9ce3e65

5 files changed

Lines changed: 198 additions & 14 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# PR_26130_028-text-to-speech-v2-empty-array-save-fix
2+
3+
## Summary
4+
- Removed the Text to Speech V2 root array `minItems: 1` schema requirement so zero named speech items is a valid payload.
5+
- Updated Text to Speech V2 load/import/delete behavior so validated `[]` renders as a safe empty state without selecting a missing item or fabricating defaults.
6+
- Allowed Name input to act as draft Add input when no item is selected, without logging the no-selection name update failure.
7+
- Kept Add blocked with a visible failure when Name is empty.
8+
- Preserved schema-gated workspace dirty/write-back behavior while allowing `[]` to return to Workspace Manager V2 and save into `game.manifest.json`.
9+
10+
## Playwright Coverage
11+
- Typing Name with no selected item does not log `FAIL Text to Speech V2 name update failed: no named speech item is selected.`
12+
- Add still requires a populated Name.
13+
- Deleting the last named speech item produces a valid empty array state.
14+
- Text to Speech V2 schema validation accepts an empty root array.
15+
- Workspace return writes `[]` into the active Workspace Manager V2 manifest/toolState payload.
16+
- Workspace Manager Save persists `root.tools.text2speech-V2: []` into `game.manifest.json`.
17+
- Relaunching Text to Speech V2 from workspace with `[]` shows the safe empty state.
18+
19+
## Scope Notes
20+
- Scope was limited to Text to Speech V2, its schema, and direct Workspace Manager V2 integration coverage.
21+
- No unrelated tool schemas were changed.
22+
- No start_of_day changes.
23+
- No built-in default speech items were introduced.
24+
25+
## Validation
26+
- Passed: `npm run test:workspace-v2`
27+
- Result: 36 passed.
28+
- Playwright V8 coverage updated for changed runtime JS:
29+
- `(90%) tools/text2speech-V2/js/TextToSpeechToolApp.js - executed lines 807/807; executed functions 62/69`
30+
31+
## Skipped
32+
- Full samples smoke test was not run. Reason: this PR is limited to Text to Speech V2 empty-array save/load behavior and direct Workspace Manager V2 integration; the requested `npm run test:workspace-v2` suite covers the scoped behavior.
33+
34+
## Artifacts
35+
- Diff report: `docs/dev/reports/codex_review.diff`
36+
- Changed files report: `docs/dev/reports/codex_changed_files.txt`
37+
- Delta ZIP: `tmp/PR_26130_028-text-to-speech-v2-empty-array-save-fix_delta.zip`

games/Asteroids/game.manifest.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,8 @@
270270
}
271271
]
272272
}
273-
}
273+
},
274+
"text2speech-V2": []
274275
}
275276
}
276277
}

tests/playwright/tools/WorkspaceManagerV2.spec.mjs

Lines changed: 143 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1135,6 +1135,14 @@ test.describe("Workspace Manager V2 bootstrap", () => {
11351135
await expect(page.locator("#text2speech-V2StatusLog")).not.toHaveValue(/Text to Speech V2 default queue|Loaded 3 schema-complete/);
11361136
const emptySummary = JSON.parse(await page.locator("#text2speech-V2SpeechSummary").textContent());
11371137
expect(emptySummary).toEqual([]);
1138+
await page.locator("#text2speech-V2AddItemButton").click();
1139+
await expect(page.locator("#text2speech-V2StatusLog")).toHaveValue(/FAIL Text to Speech V2 Add blocked: Name is required before creating a named speech item\./);
1140+
await expect(page.locator("#text2speech-V2QueueTiles [data-speech-item-id]")).toHaveCount(0);
1141+
await page.locator("#text2speech-V2SpeechItemName").fill("Draft empty state line");
1142+
await expect(page.locator("#text2speech-V2StatusLog")).not.toHaveValue(/FAIL Text to Speech V2 name update failed: no named speech item is selected\./);
1143+
await page.locator("#text2speech-V2AddItemButton").click();
1144+
await expect(page.locator("#text2speech-V2QueueTiles [data-speech-item-id]")).toHaveCount(1);
1145+
await expect(page.locator('[data-speech-item-id="draft-empty-state-line"] .text2speech-V2__queue-tile-name')).toHaveText("Draft empty state line");
11381146
expect(pageErrors).toEqual([]);
11391147
} finally {
11401148
await coverageReporter.stop(page);
@@ -1431,7 +1439,7 @@ test.describe("Workspace Manager V2 bootstrap", () => {
14311439
});
14321440
expect(schemaRequiredFields.required).toEqual(REQUIRED_TEXT2SPEECH_OPTION_FIELDS);
14331441
expect(schemaRequiredFields.rootType).toBe("array");
1434-
expect(schemaRequiredFields.rootMinItems).toBe(1);
1442+
expect(schemaRequiredFields.rootMinItems).toBeUndefined();
14351443
expect(schemaRequiredFields.rootItemsRef).toBe("#/$defs/speechQueueItem");
14361444
expect(schemaRequiredFields.assetManagerSchemaType).toBe("object");
14371445
expect(schemaRequiredFields.paletteManagerSchemaType).toBe("object");
@@ -1468,6 +1476,7 @@ test.describe("Workspace Manager V2 bootstrap", () => {
14681476
length: TEXT_TO_SPEECH_SAMPLE_ITEM_IDS.length,
14691477
validation: { ok: true }
14701478
});
1479+
expect(await page.evaluate(() => window["__text2speech-V2App"].validatePayload([], "empty Text to Speech V2 root array"))).toEqual({ ok: true });
14711480
const validationSourceCleanup = await page.evaluate(async () => {
14721481
const source = await fetch("/tools/text2speech-V2/js/TextToSpeechToolApp.js", { cache: "no-store" }).then((response) => response.text());
14731482
return {
@@ -1768,6 +1777,16 @@ test.describe("Workspace Manager V2 bootstrap", () => {
17681777
});
17691778

17701779
test("deletes the last named sentence into a safe empty runtime state", async ({ page }) => {
1780+
await page.addInitScript(() => {
1781+
Object.defineProperty(navigator, "clipboard", {
1782+
configurable: true,
1783+
value: {
1784+
writeText: async (text) => {
1785+
window.__text2speechV2CopiedJson = text;
1786+
}
1787+
}
1788+
});
1789+
});
17711790
const server = await openTextToSpeechTool(page, TEXT_TO_SPEECH_SAMPLE_QUERY);
17721791
const pageErrors = [];
17731792

@@ -1788,9 +1807,11 @@ test.describe("Workspace Manager V2 bootstrap", () => {
17881807
await expect(page.locator("#text2speech-V2ResumeButton")).toBeDisabled();
17891808
await expect(page.locator("#text2speech-V2StopButton")).toBeDisabled();
17901809
expect(JSON.parse(await page.locator("#text2speech-V2SpeechSummary").textContent())).toEqual([]);
1791-
await expect(page.locator("#text2speech-V2StatusLog")).toHaveValue(/FAIL Text to Speech V2 empty state: add a named speech item before playback, export, copy, or workspace save\./);
1810+
await expect(page.locator("#text2speech-V2StatusLog")).toHaveValue(/OK Text to Speech V2 empty state: 0 named speech items\. Add a Name and click Add to create a new item\./);
1811+
await expect(page.locator("#text2speech-V2StatusLog")).not.toHaveValue(/must contain at least 1 item|before playback, export, copy, or workspace save|name update failed/);
17921812
await page.locator("#text2speech-V2CopyJsonButton").click();
1793-
await expect(page.locator("#text2speech-V2StatusLog")).toHaveValue(/FAIL Copy JSON blocked: Text to Speech V2 payload from Text to Speech V2 current UI payload failed tools\/schemas\/tools\/text2speech-V2\.schema\.json validation: root: must contain at least 1 item/);
1813+
await expect(page.locator("#text2speech-V2StatusLog")).toHaveValue(/OK Copied Text to Speech V2 JSON root array to clipboard \(0 items\)\./);
1814+
expect(await page.evaluate(() => JSON.parse(window.__text2speechV2CopiedJson))).toEqual([]);
17941815
expect(pageErrors).toEqual([]);
17951816
} finally {
17961817
await coverageReporter.stop(page);
@@ -2112,7 +2133,7 @@ test.describe("Workspace Manager V2 bootstrap", () => {
21122133
};
21132134
});
21142135
expect(schemaContract.rootType).toBe("array");
2115-
expect(schemaContract.rootMinItems).toBe(1);
2136+
expect(schemaContract.rootMinItems).toBeUndefined();
21162137
expect(schemaContract.rootItemsRef).toBe("#/$defs/speechQueueItem");
21172138
expect(schemaContract.itemAdditionalProperties).toBe(false);
21182139
expect(schemaContract.required).toEqual(REQUIRED_TEXT2SPEECH_OPTION_FIELDS);
@@ -2160,10 +2181,15 @@ test.describe("Workspace Manager V2 bootstrap", () => {
21602181
};
21612182
const oldRootObjectResult = await app.contextService.validateGeneratedManifest(oldRootObjectContext);
21622183
cases.push({ label: "old-root-object", ok: oldRootObjectResult.ok, message: oldRootObjectResult.message });
2184+
const emptyArrayContext = structuredClone(context);
2185+
emptyArrayContext.tools["text2speech-V2"] = [];
2186+
const emptyArrayResult = await app.contextService.validateGeneratedManifest(emptyArrayContext);
2187+
cases.push({ label: "empty-array", ok: emptyArrayResult.ok, message: emptyArrayResult.message });
21632188
await validate("valid-base", () => {});
21642189
return cases;
21652190
}, TEXT_TO_SPEECH_SAMPLE_PRESET_PATH);
21662191
expect(validationMessages.find((entry) => entry.label === "valid-base")).toMatchObject({ ok: true });
2192+
expect(validationMessages.find((entry) => entry.label === "empty-array")).toMatchObject({ ok: true });
21672193
expect(validationMessages.find((entry) => entry.label === "old-root-object").message).toMatch(/root\.tools\.text2speech-V2: expected array/);
21682194
expect(validationMessages.find((entry) => entry.label === "removed-fields").message).toMatch(/root\.tools\.text2speech-V2\[0\]\.autoSpeak is not allowed/);
21692195
expect(validationMessages.find((entry) => entry.label === "removed-fields").message).toMatch(/root\.tools\.text2speech-V2\[0\]\.queueMode is not allowed/);
@@ -3690,6 +3716,119 @@ test.describe("Workspace Manager V2 bootstrap", () => {
36903716
}
36913717
});
36923718

3719+
test("saves empty Text to Speech V2 arrays through workspace return and manifest write-back", async ({ page }) => {
3720+
const server = await openWorkspaceManagerV2(page);
3721+
const pageErrors = [];
3722+
3723+
page.on("pageerror", (error) => {
3724+
pageErrors.push(error.message);
3725+
});
3726+
3727+
try {
3728+
await installMockSpeechSynthesis(page);
3729+
await selectMockRepo(page);
3730+
await page.locator("#activeGameSelect").selectOption("Asteroids");
3731+
await expectWorkspaceReturnRehydrated(page);
3732+
const seededWorkspace = await page.evaluate(async (samplePresetPath) => {
3733+
const app = window.__workspaceManagerV2App;
3734+
const samplePayload = await fetch(samplePresetPath, { cache: "no-store" }).then((response) => response.json());
3735+
const payload = [structuredClone(samplePayload[0])];
3736+
const context = structuredClone(app.activeContext);
3737+
context.tools["text2speech-V2"] = payload;
3738+
const validation = await app.contextService.validateGeneratedManifest(context);
3739+
const hostContextId = app.contextService.writePersistedContext(app.activeHostContextId, context);
3740+
const metrics = app.contextSummaryMetrics(context);
3741+
app.applyContextResult({
3742+
assetCount: metrics.assetCount,
3743+
context,
3744+
game: app.activeGame,
3745+
hostContextId,
3746+
paletteSwatches: metrics.paletteSwatches
3747+
}, { requiresRepoHandle: app.activeToolStateRequiresRepoHandle });
3748+
return { hostContextId, validation };
3749+
}, TEXT_TO_SPEECH_SAMPLE_PRESET_PATH);
3750+
expect(seededWorkspace.validation).toEqual({ ok: true });
3751+
expect(await page.evaluate(async () => {
3752+
const app = window.__workspaceManagerV2App;
3753+
const context = structuredClone(app.activeContext);
3754+
context.tools["text2speech-V2"] = [];
3755+
return await app.contextService.validateGeneratedManifest(context);
3756+
})).toEqual({ ok: true });
3757+
3758+
await page.locator('[data-workspace-tool-id="text2speech-V2"]').click();
3759+
await expect(page).toHaveURL(/text2speech-V2\/index\.html.*launch=workspace/);
3760+
await expect(page.locator("#text2speech-V2QueueTiles [data-speech-item-id]")).toHaveCount(1);
3761+
await page.locator("#text2speech-V2DeleteItemButton").click();
3762+
await expect(page.locator("#text2speech-V2QueueTiles [data-speech-item-id]")).toHaveCount(0);
3763+
expect(JSON.parse(await page.locator("#text2speech-V2SpeechSummary").textContent())).toEqual([]);
3764+
await expect(page.locator("#text2speech-V2StatusLog")).toHaveValue(/OK Text to Speech V2 empty state: 0 named speech items\. Add a Name and click Add to create a new item\./);
3765+
await expect(page.locator("#text2speech-V2StatusLog")).toHaveValue(/OK Text to Speech V2 dirty state: true; reason=speech-item-deleted; changedKeys=queue; queue=0\./);
3766+
await expect(page.locator("#text2speech-V2StatusLog")).not.toHaveValue(/schema requires at least one named speech item|name update failed/);
3767+
3768+
await page.locator("#returnToWorkspaceButton").click();
3769+
await expect(page).toHaveURL(new RegExp(`workspace-manager-v2/index\\.html\\?hostContextId=${seededWorkspace.hostContextId}`));
3770+
await expectWorkspaceReturnedFromTool(page, { dirty: true });
3771+
const returnedState = await page.evaluate(() => {
3772+
const session = JSON.parse(sessionStorage.getItem("workspace.tools.text2speech-V2"));
3773+
const outputContext = JSON.parse(document.querySelector("#workspaceContextOutput").value);
3774+
return {
3775+
activePayload: window.__workspaceManagerV2App.activeContext.tools["text2speech-V2"],
3776+
outputPayload: outputContext.tools["text2speech-V2"],
3777+
sessionData: session.data,
3778+
sessionDirty: session.dirty
3779+
};
3780+
});
3781+
expect(returnedState.activePayload).toEqual([]);
3782+
expect(returnedState.outputPayload).toEqual([]);
3783+
expect(returnedState.sessionData).toEqual([]);
3784+
expect(returnedState.sessionDirty).toMatchObject({
3785+
isDirty: true,
3786+
reason: "speech-item-deleted",
3787+
changedKeys: ["queue"]
3788+
});
3789+
3790+
await page.locator("#saveWorkspaceButton").click();
3791+
await expect(page.locator("#statusLog")).toHaveValue(/OK Saved and marked clean: workspace\.tools\.text2speech-V2\./);
3792+
await expect(page.locator("#statusLog")).toHaveValue(/INFO Saved Text to Speech V2 payload count: 0\./);
3793+
await expect(page.locator("#statusLog")).toHaveValue(/INFO Saved toolState items: 4 \(asset-manager-v2 assets=14; palette-manager-v2 swatches=10; text2speech-V2 queue=0; vector-map-editor vectors=5\)\./);
3794+
await expect(page.locator("#statusLog")).toHaveValue(/OK Save validation result: game manifest valid; root game\.workspace toolState valid; saved context matched re-read file\./);
3795+
const savedState = await page.evaluate((hostContextId) => {
3796+
const writes = JSON.parse(sessionStorage.getItem("workspace.repo.manifestWrites") || "[]");
3797+
return {
3798+
activePayload: window.__workspaceManagerV2App.activeContext.tools["text2speech-V2"],
3799+
manifest: JSON.parse(writes.at(-1).contents),
3800+
savedContext: JSON.parse(sessionStorage.getItem(hostContextId)),
3801+
session: JSON.parse(sessionStorage.getItem("workspace.tools.text2speech-V2")),
3802+
writePath: writes.at(-1).path
3803+
};
3804+
}, seededWorkspace.hostContextId);
3805+
expect(savedState.writePath).toBe("HTML-JavaScript-Gaming/games/Asteroids/game.manifest.json");
3806+
expect(savedState.activePayload).toEqual([]);
3807+
expect(savedState.savedContext.tools["text2speech-V2"]).toEqual([]);
3808+
expect(savedState.session.data).toEqual([]);
3809+
expect(savedState.session.dirty).toEqual({
3810+
isDirty: false,
3811+
reason: null,
3812+
changedAt: null,
3813+
changedKeys: []
3814+
});
3815+
expect(savedState.manifest.game.workspace.tools["text2speech-V2"]).toEqual([]);
3816+
3817+
await page.locator('[data-workspace-tool-id="text2speech-V2"]').click();
3818+
await expect(page).toHaveURL(/text2speech-V2\/index\.html.*launch=workspace/);
3819+
await expect(page.locator("#text2speech-V2QueueTiles [data-speech-item-id]")).toHaveCount(0);
3820+
await expect(page.locator("#text2speech-V2SpeakButton")).toBeDisabled();
3821+
expect(JSON.parse(await page.locator("#text2speech-V2SpeechSummary").textContent())).toEqual([]);
3822+
await expect(page.locator("#text2speech-V2StatusLog")).toHaveValue(/OK Text to Speech V2 schema validation result: tools\/schemas\/tools\/text2speech-V2\.schema\.json valid; queue=0\./);
3823+
await expect(page.locator("#text2speech-V2StatusLog")).toHaveValue(/OK Loaded 0 schema-complete Text to Speech V2 queue items\./);
3824+
await expect(page.locator("#text2speech-V2StatusLog")).not.toHaveValue(/queue selection failed|schema requires at least one named speech item/);
3825+
expect(pageErrors).toEqual([]);
3826+
} finally {
3827+
await coverageReporter.stop(page);
3828+
await server.close();
3829+
}
3830+
});
3831+
36933832
test("keeps Preview Generator V2 repo writer after Asset Manager V2 deletes the preview asset entry", async ({ page }) => {
36943833
const server = await openWorkspaceManagerV2(page);
36953834
const pageErrors = [];

tools/schemas/tools/text2speech-V2.schema.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
"$id": "tools/schemas/tools/text2speech-V2.schema.json",
44
"title": "Text to Speech V2 Payload",
55
"type": "array",
6-
"minItems": 1,
76
"items": {
87
"$ref": "#/$defs/speechQueueItem"
98
},

tools/text2speech-V2/js/TextToSpeechToolApp.js

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,13 @@ export class TextToSpeechToolApp {
331331
return;
332332
}
333333
this.queueControl.populate(queueDataResult.payload);
334-
this.applyQueueItem(this.queueControl.selectedItem() || queueDataResult.payload[0], "queue-loaded");
334+
if (queueDataResult.payload.length > 0) {
335+
this.applyQueueItem(this.queueControl.selectedItem() || queueDataResult.payload[0], "queue-loaded");
336+
} else {
337+
this.textInput.setText("", { emit: false });
338+
this.refreshOutputSummary("queue-loaded-empty");
339+
this.statusLog.ok(`${TEXT_TO_SPEECH_DISPLAY_NAME} empty state: 0 named speech items. Add a Name and click Add to create a new item.`);
340+
}
335341
this.statusLog.ok(`Loaded ${TEXT_TO_SPEECH_DISPLAY_NAME} payload source: ${queueDataResult.sourcePath}.`);
336342
if (queueDataResult.sourceKind === "url-json") {
337343
this.statusLog.ok(`Loaded preset for ${TEXT_TO_SPEECH_DISPLAY_NAME}: ${queueDataResult.sourcePath}.`);
@@ -498,13 +504,10 @@ export class TextToSpeechToolApp {
498504
const nextData = this.queueControl.selectedQueue();
499505
if (this.payloadSchema) {
500506
const validation = this.validatePayload(nextData, toolState.workspace?.gameManifestPath || WORKSPACE_TOOL_STATE_KEY);
501-
if (!validation.ok && nextData.length > 0) {
507+
if (!validation.ok) {
502508
this.statusLog.fail(`Cannot mark ${TEXT_TO_SPEECH_DISPLAY_NAME} dirty: ${validation.message}`);
503509
return;
504510
}
505-
if (!validation.ok) {
506-
this.statusLog.fail(`${TEXT_TO_SPEECH_DISPLAY_NAME} payload is empty; schema requires at least one named speech item before export or workspace save.`);
507-
}
508511
}
509512
this.window.sessionStorage.setItem(WORKSPACE_TOOL_STATE_KEY, JSON.stringify({
510513
...toolState,
@@ -569,7 +572,13 @@ export class TextToSpeechToolApp {
569572
return;
570573
}
571574
this.queueControl.populate(payload);
572-
this.applyQueueItem(this.queueControl.selectedItem() || payload[0], "json-imported");
575+
if (payload.length > 0) {
576+
this.applyQueueItem(this.queueControl.selectedItem() || payload[0], "json-imported");
577+
} else {
578+
this.textInput.setText("", { emit: false });
579+
this.refreshOutputSummary("json-imported-empty");
580+
this.statusLog.ok(`${TEXT_TO_SPEECH_DISPLAY_NAME} empty state: 0 named speech items. Add a Name and click Add to create a new item.`);
581+
}
573582
this.refreshVoices("json-imported");
574583
this.refreshOutputSummary("json-imported");
575584
this.statusLog.ok(`Imported ${payload.length} ${TEXT_TO_SPEECH_DISPLAY_NAME} item${payload.length === 1 ? "" : "s"} from ${sourcePath}; schema validation result: ${TEXT_TO_SPEECH_SCHEMA_ID} valid.`);
@@ -649,7 +658,6 @@ export class TextToSpeechToolApp {
649658
}
650659
const selectedItem = this.queueControl.selectedItem();
651660
if (!selectedItem) {
652-
this.statusLog.fail(`${TEXT_TO_SPEECH_DISPLAY_NAME} name update failed: no named speech item is selected.`);
653661
return;
654662
}
655663
const itemName = String(name || "").trim();
@@ -697,7 +705,7 @@ export class TextToSpeechToolApp {
697705
} else {
698706
this.textInput.setText("", { emit: false });
699707
this.refreshOutputSummary("queue-empty");
700-
this.statusLog.fail(`${TEXT_TO_SPEECH_DISPLAY_NAME} empty state: add a named speech item before playback, export, copy, or workspace save.`);
708+
this.statusLog.ok(`${TEXT_TO_SPEECH_DISPLAY_NAME} empty state: 0 named speech items. Add a Name and click Add to create a new item.`);
701709
}
702710
this.markWorkspaceDirty("speech-item-deleted", ["queue"]);
703711
this.statusLog.ok(`Deleted speech item: ${selectedItem.name}.`);

0 commit comments

Comments
 (0)