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:
Dante
2026-03-28 10:26:55 +09:00
committed by GitHub
parent c4d0b3c97a
commit caa6f89436
4 changed files with 674 additions and 0 deletions

View File

@@ -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
)
})
)
})
})
})

View 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)
}
)
)
})
})

View 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)
}
}
)
)
})
})

View 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)
}
)
)
})
})