diff --git a/src/components/color/pf-palette-panel.ts b/src/components/color/pf-palette-panel.ts index f93bfd07..0960fc75 100644 --- a/src/components/color/pf-palette-panel.ts +++ b/src/components/color/pf-palette-panel.ts @@ -66,6 +66,8 @@ export class PFPalettePanel extends BaseComponent { @state() private saveDialogTitle = 'Save Palette'; @state() private saveDialogDefaultName = ''; @state() private isRenaming = false; + @state() private isSaveDialogPending = false; + @state() private saveDialogError = ''; // Unsaved changes dialog state @state() private showUnsavedDialog = false; @@ -110,6 +112,8 @@ export class PFPalettePanel extends BaseComponent { this.saveDialogDefaultName = ''; } this.isRenaming = false; + this.isSaveDialogPending = false; + this.saveDialogError = ''; this.showSaveDialog = true; } @@ -118,6 +122,8 @@ export class PFPalettePanel extends BaseComponent { this.saveDialogTitle = 'Rename Palette'; this.saveDialogDefaultName = context.palette.getCurrentPaletteName(); this.isRenaming = true; + this.isSaveDialogPending = false; + this.saveDialogError = ''; this.showSaveDialog = true; } @@ -132,23 +138,39 @@ export class PFPalettePanel extends BaseComponent { // ========================================== private async handleSaveDialogSave(e: CustomEvent) { + if (this.isSaveDialogPending) return; const { name } = e.detail; const palette = this.saveDialogContext.palette; - if (this.isRenaming) { - const customId = palette.currentCustomPaletteId.value; - if (customId) { - await palette.renameCustomPalette(customId, name); + this.isSaveDialogPending = true; + this.saveDialogError = ''; + try { + if (this.isRenaming) { + const customId = palette.currentCustomPaletteId.value; + if (customId) { + await palette.renameCustomPalette(customId, name); + } + } else { + await palette.saveAsNewPalette(name); } - } else { - await palette.saveAsNewPalette(name); + this.showSaveDialog = false; + } catch { + this.saveDialogError = this.isRenaming + ? 'Palette could not be renamed. Try again.' + : 'Palette could not be saved. Try again.'; + } finally { + this.isSaveDialogPending = false; } - - this.showSaveDialog = false; } private handleSaveDialogCancel() { + if (this.isSaveDialogPending) return; this.showSaveDialog = false; + this.saveDialogError = ''; + } + + private handleSaveDialogNameInput() { + this.saveDialogError = ''; } // ========================================== @@ -294,8 +316,11 @@ export class PFPalettePanel extends BaseComponent { ?open=${this.showSaveDialog} .title=${this.saveDialogTitle} .defaultName=${this.saveDialogDefaultName} + .pending=${this.isSaveDialogPending} + .errorMessage=${this.saveDialogError} @save=${this.handleSaveDialogSave} @cancel=${this.handleSaveDialogCancel} + @name-input=${this.handleSaveDialogNameInput} > -
e.stopPropagation()}> +
e.stopPropagation()} + >

${this.title}

- + - ${this.error ? html` -
${this.error}
+ ${visibleError ? html` + ` : nothing}
- + + ?disabled=${this.pending || !this.name.trim()} + >${this.pending ? 'Saving…' : 'Save'}
diff --git a/src/components/dialogs/pf-paint-by-number-dialog.ts b/src/components/dialogs/pf-paint-by-number-dialog.ts index 07d55a2b..747534d8 100644 --- a/src/components/dialogs/pf-paint-by-number-dialog.ts +++ b/src/components/dialogs/pf-paint-by-number-dialog.ts @@ -1,15 +1,22 @@ -import { css, html } from 'lit'; +import { css, html, type PropertyValues } from 'lit'; import { customElement, property, query, state } from 'lit/decorators.js'; import { BaseComponent } from '../../core/base-component'; +import { PRESET_PALETTES } from '../../data/preset-palettes'; import { checkboxStyles } from '../../styles/editor-control-styles'; import { generateNumberedGuide, + MAX_GUIDE_COLORS, type NumberedGuide, } from '../../services/paint-by-number/guide-generator'; import { createGuidedProject } from '../../services/paint-by-number/guided-project'; import { decodeImageFile } from '../../services/paint-by-number/image-file'; import { sampleImageToGrid } from '../../services/paint-by-number/image-sampling'; -import { getActiveProjectContext } from '../../stores/project-context'; +import { + loadCustomPalettesOrThrow, + saveAsNewPalette, +} from '../../stores/palette/persistence'; +import { MAX_PALETTE_SIZE } from '../../stores/palette/types'; +import type { CustomPalette } from '../../types/palette'; import type { GuidedColorMapping, GuidedDrawingSettings, @@ -18,6 +25,9 @@ import type { import '../ui/pf-dialog'; const PREVIEW_DEBOUNCE_MS = 180; +const GENERATED_PALETTE_VALUE = 'generated'; +const CREATE_PALETTE_VALUE = 'create'; +const DEFAULT_NEW_COLOR = '#000000'; const GUIDE_PRESETS = [ { label: 'Compact', longSide: 16, maxColors: 6 }, @@ -61,6 +71,36 @@ export class PFPaintByNumberDialog extends BaseComponent { gap: 6px; } + .palette-colors { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; + } + + .palette-color { + display: grid; + grid-template-columns: minmax(0, 1fr) repeat(3, auto); + gap: 8px; + align-items: center; + } + + .palette-color input[type='color'] { + min-height: 38px; + padding: 3px; + } + + .palette-color button { + min-width: 34px; + padding-inline: 8px; + } + + .palette-create-actions { + display: flex; + justify-content: flex-start; + } + input, select { box-sizing: border-box; @@ -77,6 +117,7 @@ export class PFPaintByNumberDialog extends BaseComponent { input:focus-visible, select:focus-visible, button:focus-visible, + .palette-status:focus-visible, summary:focus-visible { outline: 2px solid var(--pf-color-accent); outline-offset: 2px; @@ -217,6 +258,10 @@ export class PFPaintByNumberDialog extends BaseComponent { color: var(--pf-color-danger, #f0aaa2); } + .palette-status { + min-height: 1.45em; + } + button { padding: 7px 12px; border-radius: var(--pf-radius-sm); @@ -257,6 +302,16 @@ export class PFPaintByNumberDialog extends BaseComponent { @state() private longSide = 24; @state() private maxColors = 8; @state() private paletteSource: GuidedPaletteSource = 'generated'; + @state() private selectedPalette = GENERATED_PALETTE_VALUE; + @state() private restrictedPalette: string[] | undefined; + @state() private customPalettes: CustomPalette[] = []; + @state() private isLoadingPalettes = false; + @state() private isCreatingPalette = false; + @state() private isSavingPalette = false; + @state() private paletteName = ''; + @state() private paletteColors: string[] = []; + @state() private paletteMessage = ''; + @state() private paletteError = ''; @state() private mapping: GuidedColorMapping = 'color'; @state() private simplifyIsolatedPixels = true; @state() private isGenerating = false; @@ -265,155 +320,322 @@ export class PFPaintByNumberDialog extends BaseComponent { @state() private errorMessage = ''; @query('#source-preview') private sourceCanvas?: HTMLCanvasElement; @query('#guide-preview') private guideCanvas?: HTMLCanvasElement; + @query('#guided-palette-source') private paletteSelect?: HTMLSelectElement; + @query('#guided-palette-name') private paletteNameInput?: HTMLInputElement; + @query('#guided-palette-creation-status') + private paletteCreationStatus?: HTMLParagraphElement; private previewTimer: number | null = null; private requestVersion = 0; + private paletteLoadVersion = 0; disconnectedCallback() { super.disconnectedCallback(); this.cancelPendingPreview(); } - protected updated(): void { + protected updated(changedProperties: PropertyValues): void { + super.updated(changedProperties); this.drawPreviews(); + if (changedProperties.has('open') && this.open) { + void this.loadPaletteChoices(); + } } render() { return html` Guided drawing -
- - -
- Guide preset -
- ${GUIDE_PRESETS.map((preset) => html` - - `)} -
-
- -
- Advanced settings -
- - - - - - - + ${this.isCreatingPalette + ? this.renderPaletteCreation() + : this.renderGuidedSetup()} + ${this.renderActions()} + + `; + } - + private renderGuidedSetup() { + return html` + + + +
+ Guide preset +
+ ${GUIDE_PRESETS.map((preset) => html` + + `)} +
+
+ +
+ Advanced settings +
+ + + + +
+ + + + ${this.paletteError || this.paletteMessage} +
-
-
- ${this.renderPreviewFigure('Source', 'source-preview', Boolean(this.sourceImage))} - ${this.renderPreviewFigure('Guide', 'guide-preview', Boolean(this.guide))} + + +
+
+ +
+ ${this.renderPreviewFigure('Source', 'source-preview', Boolean(this.sourceImage))} + ${this.renderPreviewFigure('Guide', 'guide-preview', Boolean(this.guide))} +
-

+ ${this.errorMessage || this.statusMessage} +

+
+ `; + } + + private renderPaletteCreation() { + return html` +
+

+ Create a reusable palette without leaving Guided Drawing. Your image and guide settings + stay unchanged. +

+ -
+
+ Colors +
    + ${this.paletteColors.map((color, index) => html` +
  1. + this.updatePaletteColor(index, event)} + > + + + +
  2. + `)} +
+
+ +
+
+ +

+ ${this.paletteError || this.paletteMessage} +

+ + `; + } + private renderActions() { + if (this.isCreatingPalette) { + return html`
- +
-
+ `; + } + + return html` +
+ + +
`; } @@ -477,9 +699,14 @@ export class PFPaintByNumberDialog extends BaseComponent { this.schedulePreview(); }; - private handlePaletteSourceChange = (event: Event) => { - this.paletteSource = (event.currentTarget as HTMLSelectElement).value as GuidedPaletteSource; - this.schedulePreview(); + private handlePaletteSelection = (event: Event) => { + const value = (event.currentTarget as HTMLSelectElement).value; + if (value === CREATE_PALETTE_VALUE) { + this.beginPaletteCreation(); + return; + } + + this.selectPalette(value); }; private handleMappingChange = (event: Event) => { @@ -511,12 +738,12 @@ export class PFPaintByNumberDialog extends BaseComponent { try { const sampled = sampleImageToGrid(this.sourceImage, { longSide: this.longSide }); - const restrictedPalette = this.getRestrictedPalette(); + const settings = this.currentSettings(); const guide = generateNumberedGuide(sampled, { - maxColors: this.maxColors, - palette: restrictedPalette, - mapping: this.mapping, - simplifyIsolatedPixels: this.simplifyIsolatedPixels, + maxColors: settings.maxColors, + palette: settings.restrictedPalette, + mapping: settings.mapping, + simplifyIsolatedPixels: settings.simplifyIsolatedPixels, }); if (guide.palette.length === 0) { throw new Error('The image has no visible paintable pixels.'); @@ -538,9 +765,224 @@ export class PFPaintByNumberDialog extends BaseComponent { private getRestrictedPalette(): string[] | undefined { if (this.paletteSource !== 'restricted') return undefined; - return [...getActiveProjectContext().palette.mainColors.value]; + return this.restrictedPalette ? [...this.restrictedPalette] : undefined; + } + + private async loadPaletteChoices() { + const version = ++this.paletteLoadVersion; + this.isLoadingPalettes = true; + this.paletteError = ''; + this.paletteMessage = 'Loading saved palettes…'; + + try { + const palettes = await loadCustomPalettesOrThrow(); + if (version !== this.paletteLoadVersion) return; + this.applyLoadedPaletteChoices(palettes); + } catch { + if (version !== this.paletteLoadVersion) return; + this.paletteMessage = ''; + this.paletteError = 'Saved palettes could not be loaded. Preset palettes are still available.'; + } finally { + if (version === this.paletteLoadVersion) this.isLoadingPalettes = false; + } + } + + private applyLoadedPaletteChoices(palettes: CustomPalette[]) { + this.customPalettes = palettes; + if ( + this.selectedPalette.startsWith('custom:') + && !palettes.some((palette) => `custom:${palette.id}` === this.selectedPalette) + ) { + this.selectPalette(GENERATED_PALETTE_VALUE); + } + + const selectedPalette = this.findPalette(this.selectedPalette); + if (selectedPalette) { + if (this.selectedPalette.startsWith('custom:')) { + if (selectedPalette.colors.length === 0) { + this.selectPalette(GENERATED_PALETTE_VALUE); + this.paletteError = 'This saved palette can no longer be used because it needs at least one color. Generated colors are selected instead.'; + return; + } + this.selectPalette(this.selectedPalette); + return; + } + this.paletteMessage = this.describePaletteAdaptation(selectedPalette.colors.length); + return; + } + this.paletteMessage = palettes.length > 0 + ? `${palettes.length} saved ${palettes.length === 1 ? 'palette' : 'palettes'} available.` + : 'No saved palettes yet.'; } + private selectPalette(value: string) { + if (value === GENERATED_PALETTE_VALUE) { + this.selectedPalette = value; + this.paletteSource = 'generated'; + this.restrictedPalette = undefined; + this.paletteError = ''; + this.schedulePreview(); + return; + } + + const palette = this.findPalette(value); + if (!palette) { + this.paletteError = 'That palette is no longer available.'; + return; + } + + if (value.startsWith('custom:') && palette.colors.length === 0) { + if (this.paletteSelect) this.paletteSelect.value = this.selectedPalette; + this.paletteError = 'This saved palette needs at least one color before Guided Drawing can use it.'; + return; + } + + this.selectedPalette = value; + this.paletteSource = 'restricted'; + this.restrictedPalette = palette.colors.slice(0, MAX_GUIDE_COLORS); + this.paletteError = ''; + this.paletteMessage = this.describePaletteAdaptation(palette.colors.length); + this.schedulePreview(); + } + + private describePaletteAdaptation(colorCount: number): string { + const omittedColors = colorCount - MAX_GUIDE_COLORS; + if (omittedColors <= 0) return ''; + return `Guided Drawing uses the first ${MAX_GUIDE_COLORS} colors and omits ${omittedColors} additional ${omittedColors === 1 ? 'color' : 'colors'}.`; + } + + private findPalette(value: string): { colors: string[] } | undefined { + if (value.startsWith('preset:')) { + const id = value.slice('preset:'.length); + return PRESET_PALETTES.find((palette) => palette.id === id); + } + if (value.startsWith('custom:')) { + const id = value.slice('custom:'.length); + return this.customPalettes.find((palette) => palette.id === id); + } + return undefined; + } + + private isSelectedCustomPaletteLoading(): boolean { + return this.isLoadingPalettes && this.selectedPalette.startsWith('custom:'); + } + + private beginPaletteCreation() { + this.paletteName = ''; + this.paletteColors = [DEFAULT_NEW_COLOR]; + this.paletteMessage = ''; + this.paletteError = ''; + this.isCreatingPalette = true; + void this.updateComplete.then(() => { + this.paletteNameInput?.focus(); + }); + } + + private cancelPaletteCreation = () => { + this.isCreatingPalette = false; + this.paletteName = ''; + this.paletteColors = []; + this.paletteError = ''; + this.paletteMessage = 'Palette creation cancelled.'; + void this.updateComplete.then(() => { + this.paletteSelect?.focus(); + }); + }; + + private handlePaletteCreationKeydown = (event: KeyboardEvent) => { + if (!this.isCreatingPalette || event.key !== 'Escape') return; + event.preventDefault(); + event.stopPropagation(); + if (this.isSavingPalette) return; + this.cancelPaletteCreation(); + }; + + private handlePaletteNameInput = (event: Event) => { + this.paletteName = (event.currentTarget as HTMLInputElement).value; + this.paletteError = ''; + }; + + private updatePaletteColor(index: number, event: Event) { + const colors = [...this.paletteColors]; + colors[index] = (event.currentTarget as HTMLInputElement).value; + this.paletteColors = colors; + this.paletteError = ''; + } + + private addPaletteColor = () => { + if (this.paletteColors.length >= MAX_PALETTE_SIZE) return; + this.paletteColors = [...this.paletteColors, DEFAULT_NEW_COLOR]; + this.paletteError = ''; + this.paletteMessage = this.paletteColors.length === MAX_PALETTE_SIZE + ? `Palette limit reached: ${MAX_PALETTE_SIZE} colors maximum.` + : ''; + }; + + private removePaletteColor(index: number) { + this.paletteColors = this.paletteColors.filter((_, colorIndex) => colorIndex !== index); + this.paletteError = ''; + this.paletteMessage = ''; + } + + private movePaletteColor(fromIndex: number, toIndex: number) { + if (toIndex < 0 || toIndex >= this.paletteColors.length) return; + const colors = [...this.paletteColors]; + const [color] = colors.splice(fromIndex, 1); + colors.splice(toIndex, 0, color); + this.paletteColors = colors; + } + + private saveCreatedPalette = async (event: Event) => { + event.preventDefault(); + if (this.isSavingPalette) return; + const name = this.paletteName.trim(); + if (!name) { + this.paletteError = 'Enter a palette name.'; + this.paletteNameInput?.focus(); + return; + } + if (this.paletteColors.length === 0) { + this.paletteError = 'Add at least one color.'; + return; + } + if (this.paletteColors.length > MAX_PALETTE_SIZE) { + const extraColors = this.paletteColors.length - MAX_PALETTE_SIZE; + this.paletteError = `Palettes can contain at most ${MAX_PALETTE_SIZE} colors. Remove ${extraColors} ${extraColors === 1 ? 'color' : 'colors'} before saving.`; + await this.updateComplete; + this.paletteCreationStatus?.focus(); + return; + } + + this.isSavingPalette = true; + this.paletteError = ''; + this.paletteMessage = 'Saving palette…'; + let savedPalette: CustomPalette; + try { + savedPalette = await saveAsNewPalette(name, this.paletteColors); + } catch { + this.paletteMessage = ''; + this.paletteError = 'Palette could not be saved. Try again.'; + this.isSavingPalette = false; + return; + } + + this.customPalettes = [ + savedPalette, + ...this.customPalettes.filter((palette) => palette.id !== savedPalette.id), + ]; + this.isCreatingPalette = false; + this.paletteName = ''; + this.paletteColors = []; + this.selectPalette(`custom:${savedPalette.id}`); + const adaptationMessage = this.describePaletteAdaptation(savedPalette.colors.length); + this.paletteMessage = adaptationMessage + ? `Palette saved and selected. ${adaptationMessage}` + : 'Palette saved and selected.'; + this.isSavingPalette = false; + await this.updateComplete; + this.paletteSelect?.focus(); + }; + private describeGuide(guide: NumberedGuide): string { const { complexity } = guide; const simplified = complexity.simplifiedCells > 0 @@ -551,7 +993,12 @@ export class PFPaintByNumberDialog extends BaseComponent { private createProject = async (event: Event) => { event.preventDefault(); - if (!this.guide || !this.sourceFile || this.isCreating) return; + if ( + !this.guide + || !this.sourceFile + || this.isCreating + || this.isSelectedCustomPaletteLoading() + ) return; this.isCreating = true; this.errorMessage = ''; @@ -616,6 +1063,9 @@ export class PFPaintByNumberDialog extends BaseComponent { close = () => { this.cancelPendingPreview(); + this.paletteLoadVersion += 1; + this.isCreatingPalette = false; + this.isSavingPalette = false; this.open = false; this.dispatchEvent(new CustomEvent('close')); }; diff --git a/src/services/paint-by-number/guide-generator.ts b/src/services/paint-by-number/guide-generator.ts index 36610440..a19d3cdb 100644 --- a/src/services/paint-by-number/guide-generator.ts +++ b/src/services/paint-by-number/guide-generator.ts @@ -6,7 +6,7 @@ import { } from '../../stores/palette/color-utils'; import type { GuidedColorMapping } from '../../types/guided-drawing'; -const MAX_GUIDE_COLORS = 255; +export const MAX_GUIDE_COLORS = 255; const DEFAULT_GUIDE_COLOR_COUNT = 12; export interface GuideGenerationOptions { diff --git a/src/services/persistence/palette-persistence.ts b/src/services/persistence/palette-persistence.ts index 102acfc6..02ece1b3 100644 --- a/src/services/persistence/palette-persistence.ts +++ b/src/services/persistence/palette-persistence.ts @@ -29,24 +29,33 @@ class PalettePersistenceService { */ async getAllPalettes(): Promise { try { - const db = await this.dbPromise; - const palettes = await db.getAllFromIndex("palettes", "by-updated"); - return palettes.reverse(); // Most recent first + return await this.getAllPalettesOrThrow(); } catch (error) { log.error("Failed to load palettes from IndexedDB:", error); return []; } } + /** + * Get all custom palettes and expose storage failures to the caller. + */ + async getAllPalettesOrThrow(): Promise { + const db = await this.dbPromise; + const palettes = await db.getAllFromIndex("palettes", "by-updated"); + return palettes.reverse(); // Most recent first + } + /** * Save a new palette or update existing */ - async savePalette(palette: StoredCustomPalette): Promise { + async savePalette(palette: StoredCustomPalette): Promise { try { const db = await this.dbPromise; await db.put("palettes", palette); + return true; } catch (error) { log.error("Failed to save palette to IndexedDB:", error); + return false; } } @@ -65,7 +74,7 @@ class PalettePersistenceService { /** * Update palette properties */ - async updatePalette(id: string, updates: Partial): Promise { + async updatePalette(id: string, updates: Partial): Promise { try { const db = await this.dbPromise; const existing = await db.get("palettes", id); @@ -75,9 +84,12 @@ class PalettePersistenceService { ...updates, updatedAt: Date.now(), }); + return true; } + return false; } catch (error) { log.error("Failed to update palette in IndexedDB:", error); + return false; } } diff --git a/src/stores/palette/persistence.ts b/src/stores/palette/persistence.ts index 56a45b6a..28d52267 100644 --- a/src/stores/palette/persistence.ts +++ b/src/stores/palette/persistence.ts @@ -84,13 +84,20 @@ export async function loadCustomPalettes(): Promise { return palettePersistence.getAllPalettes(); } +/** + * Load all custom palettes while preserving storage errors for failure-aware UI. + */ +export async function loadCustomPalettesOrThrow(): Promise { + return palettePersistence.getAllPalettesOrThrow(); +} + /** * Save current palette as a new custom palette. */ export async function saveAsNewPalette( name: string, colors: string[] -): Promise { +): Promise { const id = crypto.randomUUID(); const now = Date.now(); const palette: CustomPalette = { @@ -101,8 +108,11 @@ export async function saveAsNewPalette( updatedAt: now, }; - await palettePersistence.savePalette(palette); - return id; + const saved = await palettePersistence.savePalette(palette); + if (!saved) { + throw new Error('Palette could not be saved.'); + } + return palette; } /** @@ -111,8 +121,8 @@ export async function saveAsNewPalette( export async function updatePalette( id: string, updates: Partial> -): Promise { - await palettePersistence.updatePalette(id, updates); +): Promise { + return palettePersistence.updatePalette(id, updates); } /** @@ -126,7 +136,10 @@ export async function deletePalette(id: string): Promise { * Rename a custom palette. */ export async function renamePalette(id: string, newName: string): Promise { - await palettePersistence.updatePalette(id, { name: newName.trim() }); + const updated = await palettePersistence.updatePalette(id, { name: newName.trim() }); + if (!updated) { + throw new Error('Palette could not be renamed.'); + } } // ========================================== diff --git a/src/stores/palette/store.ts b/src/stores/palette/store.ts index c257a3c1..0059a27a 100644 --- a/src/stores/palette/store.ts +++ b/src/stores/palette/store.ts @@ -580,14 +580,14 @@ class PaletteStore { } async saveAsNewPalette(name: string): Promise { - const id = await persistence.saveAsNewPalette(name, this.mainColors.value); + const palette = await persistence.saveAsNewPalette(name, this.mainColors.value); await this.loadCustomPalettes(); this.currentPresetId.value = null; - this.currentCustomPaletteId.value = id; + this.currentCustomPaletteId.value = palette.id; this.isDirty.value = false; - return id; + return palette.id; } async deleteCustomPalette(id: string): Promise { diff --git a/tests/components/color/pf-palette-panel-save-dialog.test.ts b/tests/components/color/pf-palette-panel-save-dialog.test.ts new file mode 100644 index 00000000..1c2f1223 --- /dev/null +++ b/tests/components/color/pf-palette-panel-save-dialog.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const paletteDatabase = vi.hoisted(() => ({ + getAllFromIndex: vi.fn(() => Promise.resolve([])), + get: vi.fn(), + put: vi.fn(), +})); +const openPaletteDatabase = vi.hoisted(() => vi.fn(() => Promise.resolve(paletteDatabase))); + +vi.mock('idb', () => ({ + openDB: openPaletteDatabase, +})); + +vi.mock('../../../src/components/color/pf-color-picker-popup', () => ({})); + +import '../../../src/components/color/pf-palette-panel'; +import type { PFPalettePanel } from '../../../src/components/color/pf-palette-panel'; +import type { PfSavePaletteDialog } from '../../../src/components/color/pf-save-palette-dialog'; +import type { CustomPalette } from '../../../src/types/palette'; +import { + createProjectContext, + restoreDefaultProjectContext, + setActiveProjectContext, + type ProjectContext, +} from '../../../src/stores/project-context'; + +let context: ProjectContext; + +async function settle(panel: PFPalettePanel) { + await Promise.resolve(); + await panel.updateComplete; + await Promise.resolve(); + await panel.updateComplete; +} + +async function settleWithDialog(panel: PFPalettePanel, dialog: PfSavePaletteDialog) { + await settle(panel); + await dialog.updateComplete; + await panel.updateComplete; +} + +function shadowElement( + host: { shadowRoot: ShadowRoot | null }, + selector: string, +): T { + const element = host.shadowRoot?.querySelector(selector); + if (!element) throw new Error(`Missing test element: ${selector}`); + return element; +} + +describe('pf-palette-panel save dialog', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.replaceChildren(); + localStorage.clear(); + context = createProjectContext(); + context.palette.setPalette(['#112233']); + setActiveProjectContext(context); + }); + + afterEach(() => { + document.body.replaceChildren(); + localStorage.clear(); + restoreDefaultProjectContext(); + context.dispose(); + vi.restoreAllMocks(); + }); + + it('keeps a failed Save As open and announces the recoverable error', async () => { + let rejectSave = (_reason: Error) => { + throw new Error('Save rejection was not initialized'); + }; + const saveAsNewPalette = vi.spyOn(context.palette, 'saveAsNewPalette') + .mockImplementationOnce(() => new Promise((_resolve, reject) => { + rejectSave = reject; + })); + const panel = document.createElement('pf-palette-panel') as PFPalettePanel; + document.body.append(panel); + await settle(panel); + + shadowElement(panel, 'pf-palette-toolbar').dispatchEvent( + new CustomEvent('menu-save-as'), + ); + await settle(panel); + + const dialog = shadowElement( + panel, + 'pf-save-palette-dialog', + ); + const input = shadowElement(dialog, '.form-input'); + input.value = 'Forest'; + input.dispatchEvent(new Event('input', { bubbles: true })); + await settleWithDialog(panel, dialog); + const save = shadowElement(dialog, '.btn-save'); + save.click(); + save.click(); + await settleWithDialog(panel, dialog); + + expect(saveAsNewPalette).toHaveBeenCalledTimes(1); + expect(dialog.open).toBe(true); + expect(dialog.pending).toBe(true); + expect(shadowElement(dialog, '.form-input').disabled).toBe(true); + expect(shadowElement(dialog, '.btn-save').textContent) + .toContain('Saving…'); + + rejectSave(new Error('IndexedDB write failed')); + await settleWithDialog(panel, dialog); + + const error = shadowElement(dialog, '#palette-save-error'); + expect(dialog.open).toBe(true); + expect(dialog.pending).toBe(false); + expect(error.getAttribute('role')).toBe('alert'); + expect(error.textContent).toContain('Palette could not be saved'); + + const retryInput = shadowElement(dialog, '.form-input'); + retryInput.value = 'Forest retry'; + retryInput.dispatchEvent(new Event('input', { bubbles: true })); + await settleWithDialog(panel, dialog); + + expect(dialog.shadowRoot?.querySelector('#palette-save-error')).toBeNull(); + }); + + it('keeps rename open when the persistence adapter rejects the update', async () => { + const savedPalette: CustomPalette = { + id: 'saved-palette', + name: 'Saved palette', + colors: ['#112233'], + createdAt: 1, + updatedAt: 1, + }; + context.palette.currentPresetId.value = null; + context.palette.currentCustomPaletteId.value = savedPalette.id; + context.palette.customPalettes.value = [savedPalette]; + paletteDatabase.get.mockResolvedValueOnce(savedPalette); + paletteDatabase.put.mockRejectedValueOnce(new Error('IndexedDB write failed')); + const panel = document.createElement('pf-palette-panel') as PFPalettePanel; + document.body.append(panel); + await settle(panel); + + shadowElement(panel, 'pf-palette-toolbar').dispatchEvent( + new CustomEvent('menu-rename'), + ); + await settle(panel); + + const dialog = shadowElement( + panel, + 'pf-save-palette-dialog', + ); + const input = shadowElement(dialog, '.form-input'); + input.value = 'Renamed palette'; + input.dispatchEvent(new Event('input', { bubbles: true })); + await settleWithDialog(panel, dialog); + shadowElement(dialog, '.btn-save').click(); + await settleWithDialog(panel, dialog); + + expect(paletteDatabase.get).toHaveBeenCalledWith('palettes', 'saved-palette'); + expect(paletteDatabase.put).toHaveBeenCalledWith('palettes', { + ...savedPalette, + name: 'Renamed palette', + updatedAt: expect.any(Number), + }); + const error = shadowElement(dialog, '#palette-save-error'); + expect(dialog.open).toBe(true); + expect(dialog.pending).toBe(false); + expect(error.getAttribute('role')).toBe('alert'); + expect(error.textContent).toContain('Palette could not be renamed'); + }); +}); diff --git a/tests/components/color/pf-save-palette-dialog.test.ts b/tests/components/color/pf-save-palette-dialog.test.ts new file mode 100644 index 00000000..5a609782 --- /dev/null +++ b/tests/components/color/pf-save-palette-dialog.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import '../../../src/components/color/pf-save-palette-dialog'; +import type { PfSavePaletteDialog } from '../../../src/components/color/pf-save-palette-dialog'; + +async function settle(dialog: PfSavePaletteDialog) { + await Promise.resolve(); + await dialog.updateComplete; +} + +async function createDialog() { + const dialog = document.createElement('pf-save-palette-dialog') as PfSavePaletteDialog; + dialog.defaultName = 'Forest'; + dialog.open = true; + document.body.append(dialog); + await settle(dialog); + return dialog; +} + +describe('pf-save-palette-dialog', () => { + afterEach(() => { + document.body.replaceChildren(); + }); + + it('locks its fields and close paths while a save is pending', async () => { + const dialog = await createDialog(); + const saveSpy = vi.fn(); + const cancelSpy = vi.fn(); + dialog.addEventListener('save', saveSpy); + dialog.addEventListener('cancel', cancelSpy); + dialog.pending = true; + await settle(dialog); + + const input = dialog.shadowRoot?.querySelector('.form-input'); + const save = dialog.shadowRoot?.querySelector('.btn-save'); + const cancel = dialog.shadowRoot?.querySelector('.btn-cancel'); + expect(input?.disabled).toBe(true); + expect(save?.disabled).toBe(true); + expect(save?.textContent).toContain('Saving…'); + expect(cancel?.disabled).toBe(true); + + input?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + input?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + dialog.shadowRoot + ?.querySelector('.overlay') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(saveSpy).not.toHaveBeenCalled(); + expect(cancelSpy).not.toHaveBeenCalled(); + }); + + it('exposes parent failures as an accessible error and requests clearing on input', async () => { + const dialog = await createDialog(); + const inputSpy = vi.fn(); + dialog.addEventListener('name-input', inputSpy); + dialog.errorMessage = 'Palette could not be saved. Try again.'; + await settle(dialog); + + const input = dialog.shadowRoot?.querySelector('.form-input'); + const error = dialog.shadowRoot?.querySelector('#palette-save-error'); + expect(error?.getAttribute('role')).toBe('alert'); + expect(error?.textContent).toContain('Palette could not be saved'); + expect(input?.getAttribute('aria-invalid')).toBe('true'); + expect(input?.getAttribute('aria-describedby')).toBe('palette-save-error'); + + if (input) { + input.value = 'Forest 2'; + input.dispatchEvent(new Event('input', { bubbles: true })); + } + + expect(inputSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/components/dialogs/pf-paint-by-number-dialog.test.ts b/tests/components/dialogs/pf-paint-by-number-dialog.test.ts index e98b5034..ee058bb7 100644 --- a/tests/components/dialogs/pf-paint-by-number-dialog.test.ts +++ b/tests/components/dialogs/pf-paint-by-number-dialog.test.ts @@ -4,6 +4,8 @@ const decodeImageFileMock = vi.hoisted(() => vi.fn()); const sampleImageToGridMock = vi.hoisted(() => vi.fn()); const generateNumberedGuideMock = vi.hoisted(() => vi.fn()); const createGuidedProjectMock = vi.hoisted(() => vi.fn()); +const loadCustomPalettesOrThrowMock = vi.hoisted(() => vi.fn()); +const saveAsNewPaletteMock = vi.hoisted(() => vi.fn()); vi.mock('../../../src/services/paint-by-number/image-file', () => ({ decodeImageFile: decodeImageFileMock, @@ -15,14 +17,27 @@ vi.mock('../../../src/services/paint-by-number/image-sampling', () => ({ vi.mock('../../../src/services/paint-by-number/guide-generator', () => ({ generateNumberedGuide: generateNumberedGuideMock, + MAX_GUIDE_COLORS: 255, })); vi.mock('../../../src/services/paint-by-number/guided-project', () => ({ createGuidedProject: createGuidedProjectMock, })); +vi.mock('../../../src/stores/palette/persistence', async (importOriginal) => ({ + ...(await importOriginal()), + loadCustomPalettesOrThrow: loadCustomPalettesOrThrowMock, + saveAsNewPalette: saveAsNewPaletteMock, +})); + import '../../../src/components/dialogs/pf-paint-by-number-dialog'; import type { PFPaintByNumberDialog } from '../../../src/components/dialogs/pf-paint-by-number-dialog'; +import { PRESET_PALETTES } from '../../../src/data/preset-palettes'; +import { MAX_GUIDE_COLORS } from '../../../src/services/paint-by-number/guide-generator'; +import * as projectContext from '../../../src/stores/project-context'; +import { MAX_PALETTE_SIZE } from '../../../src/stores/palette/types'; +import type { CustomPalette } from '../../../src/types/palette'; +import type { PFDialog } from '../../../src/components/ui/pf-dialog'; const sourceImage = { width: 4, @@ -77,6 +92,26 @@ function chooseFile(element: PFPaintByNumberDialog, file: File) { input?.dispatchEvent(new Event('change')); } +async function selectPalette(element: PFPaintByNumberDialog, value: string) { + const select = element.shadowRoot?.querySelector( + '#guided-palette-source', + ); + if (!select) throw new Error('Palette select is unavailable'); + select.value = value; + select.dispatchEvent(new Event('change')); + await settle(element); +} + +function customPalette(id: string, name: string, colors: string[]): CustomPalette { + return { + id, + name, + colors, + createdAt: 1, + updatedAt: 1, + }; +} + describe('pf-paint-by-number-dialog', () => { beforeEach(() => { vi.useFakeTimers(); @@ -91,6 +126,10 @@ describe('pf-paint-by-number-dialog', () => { item: {}, projectId: 'guided-project', }); + loadCustomPalettesOrThrowMock.mockResolvedValue([]); + saveAsNewPaletteMock.mockImplementation(async (name: string, colors: string[]) => ( + customPalette('new-palette', name, [...colors]) + )); }); afterEach(() => { @@ -159,6 +198,820 @@ describe('pf-paint-by-number-dialog', () => { expect(input?.checked).toBe(false); }); + it.each([ + ['Palette saved and selected.', '', 'status'], + ['Guided Drawing uses the first 255 colors.', '', 'status'], + ['', 'Saved palettes could not be loaded.', 'alert'], + ])('keeps the palette label static when status text changes', async ( + paletteMessage, + paletteError, + role, + ) => { + const element = await createDialog(); + const state = element as unknown as { + paletteMessage: string; + paletteError: string; + }; + state.paletteMessage = paletteMessage; + state.paletteError = paletteError; + await settle(element); + + const label = element.shadowRoot?.querySelector( + 'label[for="guided-palette-source"]', + ); + const select = element.shadowRoot?.querySelector( + '#guided-palette-source', + ); + const status = element.shadowRoot?.querySelector('#guided-palette-status'); + expect(label?.textContent?.trim()).toBe('Palette'); + expect(label?.htmlFor).toBe(select?.id); + expect(label?.contains(status ?? null)).toBe(false); + expect(select?.getAttribute('aria-describedby')).toBe(status?.id); + expect(status?.getAttribute('role')).toBe(role); + expect(status?.getAttribute('aria-live')).toBe('polite'); + expect(status?.textContent).toContain(paletteError || paletteMessage); + }); + + it('selects preset and saved palettes without reading an active project palette', async () => { + const activeProjectPaletteSpy = vi.spyOn(projectContext, 'getActiveProjectContext'); + const saved = customPalette('night', 'Night colors', ['#101020', '#f0f0ff']); + loadCustomPalettesOrThrowMock.mockResolvedValue([saved]); + const element = await createDialog(); + const select = element.shadowRoot?.querySelector( + '#guided-palette-source', + ); + + expect(select?.options[0].textContent).toBe('Generate from image'); + expect(Array.from(select?.options ?? []).map((option) => option.textContent)).toEqual( + expect.arrayContaining(['DB32', 'PICO-8', 'Night colors', 'Create palette…']), + ); + + chooseFile(element, new File(['pixels'], 'portrait.png', { type: 'image/png' })); + await Promise.resolve(); + await vi.runAllTimersAsync(); + + const preset = PRESET_PALETTES.find((palette) => palette.id === 'gameboy')!; + await selectPalette(element, 'preset:gameboy'); + await vi.runAllTimersAsync(); + expect(generateNumberedGuideMock).toHaveBeenLastCalledWith(sampledImage, { + maxColors: undefined, + palette: preset.colors, + mapping: 'color', + simplifyIsolatedPixels: true, + }); + expect(generateNumberedGuideMock.mock.lastCall?.[1].palette).not.toBe(preset.colors); + + await selectPalette(element, 'custom:night'); + await vi.runAllTimersAsync(); + expect(generateNumberedGuideMock).toHaveBeenLastCalledWith(sampledImage, { + maxColors: undefined, + palette: ['#101020', '#f0f0ff'], + mapping: 'color', + simplifyIsolatedPixels: true, + }); + expect(generateNumberedGuideMock.mock.lastCall?.[1].palette).not.toBe(saved.colors); + expect(element.shadowRoot?.querySelector('#guided-palette-status')?.textContent).not.toContain( + 'omits', + ); + + element.shadowRoot?.querySelector('button.primary')?.click(); + await settle(element); + expect(createGuidedProjectMock).toHaveBeenCalledWith({ + guide, + settings: { + longSide: 24, + paletteSource: 'restricted', + maxColors: undefined, + restrictedPalette: ['#101020', '#f0f0ff'], + mapping: 'color', + simplifyIsolatedPixels: true, + }, + sourceName: 'portrait.png', + }); + expect(activeProjectPaletteSpy).not.toHaveBeenCalled(); + }); + + it('rejects an empty saved palette without changing the current guide setup', async () => { + const valid = customPalette('night', 'Night colors', ['#101020', '#f0f0ff']); + const empty = customPalette('empty', 'Empty palette', []); + loadCustomPalettesOrThrowMock.mockResolvedValueOnce([valid, empty]); + const element = await createDialog(); + chooseFile(element, new File(['pixels'], 'portrait.png', { type: 'image/png' })); + await Promise.resolve(); + await vi.runAllTimersAsync(); + + await selectPalette(element, 'custom:night'); + await vi.runAllTimersAsync(); + + const mapping = element.shadowRoot?.querySelector('#guided-mapping'); + if (mapping) { + mapping.value = 'luminance'; + mapping.dispatchEvent(new Event('change')); + } + const simplify = element.shadowRoot?.querySelector('#guided-simplify'); + simplify?.click(); + await vi.runAllTimersAsync(); + await settle(element); + + const generationCalls = generateNumberedGuideMock.mock.calls.length; + const guidePreview = element.shadowRoot?.querySelector('#guide-preview'); + const create = element.shadowRoot?.querySelector( + 'button[form="guided-project-form"]', + ); + expect(guidePreview).toBeTruthy(); + expect(create?.disabled).toBe(false); + + await selectPalette(element, 'custom:empty'); + await vi.runAllTimersAsync(); + + const select = element.shadowRoot?.querySelector( + '#guided-palette-source', + ); + const error = element.shadowRoot?.querySelector('#guided-palette-status'); + expect(select?.value).toBe('custom:night'); + expect(error?.getAttribute('role')).toBe('alert'); + expect(error?.textContent).toContain('needs at least one color'); + expect(generateNumberedGuideMock).toHaveBeenCalledTimes(generationCalls); + expect(element.shadowRoot?.querySelector('#guide-preview')).toBe(guidePreview); + expect(create?.disabled).toBe(false); + + create?.click(); + await settle(element); + + expect(createGuidedProjectMock).toHaveBeenCalledWith({ + guide, + settings: { + longSide: 24, + paletteSource: 'restricted', + maxColors: undefined, + restrictedPalette: ['#101020', '#f0f0ff'], + mapping: 'luminance', + simplifyIsolatedPixels: false, + }, + sourceName: 'portrait.png', + }); + }); + + it('uses the first 255 colors from an oversized selected palette', async () => { + const colors = Array.from( + { length: MAX_GUIDE_COLORS + 1 }, + (_, index) => `#${index.toString(16).padStart(6, '0')}`, + ); + const saved = customPalette('oversized', 'Oversized', colors); + loadCustomPalettesOrThrowMock.mockResolvedValueOnce([saved]); + const element = await createDialog(); + chooseFile(element, new File(['pixels'], 'portrait.png', { type: 'image/png' })); + await Promise.resolve(); + await vi.runAllTimersAsync(); + + await selectPalette(element, 'custom:oversized'); + await vi.runAllTimersAsync(); + + const adaptedColors = colors.slice(0, MAX_GUIDE_COLORS); + expect(generateNumberedGuideMock).toHaveBeenLastCalledWith(sampledImage, { + maxColors: undefined, + palette: adaptedColors, + mapping: 'color', + simplifyIsolatedPixels: true, + }); + const status = element.shadowRoot?.querySelector('#guided-palette-status'); + expect(status?.getAttribute('role')).toBe('status'); + expect(status?.textContent).toContain('uses the first 255 colors'); + expect(status?.textContent).toContain('omits 1 additional color'); + + element.shadowRoot?.querySelector('button.primary')?.click(); + await settle(element); + + expect(createGuidedProjectMock).toHaveBeenCalledWith({ + guide, + settings: { + longSide: 24, + paletteSource: 'restricted', + maxColors: undefined, + restrictedPalette: adaptedColors, + mapping: 'color', + simplifyIsolatedPixels: true, + }, + sourceName: 'portrait.png', + }); + }); + + it('creates, edits, reorders, removes, saves, and selects a custom palette inline', async () => { + const created = customPalette('new-palette', 'Forest', ['#0000ff', '#00ff00']); + loadCustomPalettesOrThrowMock.mockResolvedValueOnce([]); + saveAsNewPaletteMock.mockResolvedValueOnce(created); + const element = await createDialog(); + chooseFile(element, new File(['pixels'], 'forest.png', { type: 'image/png' })); + await Promise.resolve(); + await vi.runAllTimersAsync(); + + const longSide = element.shadowRoot?.querySelector('#guided-long-side'); + if (longSide) { + longSide.value = '32'; + longSide.dispatchEvent(new Event('change')); + } + await selectPalette(element, 'create'); + + const name = element.shadowRoot?.querySelector('#guided-palette-name'); + expect(element.shadowRoot?.activeElement).toBe(name); + if (name) { + name.value = 'Forest'; + name.dispatchEvent(new Event('input')); + } + + const firstColor = element.shadowRoot?.querySelector( + 'input[aria-label="Color 1"]', + ); + if (firstColor) { + firstColor.value = '#ff0000'; + firstColor.dispatchEvent(new Event('input')); + } + element.shadowRoot?.querySelector('.palette-create-actions button')?.click(); + await settle(element); + const secondColor = element.shadowRoot?.querySelector( + 'input[aria-label="Color 2"]', + ); + if (secondColor) { + secondColor.value = '#00ff00'; + secondColor.dispatchEvent(new Event('input')); + } + element.shadowRoot?.querySelector('.palette-create-actions button')?.click(); + await settle(element); + const thirdColor = element.shadowRoot?.querySelector( + 'input[aria-label="Color 3"]', + ); + if (thirdColor) { + thirdColor.value = '#0000ff'; + thirdColor.dispatchEvent(new Event('input')); + } + element.shadowRoot + ?.querySelector('button[aria-label="Move color 3 up"]') + ?.click(); + await settle(element); + element.shadowRoot + ?.querySelector('button[aria-label="Remove color 1"]') + ?.click(); + await settle(element); + + element.shadowRoot + ?.querySelector('button[form="palette-creation-form"]') + ?.click(); + await settle(element); + await vi.runAllTimersAsync(); + + expect(saveAsNewPaletteMock).toHaveBeenCalledWith('Forest', ['#0000ff', '#00ff00']); + expect(loadCustomPalettesOrThrowMock).toHaveBeenCalledTimes(1); + expect(element.shadowRoot?.querySelector( + '#guided-palette-source', + )?.value).toBe('custom:new-palette'); + expect(element.shadowRoot?.querySelector( + '#guided-long-side', + )?.value).toBe('32'); + expect(element.shadowRoot?.querySelector('#source-preview')).toBeTruthy(); + expect(generateNumberedGuideMock).toHaveBeenLastCalledWith(sampledImage, { + maxColors: undefined, + palette: ['#0000ff', '#00ff00'], + mapping: 'color', + simplifyIsolatedPixels: true, + }); + }); + + it('announces confirmation after saving and selecting a palette', async () => { + const created = customPalette('new-palette', 'Saved palette', ['#000000']); + loadCustomPalettesOrThrowMock.mockResolvedValueOnce([]); + saveAsNewPaletteMock.mockResolvedValueOnce(created); + const element = await createDialog(); + chooseFile(element, new File(['pixels'], 'source.png', { type: 'image/png' })); + await Promise.resolve(); + await vi.runAllTimersAsync(); + await selectPalette(element, 'create'); + + const name = element.shadowRoot?.querySelector('#guided-palette-name'); + if (name) { + name.value = 'Saved palette'; + name.dispatchEvent(new Event('input')); + } + element.shadowRoot + ?.querySelector('button[form="palette-creation-form"]') + ?.click(); + await settle(element); + await vi.runAllTimersAsync(); + await settle(element); + + const select = element.shadowRoot?.querySelector( + '#guided-palette-source', + ); + const status = element.shadowRoot?.querySelector('#guided-palette-status'); + expect(saveAsNewPaletteMock).toHaveBeenCalledWith('Saved palette', ['#000000']); + expect(select?.value).toBe('custom:new-palette'); + expect(status?.getAttribute('role')).toBe('status'); + expect(status?.textContent).toContain('Palette saved and selected.'); + expect(generateNumberedGuideMock).toHaveBeenLastCalledWith(sampledImage, { + maxColors: undefined, + palette: ['#000000'], + mapping: 'color', + simplifyIsolatedPixels: true, + }); + expect(element.shadowRoot?.activeElement).toBe(select); + }); + + it('announces save confirmation and adaptation for a 256-color palette', async () => { + const colors = Array.from( + { length: MAX_PALETTE_SIZE }, + (_, index) => `#${index.toString(16).padStart(6, '0')}`, + ); + const created = customPalette('max-palette', 'Maximum palette', colors); + loadCustomPalettesOrThrowMock.mockResolvedValueOnce([]); + saveAsNewPaletteMock.mockResolvedValueOnce(created); + const element = await createDialog(); + chooseFile(element, new File(['pixels'], 'source.png', { type: 'image/png' })); + await Promise.resolve(); + await vi.runAllTimersAsync(); + await selectPalette(element, 'create'); + + const name = element.shadowRoot?.querySelector('#guided-palette-name'); + if (name) { + name.value = 'Maximum palette'; + name.dispatchEvent(new Event('input')); + } + const paletteState = element as unknown as { paletteColors: string[] }; + paletteState.paletteColors = colors; + await settle(element); + + element.shadowRoot + ?.querySelector('button[form="palette-creation-form"]') + ?.click(); + await settle(element); + await vi.runAllTimersAsync(); + await settle(element); + + const select = element.shadowRoot?.querySelector( + '#guided-palette-source', + ); + const status = element.shadowRoot?.querySelector('#guided-palette-status'); + expect(saveAsNewPaletteMock).toHaveBeenCalledWith('Maximum palette', colors); + expect(select?.value).toBe('custom:max-palette'); + expect(status?.getAttribute('role')).toBe('status'); + expect(status?.textContent).toContain('Palette saved and selected.'); + expect(status?.textContent).toContain('uses the first 255 colors'); + expect(status?.textContent).toContain('omits 1 additional color'); + expect(generateNumberedGuideMock).toHaveBeenLastCalledWith(sampledImage, { + maxColors: undefined, + palette: colors.slice(0, MAX_GUIDE_COLORS), + mapping: 'color', + simplifyIsolatedPixels: true, + }); + }); + + it('disables adding colors when a new palette reaches the persisted limit', async () => { + const element = await createDialog(); + await selectPalette(element, 'create'); + const paletteState = element as unknown as { paletteColors: string[] }; + paletteState.paletteColors = Array.from( + { length: MAX_PALETTE_SIZE - 1 }, + () => '#000000', + ); + await settle(element); + + const addColor = element.shadowRoot?.querySelector( + '.palette-create-actions button', + ); + addColor?.click(); + await settle(element); + + expect(paletteState.paletteColors).toHaveLength(MAX_PALETTE_SIZE); + expect(addColor?.disabled).toBe(true); + expect(element.shadowRoot?.querySelector('[role="status"]')?.textContent).toContain( + '256 colors maximum', + ); + + addColor?.click(); + expect(paletteState.paletteColors).toHaveLength(MAX_PALETTE_SIZE); + }); + + it('rejects an oversized palette before persistence and focuses the error', async () => { + const element = await createDialog(); + await selectPalette(element, 'create'); + const name = element.shadowRoot?.querySelector('#guided-palette-name'); + if (name) { + name.value = 'Too many colors'; + name.dispatchEvent(new Event('input')); + } + const paletteState = element as unknown as { paletteColors: string[] }; + paletteState.paletteColors = Array.from( + { length: MAX_PALETTE_SIZE + 1 }, + () => '#000000', + ); + await settle(element); + + element.shadowRoot + ?.querySelector('button[form="palette-creation-form"]') + ?.click(); + await settle(element); + + const error = element.shadowRoot?.querySelector( + '#guided-palette-creation-status', + ); + expect(saveAsNewPaletteMock).not.toHaveBeenCalled(); + expect(error?.getAttribute('role')).toBe('alert'); + expect(error?.textContent).toContain('at most 256 colors'); + expect(error?.textContent).toContain('Remove 1 color'); + expect(element.shadowRoot?.activeElement).toBe(error); + }); + + it('cancels inline creation with Escape and restores the previous selection and focus', async () => { + const element = await createDialog(); + await selectPalette(element, 'preset:pico8'); + const callsBeforeCreation = generateNumberedGuideMock.mock.calls.length; + await selectPalette(element, 'create'); + const cancel = Array.from(element.shadowRoot?.querySelectorAll('button') ?? []) + .find((button) => button.textContent?.trim() === 'Cancel palette'); + + cancel?.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + composed: true, + })); + await settle(element); + + const select = element.shadowRoot?.querySelector( + '#guided-palette-source', + ); + expect(element.open).toBe(true); + expect(select?.value).toBe('preset:pico8'); + expect(element.shadowRoot?.activeElement).toBe(select); + expect(generateNumberedGuideMock).toHaveBeenCalledTimes(callsBeforeCreation); + expect(element.shadowRoot?.textContent).toContain('Palette creation cancelled.'); + }); + + it('keeps Guided creation open until its explicit cancel action is used', async () => { + const element = await createDialog(); + await selectPalette(element, 'create'); + const shell = element.shadowRoot?.querySelector('pf-dialog'); + await shell?.updateComplete; + + expect(shell?.closeOnBackdrop).toBe(false); + expect(shell?.closeOnEscape).toBe(false); + expect(shell?.showCloseButton).toBe(false); + expect(shell?.shadowRoot?.querySelector('.close-btn')).toBeNull(); + + shell?.shadowRoot + ?.querySelector('.overlay') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await settle(element); + + expect(element.open).toBe(true); + expect(element.shadowRoot?.querySelector('#palette-creation-form')).toBeTruthy(); + expect(element.shadowRoot?.textContent).toContain('Cancel palette'); + }); + + it('shows a recoverable save error without losing source or setup state', async () => { + const saved = customPalette('new-palette', 'Retry palette', ['#000000']); + loadCustomPalettesOrThrowMock.mockResolvedValueOnce([]); + saveAsNewPaletteMock + .mockRejectedValueOnce(new Error('IndexedDB write failed')) + .mockResolvedValueOnce(saved); + const element = await createDialog(); + chooseFile(element, new File(['pixels'], 'source.png', { type: 'image/png' })); + await Promise.resolve(); + await vi.runAllTimersAsync(); + await selectPalette(element, 'create'); + const name = element.shadowRoot?.querySelector('#guided-palette-name'); + if (name) { + name.value = 'Retry palette'; + name.dispatchEvent(new Event('input')); + } + + element.shadowRoot + ?.querySelector('button[form="palette-creation-form"]') + ?.click(); + await settle(element); + + expect(element.shadowRoot?.querySelector('[role="alert"]')?.textContent).toContain( + 'Palette could not be saved', + ); + expect(element.shadowRoot?.querySelector( + '#guided-palette-name', + )?.value).toBe('Retry palette'); + expect(element.shadowRoot?.querySelector('#palette-creation-form')).toBeTruthy(); + + element.shadowRoot + ?.querySelector('button[form="palette-creation-form"]') + ?.click(); + await settle(element); + await vi.runAllTimersAsync(); + + expect(saveAsNewPaletteMock).toHaveBeenCalledTimes(2); + expect(loadCustomPalettesOrThrowMock).toHaveBeenCalledTimes(1); + expect(element.shadowRoot?.querySelector('#source-preview')).toBeTruthy(); + expect(element.shadowRoot?.querySelector( + '#guided-palette-source', + )?.value).toBe('custom:new-palette'); + }); + + it('announces palette loading failures while keeping preset choices available', async () => { + let rejectLoad: ((reason: Error) => void) | undefined; + loadCustomPalettesOrThrowMock.mockImplementationOnce(() => new Promise((_resolve, reject) => { + rejectLoad = reject; + })); + const element = await createDialog(); + const select = element.shadowRoot?.querySelector( + '#guided-palette-source', + ); + + expect(select?.disabled).toBe(true); + expect(element.shadowRoot?.textContent).toContain('Loading saved palettes…'); + + rejectLoad?.(new Error('IndexedDB unavailable')); + await settle(element); + + expect(select?.disabled).toBe(false); + expect(element.shadowRoot?.querySelector('[role="alert"]')?.textContent).toContain( + 'Saved palettes could not be loaded', + ); + expect(Array.from(select?.options ?? []).map((option) => option.textContent)).toContain( + 'PICO-8', + ); + }); + + it('retains a selected custom palette when a later palette reload fails', async () => { + const saved = customPalette('night', 'Night colors', ['#101020', '#f0f0ff']); + loadCustomPalettesOrThrowMock + .mockResolvedValueOnce([saved]) + .mockRejectedValueOnce(new Error('IndexedDB unavailable')); + const element = await createDialog(); + chooseFile(element, new File(['pixels'], 'portrait.png', { type: 'image/png' })); + await Promise.resolve(); + await vi.runAllTimersAsync(); + await selectPalette(element, 'custom:night'); + await vi.runAllTimersAsync(); + + element.open = false; + await settle(element); + element.open = true; + await settle(element); + + const select = element.shadowRoot?.querySelector( + '#guided-palette-source', + ); + expect(select?.value).toBe('custom:night'); + expect(Array.from(select?.options ?? []).map((option) => option.textContent)).toContain( + 'Night colors', + ); + expect(element.shadowRoot?.querySelector('[role="alert"]')?.textContent).toContain( + 'Saved palettes could not be loaded', + ); + + element.shadowRoot?.querySelector('button.primary')?.click(); + await settle(element); + + expect(createGuidedProjectMock).toHaveBeenCalledWith({ + guide, + settings: { + longSide: 24, + paletteSource: 'restricted', + maxColors: undefined, + restrictedPalette: ['#101020', '#f0f0ff'], + mapping: 'color', + simplifyIsolatedPixels: true, + }, + sourceName: 'portrait.png', + }); + }); + + it('falls back to generated colors when the selected palette reloads empty', async () => { + const saved = customPalette('night', 'Night colors', ['#101020', '#f0f0ff']); + const empty = customPalette('night', 'Night colors', []); + loadCustomPalettesOrThrowMock + .mockResolvedValueOnce([saved]) + .mockResolvedValueOnce([empty]); + const element = await createDialog(); + chooseFile(element, new File(['pixels'], 'portrait.png', { type: 'image/png' })); + await Promise.resolve(); + await vi.runAllTimersAsync(); + await selectPalette(element, 'custom:night'); + await vi.runAllTimersAsync(); + + element.open = false; + await settle(element); + element.open = true; + await settle(element); + await vi.runAllTimersAsync(); + await settle(element); + + const paletteState = element as unknown as { + selectedPalette: string; + paletteSource: string; + restrictedPalette: string[] | undefined; + }; + const select = element.shadowRoot?.querySelector( + '#guided-palette-source', + ); + const status = element.shadowRoot?.querySelector('#guided-palette-status'); + const create = element.shadowRoot?.querySelector( + 'button[form="guided-project-form"]', + ); + expect(select?.value).toBe('generated'); + expect(paletteState.selectedPalette).toBe('generated'); + expect(paletteState.paletteSource).toBe('generated'); + expect(paletteState.restrictedPalette).toBeUndefined(); + expect(generateNumberedGuideMock).toHaveBeenLastCalledWith(sampledImage, { + maxColors: 8, + palette: undefined, + mapping: 'color', + simplifyIsolatedPixels: true, + }); + expect(status?.getAttribute('role')).toBe('alert'); + expect(status?.textContent).toContain('needs at least one color'); + expect(create?.disabled).toBe(false); + + create?.click(); + await settle(element); + + expect(createGuidedProjectMock).toHaveBeenCalledWith({ + guide, + settings: { + longSide: 24, + paletteSource: 'generated', + maxColors: 8, + restrictedPalette: undefined, + mapping: 'color', + simplifyIsolatedPixels: true, + }, + sourceName: 'portrait.png', + }); + }); + + it('falls back to generated colors when the selected palette is deleted before reload', async () => { + const saved = customPalette('night', 'Night colors', ['#101020', '#f0f0ff']); + loadCustomPalettesOrThrowMock + .mockResolvedValueOnce([saved]) + .mockResolvedValueOnce([]); + const element = await createDialog(); + chooseFile(element, new File(['pixels'], 'portrait.png', { type: 'image/png' })); + await Promise.resolve(); + await vi.runAllTimersAsync(); + await selectPalette(element, 'custom:night'); + await vi.runAllTimersAsync(); + + element.open = false; + await settle(element); + element.open = true; + await settle(element); + await vi.runAllTimersAsync(); + await settle(element); + + const paletteState = element as unknown as { + selectedPalette: string; + paletteSource: string; + restrictedPalette: string[] | undefined; + }; + expect(element.shadowRoot?.querySelector( + '#guided-palette-source', + )?.value).toBe('generated'); + expect(paletteState.selectedPalette).toBe('generated'); + expect(paletteState.paletteSource).toBe('generated'); + expect(paletteState.restrictedPalette).toBeUndefined(); + expect(generateNumberedGuideMock).toHaveBeenLastCalledWith(sampledImage, { + maxColors: 8, + palette: undefined, + mapping: 'color', + simplifyIsolatedPixels: true, + }); + }); + + it('waits for a selected custom palette to reload before creating a project', async () => { + const saved = customPalette('night', 'Night colors', ['#101020', '#f0f0ff']); + const updatedColors = Array.from( + { length: MAX_GUIDE_COLORS + 1 }, + (_, index) => `#${index.toString(16).padStart(6, '0')}`, + ); + const updated = customPalette('night', 'Updated night colors', updatedColors); + let resolveReload: ((palettes: CustomPalette[]) => void) | undefined; + loadCustomPalettesOrThrowMock + .mockResolvedValueOnce([saved]) + .mockImplementationOnce(() => new Promise((resolve) => { + resolveReload = resolve; + })); + const element = await createDialog(); + const file = new File(['pixels'], 'portrait.png', { type: 'image/png' }); + chooseFile(element, file); + await Promise.resolve(); + await vi.runAllTimersAsync(); + await selectPalette(element, 'custom:night'); + await vi.runAllTimersAsync(); + + element.open = false; + await settle(element); + element.open = true; + await settle(element); + + const create = element.shadowRoot?.querySelector( + 'button[form="guided-project-form"]', + ); + expect(create?.disabled).toBe(true); + + create?.click(); + element.shadowRoot?.querySelector('#guided-project-form') + ?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await settle(element); + + expect(createGuidedProjectMock).not.toHaveBeenCalled(); + + resolveReload?.([updated]); + await settle(element); + await vi.runAllTimersAsync(); + await settle(element); + + const adaptedColors = updatedColors.slice(0, MAX_GUIDE_COLORS); + const select = element.shadowRoot?.querySelector( + '#guided-palette-source', + ); + expect(select?.value).toBe('custom:night'); + expect(Array.from(select?.options ?? []).map((option) => option.textContent)).toContain( + 'Updated night colors', + ); + expect(generateNumberedGuideMock).toHaveBeenLastCalledWith(sampledImage, { + maxColors: undefined, + palette: adaptedColors, + mapping: 'color', + simplifyIsolatedPixels: true, + }); + expect(element.shadowRoot?.querySelector('#guided-palette-status')?.textContent).toContain( + 'uses the first 255 colors', + ); + expect(create?.disabled).toBe(false); + + create?.click(); + await settle(element); + + expect(createGuidedProjectMock).toHaveBeenCalledWith({ + guide, + settings: { + longSide: 24, + paletteSource: 'restricted', + maxColors: undefined, + restrictedPalette: adaptedColors, + mapping: 'color', + simplifyIsolatedPixels: true, + }, + sourceName: file.name, + }); + }); + + it('blocks duplicate palette saves while persistence is pending', async () => { + const pendingPalette = customPalette( + 'new-palette', + 'Pending palette', + ['#000000'], + ); + let resolveSave: ((palette: CustomPalette) => void) | undefined; + saveAsNewPaletteMock.mockImplementationOnce(() => new Promise((resolve) => { + resolveSave = resolve; + })); + loadCustomPalettesOrThrowMock.mockResolvedValueOnce([]); + const element = await createDialog(); + await selectPalette(element, 'create'); + const name = element.shadowRoot?.querySelector('#guided-palette-name'); + if (name) { + name.value = 'Pending palette'; + name.dispatchEvent(new Event('input')); + } + const save = element.shadowRoot?.querySelector( + 'button[form="palette-creation-form"]', + ); + const color = element.shadowRoot?.querySelector( + 'input[aria-label="Color 1"]', + ); + + save?.click(); + save?.click(); + await settle(element); + + expect(saveAsNewPaletteMock).toHaveBeenCalledTimes(1); + expect(save?.disabled).toBe(true); + expect(save?.textContent).toContain('Saving…'); + expect(name?.disabled).toBe(true); + expect(color?.disabled).toBe(true); + + save?.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + composed: true, + })); + await settle(element); + + expect(element.shadowRoot?.querySelector('#palette-creation-form')).toBeTruthy(); + expect(saveAsNewPaletteMock).toHaveBeenCalledTimes(1); + + resolveSave?.(pendingPalette); + await settle(element); + expect(loadCustomPalettesOrThrowMock).toHaveBeenCalledTimes(1); + expect(element.shadowRoot?.querySelector( + '#guided-palette-source', + )?.value).toBe('custom:new-palette'); + }); + it('ignores a stale image decode after a newer file is selected', async () => { const element = await createDialog(); const firstImage = { ...sourceImage, width: 10 } as ImageData; diff --git a/tests/services/persistence/palette-persistence.test.ts b/tests/services/persistence/palette-persistence.test.ts new file mode 100644 index 00000000..cc6500e5 --- /dev/null +++ b/tests/services/persistence/palette-persistence.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const database = vi.hoisted(() => ({ + getAllFromIndex: vi.fn(), + get: vi.fn(), + put: vi.fn(), +})); +const openDatabase = vi.hoisted(() => vi.fn(() => Promise.resolve(database))); +const logError = vi.hoisted(() => vi.fn()); + +vi.mock('idb', () => ({ + openDB: openDatabase, +})); + +vi.mock('../../../src/utils/log', () => ({ + log: { + error: logError, + }, +})); + +import { palettePersistence } from '../../../src/services/persistence/palette-persistence'; +import type { StoredCustomPalette } from '../../../src/types/palette'; + +const palette: StoredCustomPalette = { + id: 'saved-palette', + name: 'Saved palette', + colors: ['#112233'], + createdAt: 1, + updatedAt: 1, +}; + +describe('palettePersistence.savePalette', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns true after IndexedDB accepts the palette', async () => { + database.put.mockResolvedValueOnce('saved-palette'); + + await expect(palettePersistence.savePalette(palette)).resolves.toBe(true); + expect(database.put).toHaveBeenCalledWith('palettes', palette); + }); + + it('returns false when IndexedDB rejects the write', async () => { + const error = new Error('IndexedDB unavailable'); + database.put.mockRejectedValueOnce(error); + + await expect(palettePersistence.savePalette(palette)).resolves.toBe(false); + expect(logError).toHaveBeenCalledWith( + 'Failed to save palette to IndexedDB:', + error, + ); + }); +}); + +describe('palettePersistence.updatePalette', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns true after IndexedDB updates an existing palette', async () => { + database.get.mockResolvedValueOnce(palette); + database.put.mockResolvedValueOnce('saved-palette'); + + await expect( + palettePersistence.updatePalette('saved-palette', { name: 'Renamed palette' }), + ).resolves.toBe(true); + expect(database.put).toHaveBeenCalledWith('palettes', { + ...palette, + name: 'Renamed palette', + updatedAt: expect.any(Number), + }); + }); + + it('returns false when IndexedDB rejects the update', async () => { + const error = new Error('IndexedDB unavailable'); + database.get.mockResolvedValueOnce(palette); + database.put.mockRejectedValueOnce(error); + + await expect( + palettePersistence.updatePalette('saved-palette', { name: 'Renamed palette' }), + ).resolves.toBe(false); + expect(logError).toHaveBeenCalledWith( + 'Failed to update palette in IndexedDB:', + error, + ); + }); +}); + +describe('palettePersistence palette loading', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('exposes IndexedDB failures to failure-aware callers', async () => { + const error = new Error('IndexedDB unavailable'); + database.getAllFromIndex.mockRejectedValueOnce(error); + + await expect(palettePersistence.getAllPalettesOrThrow()).rejects.toBe(error); + }); + + it('preserves the safe empty fallback for existing callers', async () => { + const error = new Error('IndexedDB unavailable'); + database.getAllFromIndex.mockRejectedValueOnce(error); + + await expect(palettePersistence.getAllPalettes()).resolves.toEqual([]); + expect(logError).toHaveBeenCalledWith( + 'Failed to load palettes from IndexedDB:', + error, + ); + }); +}); diff --git a/tests/stores/palette-persistence.test.ts b/tests/stores/palette-persistence.test.ts new file mode 100644 index 00000000..dfb9ec25 --- /dev/null +++ b/tests/stores/palette-persistence.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const savePalette = vi.hoisted(() => vi.fn()); +const getAllPalettesOrThrow = vi.hoisted(() => vi.fn()); +const updatePalette = vi.hoisted(() => vi.fn()); + +vi.mock('../../src/services/persistence/palette-persistence', () => ({ + palettePersistence: { + getAllPalettesOrThrow, + savePalette, + updatePalette, + }, +})); + +import { + loadCustomPalettesOrThrow, + renamePalette, + saveAsNewPalette, +} from '../../src/stores/palette/persistence'; + +describe('palette persistence helpers', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns the saved record after a confirmed write', async () => { + const colors = ['#112233', '#abcdef']; + savePalette.mockResolvedValueOnce(true); + + const palette = await saveAsNewPalette(' Forest ', colors); + + expect(palette).toMatchObject({ + name: 'Forest', + colors, + }); + expect(palette.id).toEqual(expect.any(String)); + expect(palette.createdAt).toEqual(expect.any(Number)); + expect(palette.updatedAt).toBe(palette.createdAt); + expect(palette.colors).not.toBe(colors); + expect(savePalette).toHaveBeenCalledWith(palette); + }); + + it('throws when the persistence service reports a failed write', async () => { + savePalette.mockResolvedValueOnce(false); + + await expect(saveAsNewPalette('Forest', ['#112233'])).rejects.toThrow( + 'Palette could not be saved.', + ); + }); + + it('preserves storage failures for failure-aware callers', async () => { + const error = new Error('IndexedDB unavailable'); + getAllPalettesOrThrow.mockRejectedValueOnce(error); + + await expect(loadCustomPalettesOrThrow()).rejects.toBe(error); + }); + + it('resolves a rename after the update is persisted', async () => { + updatePalette.mockResolvedValueOnce(true); + + await expect(renamePalette('saved-palette', ' Forest ')).resolves.toBeUndefined(); + expect(updatePalette).toHaveBeenCalledWith('saved-palette', { name: 'Forest' }); + }); + + it('rejects a rename when the update is not persisted', async () => { + updatePalette.mockResolvedValueOnce(false); + + await expect(renamePalette('saved-palette', 'Forest')).rejects.toThrow( + 'Palette could not be renamed.', + ); + }); +});