Compare commits

...

3 Commits

Author SHA1 Message Date
Simon Pinfold
ac6a849669 fix: make export toast count order-independent
Amp-Thread-ID: https://ampcode.com/threads/T-019d6a6f-e97a-7659-b41f-41763db3920a
Co-authored-by: Amp <amp@ampcode.com>
2026-04-08 18:00:45 +12:00
Simon Pinfold
2ca98c8ae4 fix: prefer job-level exports over asset filters
Amp-Thread-ID: https://ampcode.com/threads/T-019d6a6f-e97a-7659-b41f-41763db3920a
Co-authored-by: Amp <amp@ampcode.com>
2026-04-08 17:37:49 +12:00
Simon Pinfold
16eacfe87c fix: count files (not jobs) in export ZIP toast
The export started toast was passing assets.length to the count
parameter, but for job-level selections each asset represents an
entire job that may produce multiple files. Sum outputCount per
job (defaulting to 1 when unknown) so the user sees the actual
number of files being exported.
2026-04-08 17:27:58 +12:00
2 changed files with 178 additions and 5 deletions

View File

@@ -19,19 +19,25 @@ vi.mock('@/platform/distribution/types', () => ({
}
}))
const mockToastAdd = vi.hoisted(() => vi.fn())
vi.mock('primevue/usetoast', () => ({
useToast: () => ({
add: vi.fn()
add: mockToastAdd
})
}))
const mockI18nT = vi.hoisted(() =>
vi.fn((key: string, count?: number) =>
typeof count === 'number' ? `${key}:${count}` : key
)
)
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
t: mockI18nT
}),
createI18n: () => ({
global: {
t: (key: string) => key
t: mockI18nT
}
})
}))
@@ -373,6 +379,142 @@ describe('useMediaAssetActions', () => {
job2: ['img2.png']
})
})
it('should omit name filters when job-level and asset-level selections share a jobId', async () => {
const jobLevelAsset = createOutputAsset('a1', 'img1.png', 'job1', 3)
const assetLevelSelection = createOutputAsset('a2', 'img2.png', 'job1')
const actions = useMediaAssetActions()
actions.downloadMultipleAssets([jobLevelAsset, assetLevelSelection])
await vi.waitFor(() => {
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
})
const payload = mockCreateAssetExport.mock.calls[0][0]
expect(payload.job_ids).toEqual(['job1'])
expect(payload.job_asset_name_filters).toBeUndefined()
})
})
describe('downloadMultipleAssets - export toast file count', () => {
beforeEach(() => {
mockIsCloud.value = true
mockCreateAssetExport.mockClear()
mockTrackExport.mockClear()
mockToastAdd.mockClear()
mockGetAssetType.mockReturnValue('output')
mockGetOutputAssetMetadata.mockImplementation(
(meta: Record<string, unknown> | undefined) =>
meta && 'jobId' in meta ? meta : null
)
})
function createOutputAsset(
id: string,
name: string,
jobId: string,
outputCount?: number
): AssetItem {
return createMockAsset({
id,
name,
tags: ['output'],
user_metadata: { jobId, nodeId: '1', subfolder: '', outputCount }
})
}
type ToastArg = { severity?: string; detail?: unknown }
async function getExportToastDetail(): Promise<unknown> {
await vi.waitFor(() => {
const hasInfoToast = mockToastAdd.mock.calls.some(
([arg]) => (arg as ToastArg).severity === 'info'
)
expect(hasInfoToast).toBe(true)
})
const exportToastCall = mockToastAdd.mock.calls.find(
([arg]) => (arg as ToastArg).severity === 'info'
)
if (!exportToastCall) throw new Error('export info toast not found')
return (exportToastCall[0] as ToastArg).detail
}
it('sums outputCount across job-level selections', async () => {
// 3 jobs each with 2 outputs => 6 files total
const j1 = createOutputAsset('a1', 'img1.png', 'job1', 2)
const j2 = createOutputAsset('a2', 'img2.png', 'job2', 2)
const j3 = createOutputAsset('a3', 'img3.png', 'job3', 2)
const actions = useMediaAssetActions()
actions.downloadMultipleAssets([j1, j2, j3])
const detail = await getExportToastDetail()
expect(detail).toBe('mediaAsset.selection.exportStarted:6')
})
it('counts assets without outputCount as a single file each', async () => {
const a1 = createOutputAsset('a1', 'img1.png', 'job1')
const a2 = createOutputAsset('a2', 'img2.png', 'job2')
const actions = useMediaAssetActions()
actions.downloadMultipleAssets([a1, a2])
const detail = await getExportToastDetail()
expect(detail).toBe('mediaAsset.selection.exportStarted:2')
})
it('counts outputCount once per job when multiple job-level assets share a jobId', async () => {
// User selects 2 cards from the same job-level stack (outputCount=3
// on each). The export is still one job-wide export of 3 files.
const j1a = createOutputAsset('a1', 'img1.png', 'job1', 3)
const j1b = createOutputAsset('a2', 'img2.png', 'job1', 3)
const actions = useMediaAssetActions()
actions.downloadMultipleAssets([j1a, j1b])
const detail = await getExportToastDetail()
expect(detail).toBe('mediaAsset.selection.exportStarted:3')
})
it('mixes job-level and asset-level selections correctly', async () => {
// job1 = job-level selection with 3 outputs
// job2 = 2 individually selected outputs (no outputCount)
const j1 = createOutputAsset('a1', 'img1.png', 'job1', 3)
const j2a = createOutputAsset('a2', 'out2a.png', 'job2')
const j2b = createOutputAsset('a3', 'out2b.png', 'job2')
const actions = useMediaAssetActions()
actions.downloadMultipleAssets([j1, j2a, j2b])
const detail = await getExportToastDetail()
expect(detail).toBe('mediaAsset.selection.exportStarted:5')
})
it('does not double-count when a job-level and asset-level selection share a jobId', async () => {
// job1 is selected at the job level (outputCount=3) and the user also
// selects one individual output from the same job. The export is still
// one job-wide export of 3 files, not 4.
const j1Job = createOutputAsset('a1', 'img1.png', 'job1', 3)
const j1Asset = createOutputAsset('a2', 'img2.png', 'job1')
const actions = useMediaAssetActions()
actions.downloadMultipleAssets([j1Job, j1Asset])
const detail = await getExportToastDetail()
expect(detail).toBe('mediaAsset.selection.exportStarted:3')
})
it('does not overcount when an asset-level selection comes before a job-level selection for the same jobId', async () => {
const j1Asset = createOutputAsset('a1', 'img1.png', 'job1')
const j1Job = createOutputAsset('a2', 'img2.png', 'job1', 3)
const actions = useMediaAssetActions()
actions.downloadMultipleAssets([j1Asset, j1Job])
const detail = await getExportToastDetail()
expect(detail).toBe('mediaAsset.selection.exportStarted:3')
})
})
describe('deleteAssets - model cache invalidation', () => {

View File

@@ -131,6 +131,15 @@ export function useMediaAssetActions() {
}
}
/**
* Build and dispatch a ZIP export for the given assets.
*
* Assets split into job-level selections (gallery cards representing a whole
* job, identified by `outputCount` being set in metadata) and individual
* asset selections. Job-level selections count as `outputCount` files in the
* resulting toast and are deduplicated by `jobId` so that mixed selections
* (the same job selected both as a whole and per-asset) don't double-count.
*/
async function downloadMultipleAssetsAsZip(assets: AssetItem[]) {
const assetExportStore = useAssetExportStore()
@@ -138,18 +147,29 @@ export function useMediaAssetActions() {
const jobIds: string[] = []
const assetIds: string[] = []
const jobAssetNameFilters: Record<string, string[]> = {}
const jobLevelCounts = new Map<string, number>()
for (const asset of assets) {
if (getAssetType(asset) === 'output') {
const metadata = getOutputAssetMetadata(asset.user_metadata)
const jobId = metadata?.jobId || asset.id
const outputCount = metadata?.outputCount
if (!jobIds.includes(jobId)) {
jobIds.push(jobId)
}
// Only add name filters when outputCount is unknown.
// When outputCount is set, the asset is a job-level selection
// from the gallery and the user wants all outputs for that job.
if (metadata?.jobId && asset.name && metadata.outputCount == null) {
if (typeof outputCount === 'number') {
delete jobAssetNameFilters[jobId]
jobLevelCounts.set(jobId, outputCount)
} else if (
metadata?.jobId &&
asset.name &&
!jobLevelCounts.has(jobId)
) {
if (!jobAssetNameFilters[metadata.jobId]) {
jobAssetNameFilters[metadata.jobId] = []
}
@@ -162,6 +182,17 @@ export function useMediaAssetActions() {
}
}
const fileCount =
assetIds.length +
jobIds.reduce((total, jobId) => {
const jobLevelCount = jobLevelCounts.get(jobId)
if (typeof jobLevelCount === 'number') {
return total + jobLevelCount
}
return total + (jobAssetNameFilters[jobId]?.length ?? 1)
}, 0)
const result = await assetService.createAssetExport({
...(jobIds.length > 0 ? { job_ids: jobIds } : {}),
...(assetIds.length > 0 ? { asset_ids: assetIds } : {}),
@@ -176,7 +207,7 @@ export function useMediaAssetActions() {
toast.add({
severity: 'info',
summary: t('exportToast.exportStarted'),
detail: t('mediaAsset.selection.exportStarted', assets.length),
detail: t('mediaAsset.selection.exportStarted', fileCount),
life: 3000
})
} catch (error) {