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
106 lines
5.1 KiB
TypeScript
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)
|
|
})
|