|
| 1 | +/** |
| 2 | + * Mouse interaction tests for column sorting in the pipelines `Table`. |
| 3 | + * |
| 4 | + * The real pipelines `Table` is mounted with a handful of pipeline thumbs whose name order |
| 5 | + * and "status changed" order deliberately disagree, so every assertion about row |
| 6 | + * order can only pass if the click actually re-sorted the rows. The default sort |
| 7 | + * (name ascending) and its persistence both live in `useLayoutSettings`, so the |
| 8 | + * test also checks the localStorage key the component writes through. |
| 9 | + */ |
| 10 | + |
| 11 | +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' |
| 12 | +import { page } from 'vitest/browser' |
| 13 | +import { render } from 'vitest-browser-svelte' |
| 14 | +import type { PipelineThumb } from '$lib/services/pipelineManager' |
| 15 | +import { useLayoutSettings } from '$lib/compositions/layout/useLayoutSettings.svelte' |
| 16 | + |
| 17 | +const SORT_KEY = 'layout/pipelines/table/sort' |
| 18 | + |
| 19 | +// `vi.mock` factories are hoisted above the imports and run before ordinary |
| 20 | +// module-scope consts initialize, so a factory can't read them. `vi.hoisted` is |
| 21 | +// hoisted too, so its result is available to both the factory and the fixtures. |
| 22 | +const { PLATFORM_VERSION } = vi.hoisted(() => ({ PLATFORM_VERSION: '1.0.0' })) |
| 23 | + |
| 24 | +// Table and its PipelineVersion child both read `page.data.feldera`. A `version` |
| 25 | +// equal to every thumb's platformVersion keeps the runtime column in its |
| 26 | +// "latest" state, so no update Popover is rendered. |
| 27 | +vi.mock('$app/state', () => ({ |
| 28 | + page: { data: { feldera: { version: PLATFORM_VERSION, unstableFeatures: [] } } } |
| 29 | +})) |
| 30 | + |
| 31 | +import Table from './Table.svelte' |
| 32 | + |
| 33 | +// `lastStatusSince` is derived from these timestamps. The date order |
| 34 | +// (bravo < delta < charlie < alpha) is a rotation of the name order |
| 35 | +// (alpha < bravo < charlie < delta), so sorting by either column yields a |
| 36 | +// distinct, unambiguous row sequence. |
| 37 | +const lastChange: Record<string, string> = { |
| 38 | + alpha: '2024-01-04T00:00:00Z', |
| 39 | + bravo: '2024-01-01T00:00:00Z', |
| 40 | + charlie: '2024-01-03T00:00:00Z', |
| 41 | + delta: '2024-01-02T00:00:00Z' |
| 42 | +} |
| 43 | + |
| 44 | +// Only the fields the Table template reads are populated; the rest of |
| 45 | +// PipelineThumb is irrelevant to sorting, hence the cast. |
| 46 | +const thumb = (name: string): PipelineThumb => |
| 47 | + ({ |
| 48 | + name, |
| 49 | + description: '', |
| 50 | + status: 'Stopped', |
| 51 | + storageStatus: 'Cleared', |
| 52 | + deploymentStatusSince: lastChange[name], |
| 53 | + programStatusSince: lastChange[name], |
| 54 | + deploymentError: undefined, |
| 55 | + platformVersion: PLATFORM_VERSION, |
| 56 | + programConfig: { runtime_version: null }, |
| 57 | + deploymentResourcesStatus: 'Stopped', |
| 58 | + deploymentResourcesStatusSince: new Date(lastChange[name]), |
| 59 | + connectors: { numErrors: 0 } |
| 60 | + }) as unknown as PipelineThumb |
| 61 | + |
| 62 | +// Fed deliberately unsorted so a passing default-order assertion proves the |
| 63 | +// table sorted them rather than echoing the input order. |
| 64 | +const pipelines = [thumb('charlie'), thumb('alpha'), thumb('delta'), thumb('bravo')] |
| 65 | + |
| 66 | +// Each rendered row carries data-testid="box-row-<name>"; reading them back in |
| 67 | +// DOM order gives the visible row sequence. |
| 68 | +const rowOrder = () => |
| 69 | + Array.from(document.querySelectorAll('tbody tr[data-testid^="box-row-"]')).map((tr) => |
| 70 | + tr.getAttribute('data-testid')!.slice('box-row-'.length) |
| 71 | + ) |
| 72 | + |
| 73 | +// The sortable headers render their label inside the clickable <th>; the <th> |
| 74 | +// gains the `active` class while it is the column the table is sorted by. |
| 75 | +const header = (label: string) => page.getByText(label, { exact: true }) |
| 76 | +const headerCell = (label: string) => header(label).element().closest('th')! |
| 77 | + |
| 78 | +const persistedSort = () => JSON.parse(localStorage.getItem(SORT_KEY)!) |
| 79 | + |
| 80 | +const mountTable = () => render(Table, { props: { pipelines, selectedPipelines: [] } } as any) |
| 81 | + |
| 82 | +describe('Table — column sorting', () => { |
| 83 | + beforeEach(() => { |
| 84 | + // The persisted sort is a process-wide singleton (useLocalStorage caches by |
| 85 | + // key), so reset both the backing store and the cached value before each test |
| 86 | + // to keep them independent. |
| 87 | + localStorage.clear() |
| 88 | + useLayoutSettings().pipelinesTableSort.value = { column: 'name', direction: 'asc' } |
| 89 | + }) |
| 90 | + |
| 91 | + afterEach(() => { |
| 92 | + localStorage.clear() |
| 93 | + }) |
| 94 | + |
| 95 | + it('sorts by pipeline name ascending by default', async () => { |
| 96 | + mountTable() |
| 97 | + |
| 98 | + await expect.poll(rowOrder).toEqual(['alpha', 'bravo', 'charlie', 'delta']) |
| 99 | + expect(headerCell('Pipeline name').classList.contains('active')).toBe(true) |
| 100 | + }) |
| 101 | + |
| 102 | + it('clicking the name header toggles ascending → descending → ascending', async () => { |
| 103 | + mountTable() |
| 104 | + await expect.poll(rowOrder).toEqual(['alpha', 'bravo', 'charlie', 'delta']) |
| 105 | + |
| 106 | + await header('Pipeline name').click() |
| 107 | + await expect.poll(rowOrder).toEqual(['delta', 'charlie', 'bravo', 'alpha']) |
| 108 | + |
| 109 | + await header('Pipeline name').click() |
| 110 | + await expect.poll(rowOrder).toEqual(['alpha', 'bravo', 'charlie', 'delta']) |
| 111 | + }) |
| 112 | + |
| 113 | + it('clicking a different header sorts by that column and moves the active marker', async () => { |
| 114 | + mountTable() |
| 115 | + await expect.poll(rowOrder).toEqual(['alpha', 'bravo', 'charlie', 'delta']) |
| 116 | + |
| 117 | + // "Status changed" sorts by lastStatusSince, ascending on first click. |
| 118 | + await header('Status changed').click() |
| 119 | + await expect.poll(rowOrder).toEqual(['bravo', 'delta', 'charlie', 'alpha']) |
| 120 | + |
| 121 | + expect(headerCell('Status changed').classList.contains('active')).toBe(true) |
| 122 | + expect(headerCell('Pipeline name').classList.contains('active')).toBe(false) |
| 123 | + }) |
| 124 | + |
| 125 | + it('persists the active sort to localStorage', async () => { |
| 126 | + mountTable() |
| 127 | + await expect.poll(rowOrder).toEqual(['alpha', 'bravo', 'charlie', 'delta']) |
| 128 | + |
| 129 | + await header('Pipeline name').click() |
| 130 | + await expect.poll(persistedSort).toEqual({ column: 'name', direction: 'desc' }) |
| 131 | + |
| 132 | + await header('Status changed').click() |
| 133 | + await expect.poll(persistedSort).toEqual({ column: 'lastStatusSince', direction: 'asc' }) |
| 134 | + }) |
| 135 | + |
| 136 | + it('restores a persisted non-default sort on mount', async () => { |
| 137 | + // Simulate a returning user whose last sort was name descending. |
| 138 | + useLayoutSettings().pipelinesTableSort.value = { column: 'name', direction: 'desc' } |
| 139 | + |
| 140 | + mountTable() |
| 141 | + |
| 142 | + await expect.poll(rowOrder).toEqual(['delta', 'charlie', 'bravo', 'alpha']) |
| 143 | + expect(headerCell('Pipeline name').classList.contains('active')).toBe(true) |
| 144 | + }) |
| 145 | +}) |
0 commit comments