LoVisual/frontend/src/features/themes/editor/tests/panel.test.tsx
loki5512344 c2c7e8fa91
feat(frontend): redesign the theme editor page
The editor's control panel is now a second ClickGui window skinned
live with the theme being edited, so every change shows in the tool
itself, not just the preview -- same "the section is a ClickGui
window" move the landing's ThemeStrip already uses, applied to a
working tool this time.

- Removed the three uppercase eyebrow-style section labels (the same
  generic-tell pattern already fixed on the landing page); colour
  slots are grouped into named fieldsets (Window/Modules/Text) instead
- Panel split into panel/{PanelHeader,PresetPicker,PaletteTabs,
  ExportActions}.tsx to stay under the file-size limit
- Preset chips got a real swatch (accent dot over a mini card
  gradient) -- previously same-hued presets like Classic/Blue/Azure
  all read as identical blue dots
- Copy/share buttons confirm inline ("Скопировано"/"Ссылка
  скопирована"); import/clipboard failures now surface a message
  instead of failing silently
- Rewrote themes copy to match the landing's voice

Claude-Session: https://claude.ai/code/session_01F1M1Jic1wTSn4igUENynmZ
2026-09-30 10:28:59 +02:00

106 lines
5.1 KiB
TypeScript

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test, vi } from 'vitest'
import { PRESETS } from '../../presets.generated'
import { ThemeEditor } from '../ThemeEditor'
import { fromShareHash, toProfileJson } from '../model/codec'
const nameBox = () => screen.getByRole('textbox', { name: 'Название темы' })
test('undo and redo walk the history from the header buttons and from Ctrl+Z / Ctrl+Shift+Z', async () => {
const user = userEvent.setup()
render(<ThemeEditor initial={PRESETS[0]} />)
const undo = screen.getByRole('button', { name: 'Отменить' })
const redo = screen.getByRole('button', { name: 'Вернуть' })
expect(undo).toBeDisabled()
// A preset pick starts a fresh history; an edit (typing, one coalesced step) is what undo reverts.
const edited = `${PRESETS[0].name} 2`
await user.type(nameBox(), ' 2')
expect(nameBox()).toHaveValue(edited)
expect(undo).toBeEnabled()
await user.click(undo)
expect(nameBox()).toHaveValue(PRESETS[0].name)
await user.click(redo)
expect(nameBox()).toHaveValue(edited)
await user.keyboard('{Control>}z{/Control}')
expect(nameBox()).toHaveValue(PRESETS[0].name)
await user.keyboard('{Control>}{Shift>}z{/Shift}{/Control}')
expect(nameBox()).toHaveValue(edited)
})
test('reset names the theme it returns to and restores it', async () => {
const user = userEvent.setup()
render(<ThemeEditor initial={PRESETS[0]} />)
await user.type(nameBox(), ' edited')
await user.click(screen.getByRole('button', { name: `Сбросить до «${PRESETS[0].name}»` }))
expect(nameBox()).toHaveValue(PRESETS[0].name)
})
test('renaming the theme updates the exported name and id', async () => {
const user = userEvent.setup()
render(<ThemeEditor initial={PRESETS[0]} />)
await user.clear(nameBox())
await user.type(nameBox(), 'My Night')
await user.click(screen.getByRole('button', { name: 'Скопировать для мода' }))
expect(JSON.parse(await navigator.clipboard.readText()).name).toBe('My Night')
})
test('copy puts the profile JSON on the clipboard and the button confirms', async () => {
const user = userEvent.setup()
render(<ThemeEditor initial={PRESETS[1]} />)
await user.click(screen.getByRole('button', { name: 'Скопировать для мода' }))
expect(await navigator.clipboard.readText()).toBe(toProfileJson(PRESETS[1]))
expect(screen.getByRole('button', { name: 'Скопировано' })).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('Скопировано')
})
test('share copies a link whose hash decodes back to the draft', async () => {
const user = userEvent.setup()
render(<ThemeEditor initial={PRESETS[4]} />)
await user.click(screen.getByRole('button', { name: 'Скопировать ссылку' }))
const link = await navigator.clipboard.readText()
const hash = /#t=(.+)$/.exec(link)?.[1] ?? ''
expect(fromShareHash(hash)).toMatchObject({ name: PRESETS[4].name, theme: PRESETS[4].theme })
expect(screen.getByRole('button', { name: 'Ссылка скопирована' })).toBeInTheDocument()
})
test('a blocked clipboard says what to do instead', async () => {
const user = userEvent.setup()
render(<ThemeEditor initial={PRESETS[0]} />)
vi.spyOn(navigator.clipboard, 'writeText').mockRejectedValueOnce(new Error('denied'))
await user.click(screen.getByRole('button', { name: 'Скопировать для мода' }))
expect(await screen.findByRole('status')).toHaveTextContent('Скачай .json')
expect(screen.getByRole('button', { name: 'Скопировать для мода' })).toBeInTheDocument()
})
test('download saves the profile JSON as <id>.json', async () => {
const user = userEvent.setup()
const createUrl = vi.fn(() => 'blob:theme')
Object.assign(URL, { createObjectURL: createUrl, revokeObjectURL: vi.fn() })
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
expect(this.download).toBe(`${PRESETS[0].id}.json`)
})
render(<ThemeEditor initial={PRESETS[0]} />)
await user.click(screen.getByRole('button', { name: 'Скачать .json' }))
expect(click).toHaveBeenCalledOnce()
const blob = (createUrl.mock.calls[0] as unknown as [Blob])[0]
expect(await blob.text()).toBe(toProfileJson(PRESETS[0]))
click.mockRestore()
})
test('loading a file imports a valid theme and explains a broken one', async () => {
const user = userEvent.setup()
const { container } = render(<ThemeEditor initial={PRESETS[0]} />)
const input = container.querySelector<HTMLInputElement>('input[type="file"]')!
await user.upload(input, new File([toProfileJson(PRESETS[5])], 'theme.json', { type: 'application/json' }))
expect(await screen.findByRole('status')).toHaveTextContent(`Загружена тема «${PRESETS[5].name}»`)
expect(nameBox()).toHaveValue(PRESETS[5].name)
await user.upload(input, new File(['not json'], 'broken.json', { type: 'application/json' }))
expect(await screen.findByText(/Это не тема LoVisual/)).toBeInTheDocument()
expect(nameBox()).toHaveValue(PRESETS[5].name)
})