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:
Jakub Zych
2026-10-14 20:38:52 +02:00
parent 0f09586ec0
commit 323b6d684a
8 changed files with 841 additions and 10 deletions

View File

@@ -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')
})
})

View File

@@ -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')