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:
2026-10-12 19:29:37 +02:00
parent 490a4e3e6a
commit 91593da1f7
17 changed files with 3136 additions and 17 deletions

View File

@@ -126,3 +126,110 @@ describe('DependentDropdown', () => {
expect(wrapper.find('#field-reference').text()).toContain('Hello world')
})
})
// Plan 06 coverage: every UI-SPEC state, last request wins, stale stored
// values, the save recheck message and a dropdown inside a repeater row.
function deferred() {
let resolve: (reply: Reply) => void = () => undefined
const promise = new Promise<Reply>((done) => {
resolve = done
})
return { promise, resolve }
}
describe('DependentDropdown states', () => {
it('shows loading while the options load, then the empty state naming the type', async () => {
const pending = deferred()
const { wrapper } = await mountApp(
'/acme/companions/links/1',
routes('category', null, { [`GET ${REFERENCES}`]: () => pending.promise }),
{ attach: true },
)
const select = () => wrapper.find<HTMLSelectElement>('#field-reference')
expect(wrapper.find('[data-field="reference"] [data-dependent]').attributes('data-dependent')).toBe('loading')
expect(select().attributes('disabled')).toBeDefined()
expect(select().attributes('aria-busy')).toBe('true')
expect(select().text()).toBe('Wczytywanie…')
pending.resolve({ body: { data: { applies: true, options: [] }, meta: {} } })
await flushPromises()
expect(select().attributes('aria-busy')).toBeUndefined()
expect(select().attributes('disabled')).toBeDefined()
const hint = wrapper.find('[data-dependent-hint]')
expect(hint.text()).toBe('Brak rekordów typu „Category”. Najpierw dodaj rekord albo wybierz inny typ.')
expect(select().attributes('aria-describedby')).toContain(hint.attributes('id'))
})
it('drops a late answer for an old sibling value: the last request wins', async () => {
const slow = deferred()
const { wrapper, calls } = await mountApp(
'/acme/companions/links/1',
routes('post', '7', {
[`GET ${REFERENCES}`]: (request) => (queryOf(request).get('values[type]') === 'category' ? slow.promise : references(request)),
}),
{ attach: true },
)
const select = () => wrapper.find<HTMLSelectElement>('#field-reference')
await wrapper.find('#field-type').setValue('2') // category: pending
await flushPromises()
expect(select().text()).toBe('Wczytywanie…')
await wrapper.find('#field-type').setValue('0') // url: answered at once
await flushPromises()
expect(select().text()).toBe('Nie dotyczy tego wyboru')
slow.resolve({ body: { data: { applies: true, options: [{ value: '3', label: 'News', depth: 0 }] }, meta: {} } })
await flushPromises()
expect(select().text()).toBe('Nie dotyczy tego wyboru')
// The dropped answer was not cached: choosing category again asks again.
await wrapper.find('#field-type').setValue('2')
await flushPromises()
expect(requestsTo(calls, 'GET', REFERENCES).filter((request) => queryOf(request).get('values[type]') === 'category')).toHaveLength(2)
})
it('shows the placeholder for a stored value no option offers, without clearing it', async () => {
let body: Record<string, unknown> | null = null
const { wrapper } = await mountApp(
'/acme/companions/links/1',
routes('post', '99', {
[`PUT ${RECORD}`]: async (request) => {
body = (await request.json()) as Record<string, unknown>
return {
status: 422,
body: { error: { code: 'validation_failed', message: 'Validation failed', details: { reference: ['Wybierz jedną z dostępnych opcji.'] } } },
}
},
}),
{ attach: true },
)
const select = wrapper.find<HTMLSelectElement>('#field-reference')
expect(select.element.selectedOptions[0]!.text).toBe('Wybierz…')
expect(wrapper.find('[data-form-live]').text()).toBe('')
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
expect(body).toMatchObject({ type: 'post', reference: '99' })
expect(wrapper.find('[data-field="reference"]').text()).toContain('Wybierz jedną z dostępnych opcji.')
expect(wrapper.find('#field-reference').attributes('aria-invalid')).toBe('true')
})
it('a dropdown inside a repeater row asks with the row path and its row sibling', async () => {
const TARGETS = `${BASE}/fields/rows.target/dependent-options`
const { wrapper, calls } = await mountApp(
'/acme/companions/links/1',
routes('url', null, {
[`GET ${RECORD}`]: {
body: { data: { id: 1, title: 'Docs', type: 'url', reference: null, rows: [{ question: 'Q', kind: 'post', target: '8', visible: true }] }, meta: {} },
},
[`GET ${TARGETS}`]: (request) =>
queryOf(request).get('values[kind]') === 'post'
? { body: { data: { applies: true, options: [{ value: '7', label: 'Hello world', depth: 0 }, { value: '8', label: 'Nested post', depth: 1 }] }, meta: {} } }
: { status: 500, body: {} },
}),
{ attach: true },
)
const asked = requestsTo(calls, 'GET', TARGETS)
expect(asked).toHaveLength(1)
expect(queryOf(asked[0]!).get('values[kind]')).toBe('post')
const target = wrapper.find<HTMLSelectElement>('[data-repeater-row="0"] [data-field="target"] select')
expect(target.element.selectedOptions[0]!.text.trim()).toBe('Nested post')
})
})