mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
test(assets): add property-based tests for asset utility functions (#10619)
## Summary Add property-based tests (using `fast-check`) for asset-related pure utility functions, complementing existing example-based unit tests with algebraic invariant checks across thousands of randomized inputs. Fixes #10617 ## Changes - **What**: 4 new `*.property.test.ts` files covering `assetFilterUtils`, `assetSortUtils`, `useAssetSelection`, and `useOutputStacks` — 32 property-based tests total ## Why property-based testing (fast-check)? ### Gap in existing tests The existing example-based unit tests (53 tests across 3 files) verify behavior for **hand-picked inputs** — specific category names, known sort orderings, fixed asset lists. This leaves two blind spots: 1. **Edge-case discovery**: Example tests only cover cases the author anticipates. Property tests generate hundreds of randomized inputs per run, probing boundaries the author didn't consider (e.g., empty strings, single-char names, deeply nested tag paths, assets with `undefined` metadata fields). 2. **Algebraic invariants**: Certain guarantees should hold for **all** inputs, not just the handful tested. For example: - "Filtering always produces a subset" — impossible to violate with 5 examples, easy to violate in production with unexpected metadata shapes - "Sorting is idempotent" — an unstable sort bug would only surface with specific duplicate patterns - "Reconciled selection IDs are always within visible assets" — a set-intersection bug might only appear with specific overlap patterns between selection and visible sets 3. **No test coverage for `useOutputStacks`**: The composable had zero tests before this PR. ### What these tests verify (invariant catalog) | Module | # Properties | Key invariants | |--------|-------------|----------------| | `assetFilterUtils` | 10 | Filter result ⊆ input; `"all"` is identity; ownership partitions into disjoint my/public; empty constraint is identity | | `assetSortUtils` | 8 | Never mutates input; output is permutation of input; idempotent (sort∘sort = sort); adjacent pairs satisfy comparator; `"default"` preserves order | | `useAssetSelection` | 7 | After reconcile: selected ⊆ visible; reconcile never adds new IDs; superset preserves all; empty visible clears; `getOutputCount` ≥ 1; `getTotalOutputCount` ≥ len(assets) | | `useOutputStacks` | 7 | Collapsed count = input count; items reference input assets; unique keys; selectableAssets length = assetItems length; no collapsed child flags; reactive ref updates | ### Quantitative impact Each property runs 100 iterations by default → **3,200 randomized inputs per test run** vs 53 hand-picked examples in existing tests. **Coverage delta** (v8, measured against target modules): | Module | Metric | Before (53 tests) | After (+32 property) | Delta | |--------|--------|-------------------|---------------------|-------| | `useAssetSelection.ts` | Branch | 76.92% | 94.87% | **+17.95pp** | | `useAssetSelection.ts` | Stmts | 82.50% | 90.00% | **+7.50pp** | | `useAssetSelection.ts` | Lines | 81.69% | 88.73% | **+7.04pp** | | `useOutputStacks.ts` | Stmts | 0% | 37.50% | **+37.50pp** (new) | | `useOutputStacks.ts` | Funcs | 0% | 75.00% | **+75.00pp** (new) | | `assetFilterUtils.ts` | All | 97.5%+ | 97.5%+ | maintained | | `assetSortUtils.ts` | All | 100% | 100% | maintained | ### Prior art Follows the established pattern from `src/platform/workflow/persistence/base/draftCacheV2.property.test.ts`. ## Review Focus - Are the chosen invariants correct and meaningful (not just change-detector tests)? - Are the `fc.Arbitrary` generators representative of real-world asset data shapes? ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10619-test-assets-add-property-based-tests-for-asset-utility-functions-3306d73d3650816985ebcd611bbe0837) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
import * as fc from 'fast-check'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
const mockShiftKey = ref(false)
|
||||
const mockCtrlKey = ref(false)
|
||||
const mockMetaKey = ref(false)
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as object),
|
||||
useKeyModifier: (key: string) => {
|
||||
if (key === 'Shift') return mockShiftKey
|
||||
if (key === 'Control') return mockCtrlKey
|
||||
if (key === 'Meta') return mockMetaKey
|
||||
return ref(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
import { useAssetSelection } from './useAssetSelection'
|
||||
import { useAssetSelectionStore } from './useAssetSelectionStore'
|
||||
|
||||
const arbAssetId = fc.stringMatching(/^[a-z0-9]{4,12}$/)
|
||||
|
||||
function arbAssets(minLength = 1, maxLength = 20): fc.Arbitrary<AssetItem[]> {
|
||||
return fc
|
||||
.uniqueArray(arbAssetId, { minLength, maxLength })
|
||||
.map((ids) =>
|
||||
ids.map((id) => ({ id, name: `${id}.png`, tags: [] }) satisfies AssetItem)
|
||||
)
|
||||
}
|
||||
|
||||
describe('useAssetSelection properties', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockShiftKey.value = false
|
||||
mockCtrlKey.value = false
|
||||
mockMetaKey.value = false
|
||||
})
|
||||
|
||||
describe('reconcileSelection', () => {
|
||||
it('after reconcile, selected IDs are always within visible assets', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
arbAssets(1, 15),
|
||||
arbAssets(1, 15),
|
||||
(initialAssets, visibleAssets) => {
|
||||
setActivePinia(createPinia())
|
||||
const selection = useAssetSelection()
|
||||
const store = useAssetSelectionStore()
|
||||
|
||||
store.setSelection(initialAssets.map((a) => a.id))
|
||||
selection.reconcileSelection(visibleAssets)
|
||||
|
||||
const visibleIds = new Set(visibleAssets.map((a) => a.id))
|
||||
for (const id of store.selectedAssetIds) {
|
||||
expect(visibleIds.has(id)).toBe(true)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('reconcile never adds new IDs that were not previously selected', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
arbAssets(1, 15),
|
||||
arbAssets(1, 15),
|
||||
(initialAssets, visibleAssets) => {
|
||||
setActivePinia(createPinia())
|
||||
const selection = useAssetSelection()
|
||||
const store = useAssetSelectionStore()
|
||||
|
||||
const initialIds = new Set(initialAssets.map((a) => a.id))
|
||||
store.setSelection([...initialIds])
|
||||
|
||||
selection.reconcileSelection(visibleAssets)
|
||||
|
||||
for (const id of store.selectedAssetIds) {
|
||||
expect(initialIds.has(id)).toBe(true)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('reconcile with superset of selected assets preserves all selections', () => {
|
||||
fc.assert(
|
||||
fc.property(arbAssets(1, 15), (assets) => {
|
||||
setActivePinia(createPinia())
|
||||
const selection = useAssetSelection()
|
||||
const store = useAssetSelectionStore()
|
||||
|
||||
const selectedIds = assets.map((a) => a.id)
|
||||
store.setSelection(selectedIds)
|
||||
|
||||
selection.reconcileSelection(assets)
|
||||
|
||||
expect(store.selectedAssetIds.size).toBe(selectedIds.length)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('reconcile with empty visible assets clears selection', () => {
|
||||
fc.assert(
|
||||
fc.property(arbAssets(1, 15), (initialAssets) => {
|
||||
setActivePinia(createPinia())
|
||||
const selection = useAssetSelection()
|
||||
const store = useAssetSelectionStore()
|
||||
|
||||
store.setSelection(initialAssets.map((a) => a.id))
|
||||
selection.reconcileSelection([])
|
||||
|
||||
expect(store.selectedAssetIds.size).toBe(0)
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectAll', () => {
|
||||
it('selectAll then getSelectedAssets returns all assets', () => {
|
||||
fc.assert(
|
||||
fc.property(arbAssets(0, 20), (assets) => {
|
||||
setActivePinia(createPinia())
|
||||
const selection = useAssetSelection()
|
||||
|
||||
selection.selectAll(assets)
|
||||
const selected = selection.getSelectedAssets(assets)
|
||||
|
||||
expect(selected.length).toBe(assets.length)
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getOutputCount / getTotalOutputCount', () => {
|
||||
it('getOutputCount always returns >= 1', () => {
|
||||
const arbAssetWithMeta: fc.Arbitrary<AssetItem> = fc.record({
|
||||
id: fc.uuid(),
|
||||
name: fc.string({ minLength: 1, maxLength: 10 }),
|
||||
tags: fc.constant([] as string[]),
|
||||
user_metadata: fc.option(
|
||||
fc.record({
|
||||
outputCount: fc.oneof(
|
||||
fc.integer(),
|
||||
fc.constant(undefined),
|
||||
fc.constant(null)
|
||||
)
|
||||
}),
|
||||
{ nil: undefined }
|
||||
)
|
||||
})
|
||||
|
||||
fc.assert(
|
||||
fc.property(arbAssetWithMeta, (asset) => {
|
||||
setActivePinia(createPinia())
|
||||
const selection = useAssetSelection()
|
||||
expect(selection.getOutputCount(asset)).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('getTotalOutputCount >= number of assets', () => {
|
||||
const arbAssetWithMeta: fc.Arbitrary<AssetItem> = fc.record({
|
||||
id: fc.uuid(),
|
||||
name: fc.string({ minLength: 1, maxLength: 10 }),
|
||||
tags: fc.constant([] as string[]),
|
||||
user_metadata: fc.option(
|
||||
fc.record({
|
||||
outputCount: fc.oneof(
|
||||
fc.integer({ min: 1, max: 100 }),
|
||||
fc.constant(undefined)
|
||||
)
|
||||
}),
|
||||
{ nil: undefined }
|
||||
)
|
||||
})
|
||||
|
||||
fc.assert(
|
||||
fc.property(fc.array(arbAssetWithMeta, { maxLength: 20 }), (assets) => {
|
||||
setActivePinia(createPinia())
|
||||
const selection = useAssetSelection()
|
||||
expect(selection.getTotalOutputCount(assets)).toBeGreaterThanOrEqual(
|
||||
assets.length
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
140
src/platform/assets/composables/useOutputStacks.property.test.ts
Normal file
140
src/platform/assets/composables/useOutputStacks.property.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import * as fc from 'fast-check'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
vi.mock('@/platform/assets/utils/outputAssetUtil', () => ({
|
||||
getOutputKey: () => null,
|
||||
resolveOutputAssetItems: () => Promise.resolve([])
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/schemas/assetMetadataSchema', () => ({
|
||||
getOutputAssetMetadata: (metadata: Record<string, unknown> | undefined) => {
|
||||
if (
|
||||
metadata &&
|
||||
typeof metadata.jobId === 'string' &&
|
||||
(typeof metadata.nodeId === 'string' ||
|
||||
typeof metadata.nodeId === 'number')
|
||||
) {
|
||||
return metadata
|
||||
}
|
||||
return null
|
||||
}
|
||||
}))
|
||||
|
||||
import { useOutputStacks } from './useOutputStacks'
|
||||
|
||||
const arbAssetId = fc.stringMatching(/^[a-z0-9]{4,12}$/)
|
||||
|
||||
function arbAssetList(
|
||||
minLength = 0,
|
||||
maxLength = 20
|
||||
): fc.Arbitrary<AssetItem[]> {
|
||||
return fc.uniqueArray(arbAssetId, { minLength, maxLength }).map((ids) =>
|
||||
ids.map(
|
||||
(id) =>
|
||||
({
|
||||
id,
|
||||
name: `${id}.png`,
|
||||
tags: ['output']
|
||||
}) satisfies AssetItem
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
describe('useOutputStacks properties', () => {
|
||||
it('collapsed stacks: item count equals input asset count', () => {
|
||||
fc.assert(
|
||||
fc.property(arbAssetList(0, 30), (assets) => {
|
||||
const assetsRef = ref(assets)
|
||||
const { assetItems } = useOutputStacks({ assets: assetsRef })
|
||||
|
||||
expect(assetItems.value.length).toBe(assets.length)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('every item in assetItems references an asset from the input', () => {
|
||||
fc.assert(
|
||||
fc.property(arbAssetList(0, 30), (assets) => {
|
||||
const assetsRef = ref(assets)
|
||||
const { assetItems } = useOutputStacks({ assets: assetsRef })
|
||||
|
||||
const inputIds = new Set(assets.map((a) => a.id))
|
||||
for (const item of assetItems.value) {
|
||||
expect(inputIds.has(item.asset.id)).toBe(true)
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('all items have unique keys', () => {
|
||||
fc.assert(
|
||||
fc.property(arbAssetList(0, 30), (assets) => {
|
||||
const assetsRef = ref(assets)
|
||||
const { assetItems } = useOutputStacks({ assets: assetsRef })
|
||||
|
||||
const keys = assetItems.value.map((item) => item.key)
|
||||
expect(new Set(keys).size).toBe(keys.length)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('selectableAssets length matches assetItems length', () => {
|
||||
fc.assert(
|
||||
fc.property(arbAssetList(0, 30), (assets) => {
|
||||
const assetsRef = ref(assets)
|
||||
const { assetItems, selectableAssets } = useOutputStacks({
|
||||
assets: assetsRef
|
||||
})
|
||||
|
||||
expect(selectableAssets.value.length).toBe(assetItems.value.length)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('no collapsed item is marked as a child', () => {
|
||||
fc.assert(
|
||||
fc.property(arbAssetList(0, 30), (assets) => {
|
||||
const assetsRef = ref(assets)
|
||||
const { assetItems } = useOutputStacks({ assets: assetsRef })
|
||||
|
||||
for (const item of assetItems.value) {
|
||||
expect(item.isChild).toBeUndefined()
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('isStackExpanded returns false for assets without job metadata', () => {
|
||||
fc.assert(
|
||||
fc.property(arbAssetList(1, 20), (assets) => {
|
||||
const assetsRef = ref(assets)
|
||||
const { isStackExpanded } = useOutputStacks({ assets: assetsRef })
|
||||
|
||||
for (const asset of assets) {
|
||||
expect(isStackExpanded(asset)).toBe(false)
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('reactively updates when assets ref changes', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
arbAssetList(1, 15),
|
||||
arbAssetList(1, 15),
|
||||
(assetsA, assetsB) => {
|
||||
const assetsRef = ref(assetsA)
|
||||
const { assetItems } = useOutputStacks({ assets: assetsRef })
|
||||
|
||||
expect(assetItems.value.length).toBe(assetsA.length)
|
||||
|
||||
assetsRef.value = assetsB
|
||||
expect(assetItems.value.length).toBe(assetsB.length)
|
||||
}
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
187
src/platform/assets/utils/assetFilterUtils.property.test.ts
Normal file
187
src/platform/assets/utils/assetFilterUtils.property.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import * as fc from 'fast-check'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { OwnershipOption } from '@/platform/assets/types/filterTypes'
|
||||
|
||||
import {
|
||||
filterByCategory,
|
||||
filterByFileFormats,
|
||||
filterByOwnership,
|
||||
filterItemByBaseModels,
|
||||
filterItemByOwnership
|
||||
} from './assetFilterUtils'
|
||||
|
||||
const arbAssetItem: fc.Arbitrary<AssetItem> = fc.record({
|
||||
id: fc.uuid(),
|
||||
name: fc.stringMatching(/^[a-z0-9_-]{1,12}\.[a-z]{2,6}$/),
|
||||
tags: fc.array(fc.stringMatching(/^[a-z0-9/]{1,15}$/), { maxLength: 5 }),
|
||||
is_immutable: fc.boolean(),
|
||||
metadata: fc.option(
|
||||
fc.record({
|
||||
base_model: fc.array(fc.stringMatching(/^[A-Z0-9.]{1,8}$/), {
|
||||
maxLength: 3
|
||||
})
|
||||
}),
|
||||
{ nil: undefined }
|
||||
),
|
||||
user_metadata: fc.option(
|
||||
fc.record({
|
||||
base_model: fc.array(fc.stringMatching(/^[A-Z0-9.]{1,8}$/), {
|
||||
maxLength: 3
|
||||
})
|
||||
}),
|
||||
{ nil: undefined }
|
||||
)
|
||||
})
|
||||
|
||||
const arbOwnership: fc.Arbitrary<OwnershipOption> = fc.constantFrom(
|
||||
'all',
|
||||
'my-models',
|
||||
'public-models'
|
||||
)
|
||||
|
||||
describe('assetFilterUtils properties', () => {
|
||||
it('filterByCategory("all") accepts every asset', () => {
|
||||
fc.assert(
|
||||
fc.property(arbAssetItem, (asset) => {
|
||||
expect(filterByCategory('all')(asset)).toBe(true)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('filtered result is always a subset of the input', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(arbAssetItem, { maxLength: 30 }),
|
||||
fc.stringMatching(/^[a-z]{1,8}$/),
|
||||
(assets, category) => {
|
||||
const filter = filterByCategory(category)
|
||||
const result = assets.filter(filter)
|
||||
expect(result.length).toBeLessThanOrEqual(assets.length)
|
||||
for (const item of result) {
|
||||
expect(assets).toContain(item)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('filterByFileFormats with empty formats accepts every asset', () => {
|
||||
fc.assert(
|
||||
fc.property(arbAssetItem, (asset) => {
|
||||
expect(filterByFileFormats([])(asset)).toBe(true)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('filterByFileFormats result is a subset of the input', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(arbAssetItem, { maxLength: 30 }),
|
||||
fc.array(fc.stringMatching(/^[a-z]{2,6}$/), { maxLength: 5 }),
|
||||
(assets, formats) => {
|
||||
const filter = filterByFileFormats(formats)
|
||||
const result = assets.filter(filter)
|
||||
expect(result.length).toBeLessThanOrEqual(assets.length)
|
||||
}
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('filterByOwnership("all") accepts every asset', () => {
|
||||
fc.assert(
|
||||
fc.property(arbAssetItem, (asset) => {
|
||||
expect(filterByOwnership('all')(asset)).toBe(true)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('filterByOwnership partitions assets: my-models + public-models = all', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.array(arbAssetItem, { maxLength: 30 }), (assets) => {
|
||||
const mine = assets.filter(filterByOwnership('my-models'))
|
||||
const pub = assets.filter(filterByOwnership('public-models'))
|
||||
const all = assets.filter(filterByOwnership('all'))
|
||||
|
||||
expect(mine.length + pub.length).toBe(all.length)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('filterItemByOwnership result is a subset of the input', () => {
|
||||
const arbItem = fc.record({
|
||||
id: fc.uuid(),
|
||||
is_immutable: fc.boolean()
|
||||
})
|
||||
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(arbItem, { maxLength: 30 }),
|
||||
arbOwnership,
|
||||
(items, ownership) => {
|
||||
const result = filterItemByOwnership(items, ownership)
|
||||
expect(result.length).toBeLessThanOrEqual(items.length)
|
||||
for (const item of result) {
|
||||
expect(items).toContain(item)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('filterItemByOwnership("all") returns all items', () => {
|
||||
const arbItem = fc.record({
|
||||
id: fc.uuid(),
|
||||
is_immutable: fc.boolean()
|
||||
})
|
||||
|
||||
fc.assert(
|
||||
fc.property(fc.array(arbItem, { maxLength: 30 }), (items) => {
|
||||
const result = filterItemByOwnership(items, 'all')
|
||||
expect(result).toEqual(items)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('filterItemByBaseModels with empty set returns all items', () => {
|
||||
const arbItem = fc.record({
|
||||
id: fc.uuid(),
|
||||
base_models: fc.option(
|
||||
fc.array(fc.stringMatching(/^[A-Z0-9.]{1,8}$/), { maxLength: 3 }),
|
||||
{ nil: undefined }
|
||||
)
|
||||
})
|
||||
|
||||
fc.assert(
|
||||
fc.property(fc.array(arbItem, { maxLength: 30 }), (items) => {
|
||||
const result = filterItemByBaseModels(items, new Set<string>())
|
||||
expect(result).toEqual(items)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('filterItemByBaseModels result is a subset of the input', () => {
|
||||
const arbItem = fc.record({
|
||||
id: fc.uuid(),
|
||||
base_models: fc.option(
|
||||
fc.array(fc.stringMatching(/^[A-Z0-9.]{1,8}$/), { maxLength: 3 }),
|
||||
{ nil: undefined }
|
||||
)
|
||||
})
|
||||
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(arbItem, { maxLength: 30 }),
|
||||
fc.array(fc.stringMatching(/^[A-Z0-9.]{1,8}$/), { maxLength: 5 }),
|
||||
(items, models) => {
|
||||
const result = filterItemByBaseModels(items, new Set(models))
|
||||
expect(result.length).toBeLessThanOrEqual(items.length)
|
||||
for (const item of result) {
|
||||
expect(items).toContain(item)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
152
src/platform/assets/utils/assetSortUtils.property.test.ts
Normal file
152
src/platform/assets/utils/assetSortUtils.property.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import * as fc from 'fast-check'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { AssetSortOption } from '../types/filterTypes'
|
||||
import type { SortableItem } from './assetSortUtils'
|
||||
import { sortAssets } from './assetSortUtils'
|
||||
|
||||
const arbISODate = fc
|
||||
.integer({ min: 1577836800000, max: 1893456000000 })
|
||||
.map((ms) => new Date(ms).toISOString())
|
||||
|
||||
const arbSortableItem: fc.Arbitrary<SortableItem> = fc.record({
|
||||
name: fc.stringMatching(/^[a-z0-9_]{1,12}\.[a-z]{2,5}$/),
|
||||
label: fc.option(fc.stringMatching(/^[A-Za-z0-9]{1,15}$/), {
|
||||
nil: undefined
|
||||
}),
|
||||
created_at: fc.option(arbISODate, { nil: undefined })
|
||||
})
|
||||
|
||||
const arbSortOption: fc.Arbitrary<AssetSortOption> = fc.constantFrom(
|
||||
'default',
|
||||
'recent',
|
||||
'name-asc',
|
||||
'name-desc'
|
||||
)
|
||||
|
||||
function getDisplayName(item: SortableItem): string {
|
||||
return item.label ?? item.name
|
||||
}
|
||||
|
||||
describe('assetSortUtils properties', () => {
|
||||
it('sort never mutates the input array', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(arbSortableItem, { maxLength: 30 }),
|
||||
arbSortOption,
|
||||
(items, sortBy) => {
|
||||
const original = [...items]
|
||||
sortAssets(items, sortBy)
|
||||
expect(items).toEqual(original)
|
||||
}
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('sorted output is always a permutation of the input', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(arbSortableItem, { maxLength: 30 }),
|
||||
arbSortOption,
|
||||
(items, sortBy) => {
|
||||
const result = sortAssets(items, sortBy)
|
||||
expect(result.length).toBe(items.length)
|
||||
|
||||
const inputNames = items.map((i) => i.name).sort()
|
||||
const resultNames = result.map((i) => i.name).sort()
|
||||
expect(resultNames).toEqual(inputNames)
|
||||
}
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('sorting is idempotent (sorting twice yields same result)', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(arbSortableItem, { maxLength: 30 }),
|
||||
arbSortOption,
|
||||
(items, sortBy) => {
|
||||
const once = sortAssets(items, sortBy)
|
||||
const twice = sortAssets(once, sortBy)
|
||||
expect(twice.map((i) => i.name)).toEqual(once.map((i) => i.name))
|
||||
}
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('name-asc: adjacent elements satisfy comparator(a, b) <= 0', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(arbSortableItem, { minLength: 2, maxLength: 30 }),
|
||||
(items) => {
|
||||
const sorted = sortAssets(items, 'name-asc')
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
const cmp = getDisplayName(sorted[i]).localeCompare(
|
||||
getDisplayName(sorted[i + 1]),
|
||||
undefined,
|
||||
{ numeric: true, sensitivity: 'base' }
|
||||
)
|
||||
expect(cmp).toBeLessThanOrEqual(0)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('name-desc: adjacent elements satisfy comparator(a, b) >= 0', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(arbSortableItem, { minLength: 2, maxLength: 30 }),
|
||||
(items) => {
|
||||
const sorted = sortAssets(items, 'name-desc')
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
const cmp = getDisplayName(sorted[i]).localeCompare(
|
||||
getDisplayName(sorted[i + 1]),
|
||||
undefined,
|
||||
{ numeric: true, sensitivity: 'base' }
|
||||
)
|
||||
expect(cmp).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('recent: adjacent elements satisfy a.created_at >= b.created_at', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(arbSortableItem, { minLength: 2, maxLength: 30 }),
|
||||
(items) => {
|
||||
const sorted = sortAssets(items, 'recent')
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
const a = new Date(sorted[i].created_at ?? 0).getTime()
|
||||
const b = new Date(sorted[i + 1].created_at ?? 0).getTime()
|
||||
expect(a).toBeGreaterThanOrEqual(b)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('default preserves original order', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.array(arbSortableItem, { maxLength: 30 }), (items) => {
|
||||
const result = sortAssets(items, 'default')
|
||||
expect(result.map((i) => i.name)).toEqual(items.map((i) => i.name))
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('sort output length equals input length for all sort options', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(arbSortableItem, { maxLength: 30 }),
|
||||
arbSortOption,
|
||||
(items, sortBy) => {
|
||||
const result = sortAssets(items, sortBy)
|
||||
expect(result.length).toBe(items.length)
|
||||
}
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user