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,9 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { applyDrop, applyMove, buildTree, canDrop, canMove, flatten, type TreeNode } from '../../src/components/reorder/tree'
|
||||
import { setSettings } from '../../src/state/useSettings'
|
||||
import { toasts } from '../../src/state/useToasts'
|
||||
import { clone, companionsFormSchemaFixture, listFixture, listSchemaFixture } from '../fixtures/typed'
|
||||
import { API, keydown, mountApp, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
|
||||
import { API, keydown, mountApp, queryOf, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
|
||||
|
||||
// Phase 17 plan 02 smoke specs for the tree reorder page (D-09), the list's
|
||||
// Reorder button and the singleton form (D-11). Plan 06 completes them.
|
||||
@@ -219,3 +220,155 @@ describe('singleton form', () => {
|
||||
expect(router.currentRoute.value.path).toBe('/settings')
|
||||
})
|
||||
})
|
||||
|
||||
// Plan 06 coverage: announcements per keyboard move, children moving with
|
||||
// their parent, loading and load failure, the partition switch flushing a
|
||||
// pending save, and the tree rules (no drop into the own subtree).
|
||||
describe('ReorderView moves', () => {
|
||||
it('announces every keyboard move and moves children with their parent', async () => {
|
||||
let body: { nodes: unknown[] } | null = null
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/companions/links/reorder',
|
||||
{
|
||||
[`GET ${REORDER}`]: tree(),
|
||||
[`POST ${REORDER}`]: async (request) => {
|
||||
body = (await request.json()) as { nodes: unknown[] }
|
||||
return tree()
|
||||
},
|
||||
},
|
||||
{ attach: true },
|
||||
)
|
||||
const live = () => wrapper.find('[data-reorder-live]').text()
|
||||
|
||||
keydown(handle(wrapper, 2).element, 'ArrowUp')
|
||||
await flushPromises()
|
||||
expect(shape(wrapper)).toBe('Blog(News) Home Bez tytułu (#4)')
|
||||
await vi.waitFor(() => expect(live()).toBe('Przeniesiono Blog na pozycję 1 z 3.'))
|
||||
|
||||
keydown(handle(wrapper, 1).element, 'ArrowRight')
|
||||
await flushPromises()
|
||||
expect(shape(wrapper)).toBe('Blog(News Home) Bez tytułu (#4)')
|
||||
await vi.waitFor(() => expect(live()).toBe('Home jest teraz wewnątrz Blog.'))
|
||||
|
||||
keydown(handle(wrapper, 3).element, 'ArrowLeft')
|
||||
await flushPromises()
|
||||
expect(shape(wrapper)).toBe('Blog(Home) News Bez tytułu (#4)')
|
||||
await vi.waitFor(() => expect(live()).toBe('Przeniesiono News na poziom 1.'))
|
||||
expect(document.activeElement).toBe(handle(wrapper, 3).element)
|
||||
|
||||
// A move that cannot run changes and announces nothing.
|
||||
const before = live()
|
||||
keydown(handle(wrapper, 2).element, 'ArrowUp')
|
||||
await flushPromises()
|
||||
expect(shape(wrapper)).toBe('Blog(Home) News Bez tytułu (#4)')
|
||||
expect(live()).toBe(before)
|
||||
|
||||
await vi.waitFor(() => expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(1))
|
||||
expect(body!.nodes).toEqual([
|
||||
{ id: 2, parent_id: null, sort_order: 1 },
|
||||
{ id: 1, parent_id: 2, sort_order: 2 },
|
||||
{ id: 3, parent_id: null, sort_order: 3 },
|
||||
{ id: 4, parent_id: null, sort_order: 4 },
|
||||
])
|
||||
})
|
||||
|
||||
it('shows the loading skeleton, then the load failure alert', async () => {
|
||||
let release: (reply: Reply) => void = () => undefined
|
||||
const pending = new Promise<Reply>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const { wrapper } = await mountApp('/acme/companions/links/reorder', { [`GET ${REORDER}`]: () => pending })
|
||||
expect(wrapper.find('[data-reorder-card]').attributes('aria-busy')).toBe('true')
|
||||
expect(wrapper.findAll('[data-reorder-skeleton]')).toHaveLength(5)
|
||||
release({ status: 500, body: { error: { code: 'x', message: 'x', details: {} } } })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[role="alert"]').text()).toContain('Nie udało się wczytać rekordów.')
|
||||
expect(wrapper.find('[data-reorder-card]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('a partition switch sends the pending save first, then loads the chosen partition', async () => {
|
||||
const order: string[] = []
|
||||
const { wrapper, router, calls } = await mountApp(
|
||||
'/acme/companions/links/reorder',
|
||||
{
|
||||
[`GET ${REORDER}`]: (request) => {
|
||||
order.push(`GET ${queryOf(request).get('partition') ?? '-'}`)
|
||||
return tree()
|
||||
},
|
||||
[`POST ${REORDER}`]: async (request) => {
|
||||
const sent = (await request.json()) as { partition: number }
|
||||
order.push(`POST ${sent.partition}`)
|
||||
return tree()
|
||||
},
|
||||
},
|
||||
{ attach: true },
|
||||
)
|
||||
keydown(handle(wrapper, 1).element, 'ArrowDown')
|
||||
await flushPromises()
|
||||
await wrapper.find('[data-reorder-partition]').setValue('6')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.query.partition).toBe('6')
|
||||
expect(order).toEqual(['GET -', 'POST 5', 'GET 6'])
|
||||
await wait(500)
|
||||
expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reorder tree rules', () => {
|
||||
// The OpenAPI document types parent_id as a number although the wire
|
||||
// carries null at the top (17-02 deferred item 3), hence the cast.
|
||||
type Wire = Parameters<typeof buildTree>[0]
|
||||
const nodes = [
|
||||
{ id: 1, name: 'Home', parent_id: null, sort_order: 2 },
|
||||
{ id: 2, name: 'Blog', parent_id: null, sort_order: 1 },
|
||||
{ id: 3, name: 'News', parent_id: 2, sort_order: 1 },
|
||||
{ id: 4, name: 'Deep', parent_id: 3, sort_order: 1 },
|
||||
{ id: 5, name: 'Orphan', parent_id: 99, sort_order: 3 },
|
||||
] as unknown as Wire
|
||||
const names = (list: TreeNode[]): string => list.map((node) => (node.children.length ? `${node.name}(${names(node.children)})` : node.name)).join(' ')
|
||||
|
||||
it('builds the tree by sort order and puts unknown parents and cycles at the top', () => {
|
||||
expect(names(buildTree(nodes))).toBe('Blog(News(Deep)) Home Orphan')
|
||||
const cycle = buildTree([
|
||||
{ id: 1, name: 'A', parent_id: 2, sort_order: 1 },
|
||||
{ id: 2, name: 'B', parent_id: 1, sort_order: 2 },
|
||||
{ id: 3, name: 'C', parent_id: null, sort_order: 3 },
|
||||
] as unknown as Wire)
|
||||
expect(names(cycle).split(' ').sort()).toEqual(['A(B)', 'C'].sort())
|
||||
})
|
||||
|
||||
it('refuses a drop into the node itself or its own subtree and leaves the tree as it was', () => {
|
||||
const tree = buildTree(nodes)
|
||||
for (const target of [2, 3, 4]) {
|
||||
expect(canDrop(tree, 2, target)).toBe(false)
|
||||
for (const zone of ['before', 'after', 'inside'] as const) {
|
||||
expect(applyDrop(tree, 2, target, zone)).toBe(false)
|
||||
}
|
||||
}
|
||||
expect(names(tree)).toBe('Blog(News(Deep)) Home Orphan')
|
||||
expect(canDrop(tree, 4, 2)).toBe(true)
|
||||
expect(applyDrop(tree, 4, 1, 'inside')).toBe(true)
|
||||
expect(applyDrop(tree, 5, 2, 'before')).toBe(true)
|
||||
expect(applyDrop(tree, 3, 1, 'after')).toBe(true)
|
||||
expect(names(tree)).toBe('Orphan Blog Home(Deep) News')
|
||||
})
|
||||
|
||||
it('moves only where a sibling or parent exists, and flattens pre-order from 1', () => {
|
||||
const tree = buildTree(nodes)
|
||||
expect(canMove(tree, 2, 'up')).toBe(false)
|
||||
expect(canMove(tree, 2, 'indent')).toBe(false)
|
||||
expect(canMove(tree, 2, 'outdent')).toBe(false)
|
||||
expect(canMove(tree, 5, 'down')).toBe(false)
|
||||
expect(canMove(tree, 99, 'down')).toBe(false)
|
||||
expect(applyMove(tree, 4, 'outdent')).toBe(true)
|
||||
expect(applyMove(tree, 1, 'indent')).toBe(true)
|
||||
expect(names(tree)).toBe('Blog(News Deep Home) Orphan')
|
||||
expect(flatten(tree)).toEqual([
|
||||
{ id: 2, parent_id: null, sort_order: 1 },
|
||||
{ id: 3, parent_id: 2, sort_order: 2 },
|
||||
{ id: 4, parent_id: 2, sort_order: 3 },
|
||||
{ id: 1, parent_id: 2, sort_order: 4 },
|
||||
{ id: 5, parent_id: null, sort_order: 5 },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user