test(18.1-06): taglist and flat reorder unit matrices, partitioned flat smoke and admin specs (task 2)
- TestTaglistFieldValidate: both modes, trim, dedupe, empty, null, wrong shapes, customTags false, required; TestTaglistFieldHydrate: stored NULL and bad text as [] - TestReorderFlatNodes: parent 422, duplicate and zero id 422, missing or extra 409, no depth or cycle check; TestReorderFlatCompile checks the localized list schema - TestReorderFlatSmoke/partitioned: flat reorder within one partition on Postgres - vitest: script markup as text, Backspace, blur, paste, datalist, customTags, string mode; flat ArrowUp/ArrowDown and the before/after drop zones - export_test.go wrappers for the taglist save check, hydration and node check - check-phase18.1.sh requires the new names in the go, admin and postgres stages
This commit is contained in:
@@ -152,3 +152,96 @@ describe('TaglistField', () => {
|
||||
expect(wrapper.find('input').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// Plan 18.1-06 coverage of the taglist control: markup safety (T-18.1-02),
|
||||
// keyboard add and remove, suggestions, customTags and string mode.
|
||||
describe('TaglistField coverage', () => {
|
||||
it('renders a tag holding a script element as text and runs nothing', async () => {
|
||||
;(window as unknown as { __pwned?: boolean }).__pwned = false
|
||||
const tag = '<script>window.__pwned = true</script>'
|
||||
const wrapper = mountField({ modelValue: [tag, '<img src=x onerror="window.__pwned=true">'] })
|
||||
await nextTick()
|
||||
expect(wrapper.find('script').exists()).toBe(false)
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
expect(chips(wrapper)).toEqual([tag, '<img src=x onerror="window.__pwned=true">'])
|
||||
expect(wrapper.find('[data-taglist-remove]').attributes('aria-label')).toBe(`Remove ${tag}`)
|
||||
expect((window as unknown as { __pwned?: boolean }).__pwned).toBe(false)
|
||||
})
|
||||
|
||||
it('removes the last chip on Backspace in an empty input, and nothing while text is typed', async () => {
|
||||
const wrapper = mountField({ modelValue: ['a', 'b', 'c'] })
|
||||
await type(wrapper, 'x')
|
||||
await input(wrapper).trigger('keydown', { key: 'Backspace' })
|
||||
expect(emitted(wrapper)).toEqual([])
|
||||
await type(wrapper, '')
|
||||
await input(wrapper).trigger('keydown', { key: 'Backspace' })
|
||||
expect(emitted(wrapper)).toEqual([['a', 'b']])
|
||||
expect(chips(wrapper)).toEqual(['a', 'b'])
|
||||
await input(wrapper).trigger('keydown', { key: 'Backspace' })
|
||||
await input(wrapper).trigger('keydown', { key: 'Backspace' })
|
||||
await input(wrapper).trigger('keydown', { key: 'Backspace' })
|
||||
expect(emitted(wrapper)).toEqual([['a', 'b'], ['a'], []])
|
||||
expect(chips(wrapper)).toEqual([])
|
||||
})
|
||||
|
||||
it('adds the draft on blur', async () => {
|
||||
const wrapper = mountField({ modelValue: ['a'] })
|
||||
await type(wrapper, ' b ')
|
||||
await input(wrapper).trigger('blur')
|
||||
expect(emitted(wrapper)).toEqual([['a', 'b']])
|
||||
expect(input(wrapper).element.value).toBe('')
|
||||
})
|
||||
|
||||
it('adds every complete tag of a pasted list and keeps the unfinished rest as the draft', async () => {
|
||||
const wrapper = mountField({ modelValue: [] })
|
||||
await type(wrapper, 'a, b,,a,c')
|
||||
expect(emitted(wrapper)).toEqual([['a'], ['a', 'b']])
|
||||
expect(input(wrapper).element.value).toBe('c')
|
||||
await input(wrapper).trigger('keydown', { key: 'Enter' })
|
||||
expect(emitted(wrapper).at(-1)).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('offers the options as datalist suggestions bound to the input, and none without options', () => {
|
||||
const wrapper = mountField({ modelValue: [] })
|
||||
const list = wrapper.find('datalist')
|
||||
expect(list.attributes('id')).toBe('f-tags-options')
|
||||
expect(input(wrapper).attributes('list')).toBe('f-tags-options')
|
||||
expect(list.findAll('option').map((o) => o.attributes('value'))).toEqual(['alpha', 'beta'])
|
||||
const bare = mountField({ modelValue: [], field: { options: [] } })
|
||||
expect(bare.find('datalist').exists()).toBe(false)
|
||||
expect(bare.find('input').attributes('list')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('with customTags false ignores a free tag typed with the separator but adds a suggested one', async () => {
|
||||
const wrapper = mountField({ modelValue: ['alpha'], field: { customTags: false } })
|
||||
await type(wrapper, 'gamma,')
|
||||
expect(emitted(wrapper)).toEqual([])
|
||||
expect(chips(wrapper)).toEqual(['alpha'])
|
||||
await type(wrapper, 'beta,')
|
||||
expect(emitted(wrapper)).toEqual([['alpha', 'beta']])
|
||||
})
|
||||
|
||||
it('splits a comma string-mode value, trims and drops duplicates, and emits the joined string', async () => {
|
||||
const wrapper = mountField({ modelValue: ' a , ,b,a', field: { mode: 'string', separator: 'comma', options: [] } })
|
||||
expect(chips(wrapper)).toEqual(['a', 'b'])
|
||||
await type(wrapper, 'c,d,')
|
||||
expect(emitted(wrapper)).toEqual(['a,b,c', 'a,b,c,d'])
|
||||
await wrapper.find('[data-taglist-remove]').trigger('click')
|
||||
expect(emitted(wrapper).at(-1)).toBe('b,c,d')
|
||||
await wrapper.findAll('[data-taglist-remove]').at(-1)!.trigger('click')
|
||||
await wrapper.findAll('[data-taglist-remove]').at(-1)!.trigger('click')
|
||||
await wrapper.findAll('[data-taglist-remove]').at(-1)!.trigger('click')
|
||||
expect(emitted(wrapper).at(-1)).toBe('')
|
||||
})
|
||||
|
||||
it('reads a string as no tags in array mode', () => {
|
||||
const wrapper = mountField({ modelValue: 'a,b' })
|
||||
expect(chips(wrapper)).toEqual([])
|
||||
})
|
||||
|
||||
it('marks the input required and invalid for the form', () => {
|
||||
const wrapper = mountField({ modelValue: [], invalid: true, field: { required: true } })
|
||||
expect(input(wrapper).attributes('aria-required')).toBe('true')
|
||||
expect(input(wrapper).attributes('aria-invalid')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -533,6 +533,98 @@ describe('ReorderView flat', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('moves by ArrowUp and ArrowDown on the handles, keeps focus and announces, then sends one save with null parents', async () => {
|
||||
let body: unknown = null
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/companions/links/reorder',
|
||||
{
|
||||
[`GET ${REORDER}`]: flatTree(),
|
||||
[`POST ${REORDER}`]: async (request) => {
|
||||
body = await request.json()
|
||||
return flatTree()
|
||||
},
|
||||
},
|
||||
{ attach: true },
|
||||
)
|
||||
// ArrowUp on the first row and ArrowDown on the last change nothing.
|
||||
keydown(handle(wrapper, 1).element, 'ArrowUp')
|
||||
keydown(handle(wrapper, 3).element, 'ArrowDown')
|
||||
await flushPromises()
|
||||
expect(shape(wrapper)).toBe('Alpha Beta Gamma')
|
||||
expect(wrapper.find('[data-reorder-node="1"] [data-action="reorder-up"]').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.find('[data-reorder-node="3"] [data-action="reorder-down"]').attributes('disabled')).toBeDefined()
|
||||
|
||||
const down = keydown(handle(wrapper, 1).element, 'ArrowDown')
|
||||
expect(down.defaultPrevented).toBe(true)
|
||||
await flushPromises()
|
||||
expect(shape(wrapper)).toBe('Beta Alpha Gamma')
|
||||
expect(document.activeElement).toBe(handle(wrapper, 1).element)
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('[data-reorder-live]').text()).toBe('Przeniesiono Alpha na pozycję 2 z 3.')
|
||||
})
|
||||
const up = keydown(handle(wrapper, 3).element, 'ArrowUp')
|
||||
expect(up.defaultPrevented).toBe(true)
|
||||
await flushPromises()
|
||||
expect(shape(wrapper)).toBe('Beta Gamma Alpha')
|
||||
expect(document.activeElement).toBe(handle(wrapper, 3).element)
|
||||
// ArrowRight and ArrowLeft are not taken from the browser on a flat list.
|
||||
expect(keydown(handle(wrapper, 3).element, 'ArrowRight').defaultPrevented).toBe(false)
|
||||
expect(keydown(handle(wrapper, 3).element, 'ArrowLeft').defaultPrevented).toBe(false)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(1)
|
||||
})
|
||||
expect(body).toEqual({
|
||||
partition: null,
|
||||
nodes: [
|
||||
{ id: 2, parent_id: null, sort_order: 1 },
|
||||
{ id: 3, parent_id: null, sort_order: 2 },
|
||||
{ id: 1, parent_id: null, sort_order: 3 },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('maps the top third of a row to before, the bottom third to after and the middle to after on a flat list', async () => {
|
||||
const { wrapper } = await mountApp('/acme/companions/links/reorder', { [`GET ${REORDER}`]: flatTree(), [`POST ${REORDER}`]: flatTree() }, { attach: true })
|
||||
const box = { top: 0, height: 30, bottom: 30, left: 0, right: 100, width: 100, x: 0, y: 0, toJSON: () => ({}) } as DOMRect
|
||||
const row = (id: number) => {
|
||||
const found = wrapper.find(`[data-reorder-node="${id}"]`)
|
||||
vi.spyOn(found.element, 'getBoundingClientRect').mockReturnValue(box)
|
||||
return found
|
||||
}
|
||||
const lineOf = (id: number) => wrapper.find(`[data-reorder-node="${id}"] [data-reorder-line]`)
|
||||
|
||||
// Gamma dragged over the top third of Alpha: a line at the top, dropped before Alpha.
|
||||
await handle(wrapper, 3).trigger('dragstart')
|
||||
const alpha = row(1)
|
||||
await alpha.trigger('dragover', { clientY: 2 })
|
||||
expect(lineOf(1).classes()).toContain('top-0')
|
||||
expect(alpha.classes()).not.toContain('outline-dashed')
|
||||
await alpha.trigger('drop')
|
||||
await flushPromises()
|
||||
expect(shape(wrapper)).toBe('Gamma Alpha Beta')
|
||||
|
||||
// Gamma dragged over the middle of Beta: never an inside highlight, a line at the bottom, dropped after Beta.
|
||||
await handle(wrapper, 3).trigger('dragstart')
|
||||
const beta = row(2)
|
||||
await beta.trigger('dragover', { clientY: 15 })
|
||||
expect(lineOf(2).classes()).toContain('bottom-0')
|
||||
expect(beta.classes()).not.toContain('outline-dashed')
|
||||
await beta.trigger('drop')
|
||||
await flushPromises()
|
||||
expect(shape(wrapper)).toBe('Alpha Beta Gamma')
|
||||
|
||||
// Alpha dragged over the bottom third of Gamma lands after it.
|
||||
await handle(wrapper, 1).trigger('dragstart')
|
||||
const gamma = row(3)
|
||||
await gamma.trigger('dragover', { clientY: 28 })
|
||||
expect(lineOf(3).classes()).toContain('bottom-0')
|
||||
await gamma.trigger('drop')
|
||||
await flushPromises()
|
||||
expect(shape(wrapper)).toBe('Beta Gamma Alpha')
|
||||
expect(wrapper.findAll('[data-reorder-tree] ul')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('maps the inside zone to after, keeps sibling moves and flattens with null parents', () => {
|
||||
expect(flatZone('inside', true)).toBe('after')
|
||||
expect(flatZone('inside', false)).toBe('inside')
|
||||
|
||||
Reference in New Issue
Block a user