Compare commits

...

1 Commits

Author SHA1 Message Date
Benjamin Lu
20bc16d0cb fix: simplify and deduplicate output selection counting 2026-02-07 15:41:14 -08:00
3 changed files with 517 additions and 1 deletions

View File

@@ -0,0 +1,314 @@
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
const mocks = vi.hoisted(() => ({
shiftKey: { value: true },
ctrlKey: { value: false },
metaKey: { value: false },
outputMedia: [] as AssetItem[]
}))
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as object),
useDebounceFn: <T extends (...args: never[]) => unknown>(fn: T) => fn,
useElementHover: () => ref(false),
useResizeObserver: () => undefined,
useStorage: <T>(_key: string, initialValue: T) => ref(initialValue),
useKeyModifier: (key: string) => {
if (key === 'Shift') return mocks.shiftKey
if (key === 'Control') return mocks.ctrlKey
if (key === 'Meta') return mocks.metaKey
return ref(false)
}
}
})
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) =>
params ? `${key}:${JSON.stringify(params)}` : key,
n: (value: number) => String(value)
}),
createI18n: () => ({
global: {
t: (key: string, params?: Record<string, unknown>) =>
params ? `${key}:${JSON.stringify(params)}` : key
}
})
}))
vi.mock('primevue/usetoast', () => ({
useToast: () => ({
add: vi.fn()
})
}))
vi.mock('@/platform/assets/composables/media/useMediaAssets', () => ({
useMediaAssets: (type: 'input' | 'output') => ({
loading: ref(false),
error: ref(null),
media: ref(type === 'output' ? mocks.outputMedia : []),
fetchMediaList: vi.fn(async () => {}),
hasMore: ref(false),
isLoadingMore: ref(false),
loadMore: vi.fn(async () => {})
})
}))
vi.mock('@/platform/assets/composables/useMediaAssetFiltering', () => ({
useMediaAssetFiltering: (baseAssets: { value: AssetItem[] }) => ({
searchQuery: ref(''),
sortBy: ref('newest'),
mediaTypeFilters: ref([]),
filteredAssets: computed(() => baseAssets.value)
})
}))
vi.mock('@/platform/assets/composables/useOutputStacks', () => ({
useOutputStacks: ({ assets }: { assets: { value: AssetItem[] } }) => ({
assetItems: computed(() =>
assets.value.map((asset) => ({ key: asset.id, asset }))
),
selectableAssets: computed(() => assets.value),
isStackExpanded: () => false,
toggleStack: vi.fn(async () => {})
})
}))
vi.mock('@/platform/assets/composables/useMediaAssetActions', () => ({
useMediaAssetActions: () => ({
downloadMultipleAssets: vi.fn(),
deleteAssets: vi.fn(async () => true),
addMultipleToWorkflow: vi.fn(async () => {}),
openMultipleWorkflows: vi.fn(async () => {}),
exportMultipleWorkflows: vi.fn(async () => {})
})
}))
vi.mock('@/platform/assets/composables/media/assetMappers', () => ({
getAssetType: (tags: unknown) =>
Array.isArray(tags) && tags.includes('output') ? 'output' : 'input'
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: () => false
})
}))
vi.mock('@/stores/queueStore', () => {
class ResultItemImpl {
filename: string
subfolder: string
type: string
nodeId: string
mediaType: string
constructor({
filename,
subfolder,
type,
nodeId,
mediaType
}: {
filename: string
subfolder: string
type: string
nodeId: string
mediaType: string
}) {
this.filename = filename
this.subfolder = subfolder
this.type = type
this.nodeId = nodeId
this.mediaType = mediaType
}
}
return {
useQueueStore: () => ({
activeJobsCount: ref(0),
pendingTasks: []
}),
ResultItemImpl
}
})
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: vi.fn(async () => {})
})
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
showDialog: vi.fn()
})
}))
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => ({
clearInitializationByPromptIds: vi.fn()
})
}))
vi.mock('@/utils/formatUtil', () => ({
formatDuration: (duration: number) => `${duration}ms`,
getMediaTypeFromFilename: () => 'image'
}))
vi.mock('@/utils/tailwindUtil', () => ({
cn: (...classes: Array<string | false | null | undefined>) =>
classes.filter(Boolean).join(' ')
}))
vi.mock('@/platform/assets/utils/outputAssetUtil', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as object),
resolveOutputAssetItems: vi.fn(async () => [])
}
})
import AssetsSidebarTab from '@/components/sidebar/tabs/AssetsSidebarTab.vue'
const sidebarTabTemplateStub = {
template: `
<div>
<slot name="alt-title" />
<slot name="tool-buttons" />
<slot name="header" />
<slot name="body" />
<slot name="footer" />
</div>
`
}
const buttonStub = {
template: '<button @click="$emit(\'click\', $event)"><slot /></button>'
}
const assetsSidebarGridViewStub = {
props: {
assets: {
type: Array,
required: true
}
},
template: `
<div>
<button
v-for="asset in assets"
:key="asset.id"
:data-testid="'asset-' + asset.id"
@click.stop="$emit('select-asset', asset, assets)"
>
{{ asset.name }}
</button>
</div>
`
}
function createAsset(
id: string,
name: string,
userMetadata?: Record<string, unknown>
): AssetItem {
return {
id,
name,
tags: ['output'],
user_metadata: userMetadata
}
}
describe('AssetsSidebarTab', () => {
beforeEach(() => {
setActivePinia(createPinia())
mocks.shiftKey.value = true
mocks.ctrlKey.value = false
mocks.metaKey.value = false
mocks.outputMedia = []
})
it('shows deduplicated selected count for parent stack and selected children', async () => {
const outputs = [
{
filename: 'parent.png',
nodeId: '1',
subfolder: 'outputs',
url: 'https://example.com/parent.png'
}
]
const parent = createAsset('parent', 'parent.png', {
promptId: 'prompt-1',
nodeId: '1',
subfolder: 'outputs',
outputCount: 4,
allOutputs: outputs
})
const child1 = createAsset('child-1', 'child-1.png', {
promptId: 'prompt-1',
nodeId: '2',
subfolder: 'outputs'
})
const child2 = createAsset('child-2', 'child-2.png', {
promptId: 'prompt-1',
nodeId: '3',
subfolder: 'outputs'
})
const child3 = createAsset('child-3', 'child-3.png', {
promptId: 'prompt-1',
nodeId: '4',
subfolder: 'outputs'
})
mocks.outputMedia = [parent, child1, child2, child3]
const wrapper = mount(AssetsSidebarTab, {
global: {
stubs: {
SidebarTabTemplate: sidebarTabTemplateStub,
Button: buttonStub,
TabList: true,
Tab: true,
Divider: true,
ProgressSpinner: true,
NoResultsPlaceholder: true,
MediaAssetFilterBar: true,
AssetsSidebarListView: true,
AssetsSidebarGridView: assetsSidebarGridViewStub,
ResultGallery: true,
MediaAssetContextMenu: true
},
mocks: {
$t: (key: string, params?: Record<string, unknown>) =>
params ? `${key}:${JSON.stringify(params)}` : key
}
}
})
await wrapper.find('[data-testid="asset-parent"]').trigger('click')
await wrapper.find('[data-testid="asset-child-3"]').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain(
'mediaAsset.selection.selectedCount:{"count":4}'
)
expect(wrapper.text()).not.toContain(
'mediaAsset.selection.selectedCount:{"count":7}'
)
})
})

View File

@@ -36,6 +36,39 @@ function createMockAssets(count: number): AssetItem[] {
}))
}
type OutputStub = {
filename: string
nodeId: string
subfolder: string
url: string
}
function createOutputAsset(
id: string,
name: string,
metadata: Record<string, unknown>
): AssetItem {
return {
id,
name,
tags: ['output'],
user_metadata: metadata
}
}
function createOutput(
filename: string,
nodeId: string,
subfolder: string
): OutputStub {
return {
filename,
nodeId,
subfolder,
url: `https://example.com/${filename}`
}
}
describe('useAssetSelection', () => {
beforeEach(() => {
setActivePinia(createPinia())
@@ -272,4 +305,80 @@ describe('useAssetSelection', () => {
expect(selected[0].id).toBe('asset-1')
})
})
describe('getTotalOutputCount', () => {
it('deduplicates overlapping parent stack and selected children with partial parent outputs', () => {
const { getTotalOutputCount } = useAssetSelection()
const outputs = [createOutput('parent.png', '1', 'outputs')]
const parent = createOutputAsset('parent', 'parent.png', {
promptId: 'prompt-1',
nodeId: '1',
subfolder: 'outputs',
outputCount: 4,
allOutputs: outputs
})
const child1 = createOutputAsset('child-1', 'child-1.png', {
promptId: 'prompt-1',
nodeId: '2',
subfolder: 'outputs'
})
const child2 = createOutputAsset('child-2', 'child-2.png', {
promptId: 'prompt-1',
nodeId: '3',
subfolder: 'outputs'
})
const child3 = createOutputAsset('child-3', 'child-3.png', {
promptId: 'prompt-1',
nodeId: '4',
subfolder: 'outputs'
})
expect(getTotalOutputCount([parent, child1, child2, child3])).toBe(4)
})
it('deduplicates when parent includes full output list', () => {
const { getTotalOutputCount } = useAssetSelection()
const parent = createOutputAsset('parent', 'parent.png', {
promptId: 'prompt-1',
nodeId: '1',
subfolder: 'outputs',
outputCount: 4,
allOutputs: [
createOutput('parent.png', '1', 'outputs'),
createOutput('child-1.png', '2', 'outputs'),
createOutput('child-2.png', '3', 'outputs'),
createOutput('child-3.png', '4', 'outputs')
]
})
const child = createOutputAsset('child-1', 'child-1.png', {
promptId: 'prompt-1',
nodeId: '2',
subfolder: 'outputs'
})
expect(getTotalOutputCount([parent, child])).toBe(4)
})
it('falls back to outputCount when only unresolved stack count exists', () => {
const { getTotalOutputCount } = useAssetSelection()
const parent = createOutputAsset('parent', 'parent.png', {
promptId: 'prompt-1',
nodeId: '1',
subfolder: 'outputs',
outputCount: 4
})
expect(getTotalOutputCount([parent])).toBe(4)
})
it('counts non-output metadata assets as one each', () => {
const { getTotalOutputCount } = useAssetSelection()
const assetA: AssetItem = { id: 'a', name: 'a.png', tags: ['input'] }
const assetB: AssetItem = { id: 'b', name: 'b.png', tags: ['input'] }
expect(getTotalOutputCount([assetA, assetB])).toBe(2)
})
})
})

View File

@@ -1,9 +1,71 @@
import { useKeyModifier } from '@vueuse/core'
import { computed, ref } from 'vue'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getOutputKey } from '@/platform/assets/utils/outputAssetUtil'
import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore'
type PromptSelection = {
expectedOutputCount: number
outputKeys: Set<string>
}
function getPromptSelection(
selectionsByPromptId: Map<string, PromptSelection>,
promptId: string
): PromptSelection {
const existingSelection = selectionsByPromptId.get(promptId)
if (existingSelection) {
return existingSelection
}
const selection: PromptSelection = {
expectedOutputCount: 0,
outputKeys: new Set<string>()
}
selectionsByPromptId.set(promptId, selection)
return selection
}
function updateExpectedOutputCount(
selection: PromptSelection,
outputCount: OutputAssetMetadata['outputCount']
) {
if (
typeof outputCount === 'number' &&
outputCount > selection.expectedOutputCount
) {
selection.expectedOutputCount = outputCount
}
}
function resolveOutputKeysForSelection(
asset: AssetItem,
metadata: OutputAssetMetadata
): string[] {
const allOutputKeys = (metadata.allOutputs ?? [])
.map((output) => getOutputKey(output))
.filter((key): key is string => key !== null)
if (allOutputKeys.length > 0) {
return allOutputKeys
}
const assetOutputKey = getOutputKey({
nodeId: metadata.nodeId,
subfolder: metadata.subfolder,
filename: asset.name
})
return [assetOutputKey ?? `asset:${asset.id}`]
}
function getPromptSelectionCount(selection: PromptSelection): number {
return Math.max(selection.outputKeys.size, selection.expectedOutputCount)
}
export function useAssetSelection() {
const selectionStore = useAssetSelectionStore()
@@ -150,7 +212,38 @@ export function useAssetSelection() {
* Get the total output count for given assets
*/
function getTotalOutputCount(assets: AssetItem[]): number {
return assets.reduce((sum, asset) => sum + getOutputCount(asset), 0)
const nonOutputAssetIds = new Set<string>()
const promptSelectionsByPromptId = new Map<string, PromptSelection>()
for (const asset of assets) {
const outputMetadata = getOutputAssetMetadata(asset.user_metadata)
const promptId = outputMetadata?.promptId
if (!promptId) {
nonOutputAssetIds.add(asset.id)
continue
}
const promptSelection = getPromptSelection(
promptSelectionsByPromptId,
promptId
)
updateExpectedOutputCount(promptSelection, outputMetadata.outputCount)
for (const outputKey of resolveOutputKeysForSelection(
asset,
outputMetadata
)) {
promptSelection.outputKeys.add(outputKey)
}
}
let totalOutputCount = nonOutputAssetIds.size
for (const selection of promptSelectionsByPromptId.values()) {
totalOutputCount += getPromptSelectionCount(selection)
}
return totalOutputCount
}
/**