import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ref } from 'vue' import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' import ReorderBranch from '../../src/components/reorder/ReorderBranch.vue' import { REORDER_PAGE, type ReorderPage } from '../../src/components/reorder/context' import { applyDrop, applyMove, buildTree, canDrop, canMove, flatten, type TreeNode } from '../../src/components/reorder/tree' import { setSettings } from '../../src/state/useSettings' import ReorderView from '../../src/views/ReorderView.vue' import { toasts } from '../../src/state/useToasts' import { clone, companionsFormSchemaFixture, listFixture, listSchemaFixture } from '../fixtures/typed' 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. const BASE = `${API}/acme/companions/links` const REORDER = `${BASE}/reorder` function tree(): Reply { return { body: { data: { title: 'Reorder links', partition: { label: 'Group', value: 5, options: [ { value: 5, label: 'Main' }, { value: 6, label: 'Footer' }, ], }, nodes: [ { id: 1, name: 'Home', parent_id: null, sort_order: 1 }, { id: 2, name: 'Blog', parent_id: null, sort_order: 2 }, { id: 3, name: 'News', parent_id: 2, sort_order: 3 }, { id: 4, name: '', parent_id: null, sort_order: 4 }, ], }, meta: {}, }, } } /** The tree as "name(children)" strings, in display order. */ function shape(wrapper: VueWrapper): string { const read = (list: Element): string => Array.from(list.children) .map((item) => { const name = item.querySelector(':scope > [data-reorder-node] > span.min-w-0')?.textContent?.trim() ?? '?' const children = item.querySelector(':scope > ul') return children ? `${name}(${read(children)})` : name }) .join(' ') return read(wrapper.find('[data-reorder-tree]').element) } const handle = (wrapper: VueWrapper, id: number) => wrapper.find(`[data-reorder-handle="${id}"]`) beforeEach(() => { resetState() }) afterEach(() => { document.body.innerHTML = '' }) enableAutoUnmount(afterEach) describe('ReorderView', () => { it('ReorderView tracer: a keyboard move sends one debounced POST with the full node set', async () => { let body: unknown = null const { wrapper, calls } = await mountApp( '/acme/companions/links/reorder', { [`GET ${REORDER}`]: tree(), [`POST ${REORDER}`]: async (request) => { body = await request.json() return tree() }, }, { attach: true }, ) expect(wrapper.find('h1').text()).toBe('Reorder links') expect(wrapper.find('[data-reorder-tree]').attributes('aria-label')).toBe('Kolejność rekordów') expect(shape(wrapper)).toBe('Home Blog(News) Bez tytułu (#4)') expect(wrapper.find('[data-reorder-partition]').element.value).toBe('5') // First row: up, indent and outdent are impossible. const first = wrapper.find('[data-reorder-node="1"]') expect(first.find('[data-action="reorder-up"]').attributes('disabled')).toBeDefined() expect(first.find('[data-action="reorder-indent"]').attributes('disabled')).toBeDefined() expect(first.find('[data-action="reorder-outdent"]').attributes('disabled')).toBeDefined() // Three quick moves by keyboard on the handles. keydown(handle(wrapper, 1).element, 'ArrowDown') await flushPromises() expect(shape(wrapper)).toBe('Blog(News) Home Bez tytułu (#4)') expect(document.activeElement).toBe(handle(wrapper, 1).element) keydown(handle(wrapper, 1).element, 'ArrowRight') await flushPromises() expect(shape(wrapper)).toBe('Blog(News Home) Bez tytułu (#4)') keydown(handle(wrapper, 4).element, 'ArrowUp') await flushPromises() expect(shape(wrapper)).toBe('Bez tytułu (#4) Blog(News Home)') expect(document.activeElement).toBe(handle(wrapper, 4).element) expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(0) await vi.waitFor(() => { expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(1) }) expect(body).toEqual({ partition: 5, nodes: [ { id: 4, parent_id: null, sort_order: 1 }, { id: 2, parent_id: null, sort_order: 2 }, { id: 3, parent_id: 2, sort_order: 3 }, { id: 1, parent_id: 2, sort_order: 4 }, ], }) await vi.waitFor(() => { expect(wrapper.find('[data-reorder-status]').text()).toBe('Zapisano kolejność') }) expect(wrapper.find('[data-reorder-live]').text()).toBe('Przeniesiono Bez tytułu (#4) na pozycję 1 z 2.') await wait(500) expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(1) }) it('restores the saved tree when a save fails and reloads on a stale set', async () => { let status = 500 const { wrapper, calls } = await mountApp( '/acme/companions/links/reorder', { [`GET ${REORDER}`]: tree(), [`POST ${REORDER}`]: () => ({ status, body: { error: { code: status === 409 ? 'reorder_stale' : 'x', message: 'x', details: {} } } }), }, { attach: true }, ) await wrapper.find('[data-reorder-node="1"] [data-action="reorder-down"]').trigger('click') expect(shape(wrapper)).toBe('Blog(News) Home Bez tytułu (#4)') await vi.waitFor(() => { expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się zapisać nowej kolejności. Przywrócono poprzednią.', tone: 'danger' }) }) expect(shape(wrapper)).toBe('Home Blog(News) Bez tytułu (#4)') status = 409 await wrapper.find('[data-reorder-node="2"] [data-action="reorder-outdent"]').trigger('click') await wrapper.find('[data-reorder-node="3"] [data-action="reorder-outdent"]').trigger('click') await vi.waitFor(() => { expect(toasts.value[0]).toMatchObject({ text: 'Rekordy zmieniły się w międzyczasie. Lista została wczytana ponownie.', tone: 'danger' }) }) await flushPromises() expect(requestsTo(calls, 'GET', REORDER)).toHaveLength(2) expect(shape(wrapper)).toBe('Home Blog(News) Bez tytułu (#4)') }) it('shows the empty state and the 403 alert', async () => { const empty = tree() ;(empty.body as { data: { nodes: unknown[] } }).data.nodes = [] const { wrapper } = await mountApp('/acme/companions/links/reorder', { [`GET ${REORDER}`]: empty }) expect(wrapper.find('[data-reorder-empty]').text()).toContain('Nie ma jeszcze czego porządkować') wrapper.unmount() const forbidden = await mountApp('/acme/companions/links/reorder', { [`GET ${REORDER}`]: { status: 403, body: { error: { code: 'forbidden', message: 'x', details: {} } } }, }) expect(forbidden.wrapper.find('[role="alert"]').text()).toContain('Nie masz uprawnień') }) }) describe('list Reorder button', () => { it('links to the reorder page with the filtered partition', async () => { const schema = clone(listSchemaFixture) schema.data.toolbarButtons = ['create', 'reorder'] schema.data.reorder = { title: 'Reorder links', partition: { column: 'group_id', label: 'Group' } } schema.data.filters = [...schema.data.filters, { ...schema.data.filters[0]!, name: 'group', label: 'Group', column: 'group_id' }] const routes: Record = { [`GET ${BASE}/schema/list`]: { body: schema }, [`GET ${BASE}`]: { body: listFixture }, } const { wrapper } = await mountApp('/acme/companions/links?filter[group]=6', routes) const buttons = wrapper.findAll('header [data-action]').map((button) => button.attributes('data-action')) expect(buttons).toEqual(['reorder', 'create']) const reorder = wrapper.find('[data-action="reorder"]') expect(reorder.text()).toBe('Zmień kolejność rekordów') expect(reorder.attributes('href')).toBe('/acme/companions/links/reorder?partition=6') }) }) describe('singleton form', () => { it('opens the one record from settings, without Delete, and returns to the settings index', async () => { setSettings([ { code: 'profile', label: 'Site profile', description: '', icon: 'settings', category: 'CMS', keywords: [], model: '', controller: 'acme.companions.profiles', order: 1, }, ]) const schema = clone(companionsFormSchemaFixture) schema.data.singleton = true schema.data.fields = schema.data.fields.filter((field) => field.name === 'title') const PROFILES = `${API}/acme/companions/profiles` const { wrapper, router, calls } = await mountApp( '/settings', { [`GET ${PROFILES}/singleton`]: { body: { data: { id: 9 }, meta: {} } }, [`GET ${PROFILES}/schema/form`]: { body: schema }, [`GET ${PROFILES}/9`]: { body: { data: { id: 9, title: 'Main site' }, meta: {} } }, }, { attach: true }, ) await wrapper.find('[data-settings="profile"]').trigger('click', { button: 0 }) await flushPromises() expect(requestsTo(calls, 'GET', `${PROFILES}/singleton`)).toHaveLength(1) expect(router.currentRoute.value.path).toBe('/acme/companions/profiles/9') expect(wrapper.find('[data-action="delete"]').exists()).toBe(false) expect(wrapper.find('[data-action="back"]').attributes('aria-label')).toBe('Wróć do ustawień') await wrapper.find('[data-action="cancel"]').trigger('click') await flushPromises() 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((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) }) }) // WR-02: one save in flight at a time, later moves sent after it settles, // and a failure that a waiting save supersedes does not throw moves away. describe('ReorderView save ordering', () => { it('sends a move made during a save after that save, with the latest tree', async () => { const bodies: { nodes: { id: number }[] }[] = [] const releases: ((reply: Reply) => void)[] = [] const { wrapper, calls } = await mountApp( '/acme/companions/links/reorder', { [`GET ${REORDER}`]: tree(), [`POST ${REORDER}`]: async (request) => { bodies.push((await request.json()) as { nodes: { id: number }[] }) return new Promise((resolve) => releases.push(resolve)) }, }, { attach: true }, ) keydown(handle(wrapper, 1).element, 'ArrowDown') await flushPromises() await vi.waitFor(() => expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(1)) // A second move while the first save is in flight waits for it. keydown(handle(wrapper, 4).element, 'ArrowUp') await flushPromises() await wait(500) expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(1) // The first save fails: the later moves stay on screen, and the waiting // save sends them. releases[0]!({ status: 500, body: { error: { code: 'x', message: 'x', details: {} } } }) await vi.waitFor(() => expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(2)) expect(shape(wrapper)).toBe('Blog(News) Bez tytułu (#4) Home') expect(toasts.value).toHaveLength(0) expect(bodies[1]!.nodes.map((node) => node.id)).toEqual([2, 3, 4, 1]) releases[1]!(tree()) await vi.waitFor(() => { expect(wrapper.find('[data-reorder-status]').text()).toBe('Zapisano kolejność') }) expect(shape(wrapper)).toBe('Blog(News) Bez tytułu (#4) Home') await wait(500) expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(2) }) it('ignores a save response that arrives after the partition changed', async () => { let releaseFirst: (reply: Reply) => void = () => undefined let posts = 0 const footer = tree() ;(footer.body as { data: { nodes: unknown[] } }).data.nodes = [ { id: 7, name: 'Imprint', parent_id: null, sort_order: 1 }, { id: 8, name: 'Privacy', parent_id: null, sort_order: 2 }, ] const { wrapper } = await mountApp( '/acme/companions/links/reorder', { [`GET ${REORDER}`]: (request) => (queryOf(request).get('partition') === '6' ? footer : tree()), [`POST ${REORDER}`]: () => { posts++ if (posts === 1) { return new Promise((resolve) => { releaseFirst = resolve }) } return { status: 500, body: { error: { code: 'x', message: 'x', details: {} } } } }, }, { attach: true }, ) keydown(handle(wrapper, 1).element, 'ArrowDown') await flushPromises() await wrapper.find('[data-reorder-partition]').setValue('6') await flushPromises() await vi.waitFor(() => expect(shape(wrapper)).toBe('Imprint Privacy')) // The first partition's save succeeds late: the second partition's // screen and its last saved tree are not touched. releaseFirst(tree()) await flushPromises() expect(shape(wrapper)).toBe('Imprint Privacy') // A failed save on the second partition restores its own tree. keydown(handle(wrapper, 7).element, 'ArrowDown') await flushPromises() expect(shape(wrapper)).toBe('Privacy Imprint') await vi.waitFor(() => expect(toasts.value.length).toBeGreaterThan(0)) expect(shape(wrapper)).toBe('Imprint Privacy') }) }) 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[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 }, ]) }) }) // The reorder modules imported by path (T-10-25): the page the route renders, // its tree level and the injection key they share. describe('reorder modules', () => { it('the reorder route renders ReorderView with a ReorderBranch per tree level', async () => { const { wrapper } = await mountApp('/acme/companions/links/reorder', { [`GET ${REORDER}`]: tree() }) expect(wrapper.findComponent(ReorderView).exists()).toBe(true) // Top level and Blog's children. expect(wrapper.findAllComponents(ReorderBranch).length).toBe(2) }) it('ReorderBranch renders each node from the REORDER_PAGE context and moves through it', async () => { const move = vi.fn() const page: ReorderPage = { dragging: ref(null), dropTarget: ref(null), blocked: ref(null), nameOf: (node: TreeNode) => node.name, canMove: () => true, move, onHandleKeydown: vi.fn(), onDragStart: vi.fn(), onDragOver: vi.fn(), onDragLeave: vi.fn(), onDrop: vi.fn(), onDragEnd: vi.fn(), } const nodes: TreeNode[] = [ { id: 7, name: 'First', children: [] }, { id: 8, name: 'Second', children: [{ id: 9, name: 'Child', children: [] }] }, ] const wrapper = mount(ReorderBranch, { props: { nodes, depth: 0 }, global: { provide: { [REORDER_PAGE as symbol]: page } } }) const rows = wrapper.findAll('[data-reorder-node]') expect(rows.map((row) => row.attributes('data-reorder-node'))).toEqual(['7', '8', '9']) expect(rows.map((row) => row.find('span.min-w-0').text())).toEqual(['First', 'Second', 'Child']) await wrapper.find('[data-reorder-node="8"] [data-action="reorder-down"]').trigger('click') expect(move).toHaveBeenCalledWith(8, 'down') }) })