test(17-06): framework and admin SPA unit tests for the blog companions
- signpost: activation refusals, host overrides, D-02 pattern layers incl. PatternFrom, ReferenceOptions depths - seaglass: removal report, bluemonday bypass corpus, Check - pact Option.Depth omitempty and the menu item type contracts; party activates menu item types after Register and before the first Boot - cabana: repeater compile and decode, dependsOn compile and recheck, richeditor keys and notices, reorder and singleton boot checks (Docker-free); repeater save, dependent recheck, richeditor save, reorder and singleton routes on Postgres; conformance cases for the documented 404, 409 and 422 answers of the new routes - admin: richeditor value contract, link popover, notices and chunk failure; repeater bounds, remove and reorder, collapsed rows; dependent dropdown states and last request wins; reorder announcements, partition switch and tree rules; SingletonForm spec - gate: required names in the go, admin and postgres stages
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
import type { AdminRecord, FormField } from '../../src/api/types'
|
||||
// The registry first: it imports RepeaterField, whose FormGrid imports it back.
|
||||
import '../../src/components/form/registry'
|
||||
import RepeaterField from '../../src/components/form/fields/RepeaterField.vue'
|
||||
import { FORM_FEEDBACK, type FormFeedback } from '../../src/components/form/formContext'
|
||||
import { companionsFormSchemaFixture } from '../fixtures/typed'
|
||||
import { API, keydown, mountApp, requestsTo, resetState, type Route } from '../helpers'
|
||||
|
||||
@@ -164,3 +170,172 @@ describe('RepeaterField', () => {
|
||||
expect(wrapper.find('[data-action="repeater-add"]').text()).toBe('Add question')
|
||||
})
|
||||
})
|
||||
|
||||
// Plan 06 coverage on the field alone, with a recording form feedback:
|
||||
// row bounds, remove and reorder semantics, collapsed rows, read-only.
|
||||
const field: FormField = {
|
||||
name: 'rows',
|
||||
label: 'Rows',
|
||||
type: 'repeater',
|
||||
span: 'full',
|
||||
titleFrom: 'title',
|
||||
minItems: 1,
|
||||
maxItems: 3,
|
||||
form: {
|
||||
fields: [
|
||||
{ name: 'title', type: 'text', label: 'Title', span: 'left' },
|
||||
{ name: 'body', type: 'richeditor', label: 'Body', span: 'full' },
|
||||
{ name: 'on', type: 'checkbox', label: 'On', span: 'right', default: true },
|
||||
],
|
||||
},
|
||||
} as FormField
|
||||
|
||||
function repeater(rows: AdminRecord[], options: { confirm?: boolean; readOnly?: boolean } = {}) {
|
||||
const announced: string[] = []
|
||||
const asked: string[] = []
|
||||
const feedback: FormFeedback = {
|
||||
errors: ref({}),
|
||||
notices: ref({}),
|
||||
clearError: () => undefined,
|
||||
announce: (text) => announced.push(text),
|
||||
confirm: async (request) => {
|
||||
asked.push(request.message)
|
||||
return options.confirm ?? true
|
||||
},
|
||||
}
|
||||
const wrapper: VueWrapper = mount(RepeaterField, {
|
||||
props: {
|
||||
field: options.readOnly ? { ...field, readOnly: true } : field,
|
||||
modelValue: rows,
|
||||
controlId: 'field-rows',
|
||||
'onUpdate:modelValue': (value: unknown) => wrapper.setProps({ modelValue: value }),
|
||||
},
|
||||
global: { provide: { [FORM_FEEDBACK as symbol]: feedback } },
|
||||
attachTo: document.body,
|
||||
})
|
||||
const values = () => (wrapper.props() as { modelValue: AdminRecord[] }).modelValue
|
||||
return { wrapper, announced, asked, values }
|
||||
}
|
||||
|
||||
const rowsOf = (wrapper: VueWrapper) => wrapper.findAll('[data-repeater-title]').map((node) => node.text())
|
||||
const row = (wrapper: VueWrapper, index: number) => wrapper.find(`[data-repeater-row="${index}"]`)
|
||||
|
||||
describe('RepeaterField rows', () => {
|
||||
it('enforces minItems and maxItems on remove, add and the counter', async () => {
|
||||
const { wrapper, values } = repeater([{ title: 'Only', body: null, on: true }])
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(row(wrapper, 0).find('[data-action="repeater-remove"]').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.find('[data-repeater-count]').text()).toBe('Pozycje: 1 z 3')
|
||||
expect(wrapper.find('[data-action="repeater-toggle-all"]').exists()).toBe(false)
|
||||
|
||||
await wrapper.find('[data-action="repeater-add"]').trigger('click')
|
||||
await wrapper.find('[data-action="repeater-add"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(values()).toEqual([
|
||||
{ title: 'Only', body: null, on: true },
|
||||
{ title: null, body: null, on: true },
|
||||
{ title: null, body: null, on: true },
|
||||
])
|
||||
expect(rowsOf(wrapper)).toEqual(['Only', 'Pozycja 2', 'Pozycja 3'])
|
||||
expect(wrapper.find('[data-action="repeater-add"]').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.find('[data-repeater-count]').text()).toBe('Pozycje: 3 z 3')
|
||||
// A fourth add does nothing.
|
||||
await wrapper.find('[data-action="repeater-add"]').trigger('click')
|
||||
expect(values()).toHaveLength(3)
|
||||
expect(row(wrapper, 0).find('[data-action="repeater-remove"]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('asks before removing a row with a value, never for an empty or default row, and keeps it on cancel', async () => {
|
||||
const { wrapper, asked, values } = repeater(
|
||||
[
|
||||
{ title: 'Kept', body: null, on: true },
|
||||
{ title: '', body: '', on: true },
|
||||
{ title: null, body: '<p>text</p>', on: true },
|
||||
],
|
||||
{ confirm: false },
|
||||
)
|
||||
// Only defaults and empty values: removed at once.
|
||||
await row(wrapper, 1).find('[data-action="repeater-remove"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(asked).toEqual([])
|
||||
expect(values()).toHaveLength(2)
|
||||
// A rich text value: asked, and cancel keeps the row.
|
||||
await row(wrapper, 1).find('[data-action="repeater-remove"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(asked).toEqual(['Usunąć „Pozycja 2”? Zmiana zostanie zapisana razem z rekordem.'])
|
||||
expect(values()).toHaveLength(2)
|
||||
expect(rowsOf(wrapper)).toEqual(['Kept', 'Pozycja 2'])
|
||||
// Removing moves focus to the handle that takes the row's place.
|
||||
const confirmed = repeater([{ title: 'A' }, { title: 'B' }, { title: 'C' }], { confirm: true })
|
||||
await row(confirmed.wrapper, 1).find('[data-action="repeater-remove"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(confirmed.asked).toHaveLength(1)
|
||||
expect(rowsOf(confirmed.wrapper)).toEqual(['A', 'C'])
|
||||
expect(document.activeElement).toBe(row(confirmed.wrapper, 1).find('[data-repeater-handle]').element)
|
||||
})
|
||||
|
||||
it('reorders by the arrow keys and the buttons, announces repeater.moved and stops at the ends', async () => {
|
||||
const { wrapper, announced, values } = repeater([{ title: 'A' }, { title: 'B' }, { title: 'C' }])
|
||||
expect(row(wrapper, 0).find('[data-action="repeater-up"]').attributes('disabled')).toBeDefined()
|
||||
expect(row(wrapper, 2).find('[data-action="repeater-down"]').attributes('disabled')).toBeDefined()
|
||||
|
||||
keydown(row(wrapper, 0).find('[data-repeater-handle]').element, 'ArrowUp')
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
|
||||
const down = keydown(row(wrapper, 0).find('[data-repeater-handle]').element, 'ArrowDown')
|
||||
await flushPromises()
|
||||
expect(down.defaultPrevented).toBe(true)
|
||||
expect(rowsOf(wrapper)).toEqual(['B', 'A', 'C'])
|
||||
expect(document.activeElement).toBe(row(wrapper, 1).find('[data-repeater-handle]').element)
|
||||
expect(announced).toEqual(['Przeniesiono A na pozycję 2 z 3.'])
|
||||
|
||||
await row(wrapper, 2).find('[data-action="repeater-up"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(rowsOf(wrapper)).toEqual(['B', 'C', 'A'])
|
||||
await row(wrapper, 0).find('[data-action="repeater-down"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(rowsOf(wrapper)).toEqual(['C', 'B', 'A'])
|
||||
expect(announced).toEqual(['Przeniesiono A na pozycję 2 z 3.', 'Przeniesiono C na pozycję 2 z 3.', 'Przeniesiono B na pozycję 2 z 3.'])
|
||||
expect(values().map((item) => item.title)).toEqual(['C', 'B', 'A'])
|
||||
// Other keys do nothing.
|
||||
const other = keydown(row(wrapper, 0).find('[data-repeater-handle]').element, 'ArrowRight')
|
||||
expect(other.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('collapsed rows do not mount their richeditors and keep their values', async () => {
|
||||
const { wrapper, values } = repeater([
|
||||
{ title: 'A', body: '<p>one</p>' },
|
||||
{ title: 'B', body: '<p>two</p>' },
|
||||
])
|
||||
const editors = () => wrapper.findAll('[data-field="body"]').length
|
||||
expect(editors()).toBe(2)
|
||||
await row(wrapper, 0).find('[data-action="repeater-toggle"]').trigger('click')
|
||||
expect(row(wrapper, 0).find('[data-repeater-body]').exists()).toBe(false)
|
||||
expect(row(wrapper, 0).find('[data-action="repeater-toggle"]').attributes('aria-expanded')).toBe('false')
|
||||
expect(editors()).toBe(1)
|
||||
|
||||
const all = wrapper.find('[data-action="repeater-toggle-all"]')
|
||||
expect(all.text()).toBe('Zwiń wszystkie')
|
||||
await all.trigger('click')
|
||||
expect(editors()).toBe(0)
|
||||
expect(wrapper.find('[data-action="repeater-toggle-all"]').text()).toBe('Rozwiń wszystkie')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(values()).toEqual([
|
||||
{ title: 'A', body: '<p>one</p>' },
|
||||
{ title: 'B', body: '<p>two</p>' },
|
||||
])
|
||||
|
||||
await wrapper.find('[data-action="repeater-toggle-all"]').trigger('click')
|
||||
expect(editors()).toBe(2)
|
||||
expect(row(wrapper, 1).find('[data-action="repeater-toggle"]').attributes('aria-controls')).toBe(row(wrapper, 1).find('[data-repeater-body]').attributes('id'))
|
||||
})
|
||||
|
||||
it('read-only shows every row expanded with no controls', () => {
|
||||
const { wrapper } = repeater([{ title: 'A', body: '<p>one</p>' }], { readOnly: true })
|
||||
expect(wrapper.find('[data-repeater-handle]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-action="repeater-add"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-action="repeater-remove"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-repeater-body]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user