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