Merge remote-tracking branch 'origin/main' into fix/252-no-sampler-scan
This commit is contained in:
@@ -95,8 +95,14 @@ async function watchProgress(page) {
|
||||
await page.evaluate(() => {
|
||||
window.progressSeen = [];
|
||||
const node = document.getElementById('autoTagProgressPercent');
|
||||
new MutationObserver(() => window.progressSeen.push(node.textContent))
|
||||
.observe(node, { childList: true, characterData: true, subtree: true });
|
||||
// Each change from its own record: frames that land in one task share one
|
||||
// callback, and the node's text by then is only the last of them.
|
||||
new MutationObserver(records => {
|
||||
for (const record of records) {
|
||||
if (record.type === 'characterData') window.progressSeen.push(record.target.data);
|
||||
for (const added of record.addedNodes) window.progressSeen.push(added.textContent);
|
||||
}
|
||||
}).observe(node, { childList: true, characterData: true, subtree: true });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -108,25 +114,58 @@ async function runGalleryBatch(page) {
|
||||
await page.locator('#startAutoTagBtn').click();
|
||||
await done;
|
||||
await expect(page.locator('#autoTagProgressModal')).toBeHidden();
|
||||
await expect(page.getByText(/^Auto tagging complete! Applied tags to \d+ prompts\.$/)).toBeVisible();
|
||||
// #379: the batch counts images, never prompts.
|
||||
await expect(page.getByText(/^Auto tagging complete! Tagged \d+ images?\.$/)).toBeVisible();
|
||||
}
|
||||
|
||||
const CLOSE_NOTE = 'Closing this window stops tagging. Tags already added are kept. Prompt Manager\'s Activity page shows the '
|
||||
+ 'time left.';
|
||||
|
||||
async function serverJobs(request) {
|
||||
const response = await request.get('/prompt_manager/jobs');
|
||||
await expect(response).toBeOK();
|
||||
return (await response.json()).jobs;
|
||||
}
|
||||
|
||||
async function runNativeBatch(request) {
|
||||
const started = await request.post('/prompt_manager/jobs/autotag', { data: { model_type: 'wd14-swinv2', existing: 'skip',
|
||||
options: { general_threshold: 0.35, character_threshold: 0.85 } } });
|
||||
expect(started.status()).toBe(202);
|
||||
const { job } = await started.json();
|
||||
await expect.poll(async () => (await serverJobs(request)).find(item => item.id === job.id)?.state).toBe('finished');
|
||||
}
|
||||
|
||||
test('gallery batch modal counts progress and closes on complete', async ({ page, request }) => {
|
||||
await page.goto('/prompt_manager/gallery');
|
||||
await runGalleryBatch(page);
|
||||
const seen = await page.evaluate(() => window.progressSeen);
|
||||
// The fixture's prompts are already tagged, so the batch skips them; its progress frames still move the bar.
|
||||
expect(seen).toContain('95%');
|
||||
// The phase frames (model 5%, listing 8%) then the per-image frames (from 10%) move the bar.
|
||||
expect(seen).toContain('8%');
|
||||
expect(seen.some(value => parseInt(value, 10) >= 10)).toBe(true);
|
||||
await expect(page.locator('#autoTagCloseNote')).toHaveText(CLOSE_NOTE);
|
||||
// The batch ran as the autotag job of the legacy page.
|
||||
expect((await serverJobs(request)).filter(job => job.kind === 'autotag').map(job => [job.title, job.state]))
|
||||
.toEqual([['Tag output images (legacy page)', 'finished']]);
|
||||
expect((await downloads(request)).length).toBe(0);
|
||||
});
|
||||
|
||||
test('admin batch copy names images, not prompts (#379)', async ({ page }) => {
|
||||
await page.goto('/prompt_manager/admin');
|
||||
await page.locator('#autoTagBtn').click();
|
||||
await expect(page.locator('input[name="autoTagMode"][value="skip"] + span')).toHaveText('Skip images already auto-tagged');
|
||||
await expect(page.getByText('"auto-scanned" is not counted as a real tag')).toHaveCount(0);
|
||||
await expect(page.locator('#autoTagCloseNote')).toHaveText(CLOSE_NOTE);
|
||||
await expect(page.locator('#retagConfirmModal h3')).toHaveText('Image Already Auto-tagged');
|
||||
await expect(page.locator('#retagConfirmModal')).toContainText('A tagging model already tagged this image. Generate new tags?');
|
||||
});
|
||||
|
||||
test('admin review opens the first image', async ({ page }) => {
|
||||
await page.goto('/prompt_manager/admin');
|
||||
await page.locator('#autoTagBtn').click();
|
||||
await page.locator('input[name="autoTagModel"][value="wd14-swinv2"]').check();
|
||||
await page.locator('#startReviewBtn').click();
|
||||
// The first image's prompt is already tagged: confirm a new pass, then the review opens on that image.
|
||||
await page.locator('#retagConfirmBtn').click();
|
||||
// #379: no image is auto-tagged yet (prompt tags no longer count), so the review opens on the first image.
|
||||
await expect(page.locator('#retagConfirmModal')).toBeHidden();
|
||||
await expect(page.locator('#autoTagReviewModal')).toBeVisible();
|
||||
await expect(page.locator('#reviewImageName')).not.toHaveText(/^(image_name\.png|Unknown|)$/);
|
||||
await expect(page.getByText('Failed to start review mode')).toHaveCount(0);
|
||||
@@ -150,3 +189,70 @@ test('gallery unload works and is refused while the gate is held', async ({ page
|
||||
await unload.click();
|
||||
await expect(page.getByText('WD14-SWINV2 model unloaded successfully')).toBeVisible();
|
||||
});
|
||||
|
||||
/** The admin review lists every row, videos too: Skip any image that is not one until the confirmation shows. */
|
||||
async function untilAutoTagged(page, confirm) {
|
||||
// Each image's skip check answers before its confirmation or its error shows.
|
||||
const failed = page.locator('#reviewTagsVisible .text-pm-error');
|
||||
for (let tries = 0; tries < 5; tries++) {
|
||||
await expect.poll(async () => (await confirm.isVisible()) || (await failed.count()) > 0).toBe(true);
|
||||
if (await confirm.isVisible()) return;
|
||||
const answered = page.waitForResponse(item => item.url().endsWith('/prompt_manager/autotag/single'));
|
||||
await page.locator('#skipReviewBtn').click();
|
||||
await answered;
|
||||
}
|
||||
await expect(confirm).toBeVisible();
|
||||
}
|
||||
|
||||
// #379: the admin review asks before re-tagging an image a model already tagged, and Apply & Next writes that image's
|
||||
// tags (its bytes, never its prompt).
|
||||
test('admin review: an auto-tagged image asks first; Re-tag then Apply & Next tags the image', async ({ page, request }) => {
|
||||
await runNativeBatch(request);
|
||||
await page.goto('/prompt_manager/admin');
|
||||
await page.locator('#autoTagBtn').click();
|
||||
await page.locator('input[name="autoTagModel"][value="wd14-swinv2"]').check();
|
||||
await page.locator('#startReviewBtn').click();
|
||||
const confirm = page.locator('#retagConfirmModal');
|
||||
await untilAutoTagged(page, confirm);
|
||||
await expect(confirm.locator('#retagExistingTags span').first()).toHaveText('sandstone');
|
||||
await page.locator('#retagConfirmBtn').click();
|
||||
await expect(page.locator('#autoTagReviewModal')).toBeVisible();
|
||||
await expect(page.locator('#reviewTagsVisible .tag-chip').first()).toContainText('sandstone');
|
||||
const applied = page.waitForRequest(item => item.url().endsWith('/prompt_manager/autotag/apply'));
|
||||
const answer = page.waitForResponse(item => item.url().endsWith('/prompt_manager/autotag/apply'));
|
||||
await page.locator('#applyReviewBtn').click();
|
||||
const body = (await applied).postDataJSON();
|
||||
expect(body.prompt_id).toBeUndefined();
|
||||
expect(body.sha256).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect((await answer).status()).toBe(200);
|
||||
});
|
||||
|
||||
test('admin review: Skip All Tagged passes every auto-tagged image', async ({ page, request }) => {
|
||||
await runNativeBatch(request);
|
||||
await page.goto('/prompt_manager/admin');
|
||||
await page.locator('#autoTagBtn').click();
|
||||
await page.locator('input[name="autoTagModel"][value="wd14-swinv2"]').check();
|
||||
await page.locator('#startReviewBtn').click();
|
||||
const confirm = page.locator('#retagConfirmModal');
|
||||
await untilAutoTagged(page, confirm);
|
||||
await page.locator('#retagSkipAllBtn').click();
|
||||
await expect(page.locator('#retagConfirmModal')).toBeHidden();
|
||||
await expect(page.getByText('Review complete!')).toBeVisible();
|
||||
});
|
||||
|
||||
test('gallery review: Apply & Next tags the image by its path and bytes', async ({ page }) => {
|
||||
await page.goto('/prompt_manager/gallery');
|
||||
await page.locator('#autoTagBtn').click();
|
||||
await page.locator('input[name="autoTagModel"][value="wd14-swinv2"]').check();
|
||||
const suggested = page.waitForRequest(item => item.url().endsWith('/prompt_manager/autotag/single'));
|
||||
await page.locator('#startReviewBtn').click();
|
||||
expect((await suggested).postDataJSON().existing).toBe('merge');
|
||||
await expect(page.locator('#reviewTagsContainer .tag-chip').first()).toContainText('sandstone');
|
||||
const applied = page.waitForRequest(item => item.url().endsWith('/prompt_manager/autotag/apply'));
|
||||
const answer = page.waitForResponse(item => item.url().endsWith('/prompt_manager/autotag/apply'));
|
||||
await page.locator('#applyReviewBtn').click();
|
||||
const body = (await applied).postDataJSON();
|
||||
expect(Object.keys(body).sort()).toEqual(['path', 'root_index', 'sha256', 'tags'].filter(key => key in body).sort());
|
||||
expect(body.prompt_id).toBeUndefined();
|
||||
expect((await answer).status()).toBe(200);
|
||||
});
|
||||
|
||||
@@ -132,7 +132,8 @@ test('the gallery cache clear keeps preferences and other tools caches', async (
|
||||
}))).toEqual({ theme: 'light', other: 'keep', prefixed: 'keep', notice: '3.2.0', caches: ['other-tool-gallery-thumbnails'] });
|
||||
});
|
||||
|
||||
test('actual gallery review generates and persists tags for the selected root', async ({ page, request }) => {
|
||||
// #379: the reviewed tags are that image's own tags; no prompt changes.
|
||||
test('actual gallery review generates and persists tags for the selected root\'s image', async ({ page, request }) => {
|
||||
await page.goto('/prompt_manager/gallery');
|
||||
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(2);
|
||||
await page.locator('#autoTagBtn').click();
|
||||
@@ -140,13 +141,15 @@ test('actual gallery review generates and persists tags for the selected root',
|
||||
await page.locator('#startReviewBtn').click();
|
||||
const generation = await generated;
|
||||
expect(generation.request().postDataJSON().root_index).toBe(0);
|
||||
await expect(page.locator('#reviewTagsContainer')).toContainText('generated');
|
||||
await expect(page.locator('#reviewTagsContainer')).toContainText('sandstone');
|
||||
const result = page.waitForResponse(response => response.url().endsWith('/prompt_manager/autotag/apply'));
|
||||
await page.locator('#applyReviewBtn').click();
|
||||
expect((await (await result).json()).success).toBe(true);
|
||||
const data = await (await request.get('/prompt_manager/recent?limit=20')).json();
|
||||
expect(data.results.find(prompt => prompt.text === 'Cat portrait').tags).toContain('generated');
|
||||
expect(data.results.find(prompt => prompt.text === 'Dog landscape').tags).not.toContain('generated');
|
||||
expect(data.results.find(prompt => prompt.text === 'Cat portrait').tags).toEqual(['cats']);
|
||||
expect(data.results.find(prompt => prompt.text === 'Dog landscape').tags).toEqual(['dogs']);
|
||||
const tagged = await (await request.get('/prompt_manager/images/output?mode=workspace&tags=sandstone')).json();
|
||||
expect(tagged.images.map(image => `${image.root_index}:${image.path}`)).toEqual(['0:shared/same.png']);
|
||||
});
|
||||
|
||||
test('actual Tags page reloads the selected filter and opens its image viewer', async ({ page }) => {
|
||||
|
||||
@@ -2189,3 +2189,248 @@ test('scan for metadata opens the review dialog from Import', async ({ page, req
|
||||
await expect.poll(async () => (await (await request.get('/prompt_manager/jobs')).json()).jobs
|
||||
.map(job => [job.kind, job.state])).toEqual([['backup', 'finished']]);
|
||||
});
|
||||
|
||||
// #392 (D135): file-manager selection beyond the concept's checkbox.
|
||||
const selectedCount = page => bulkbar(page).locator('.bulkbar > strong');
|
||||
const cardAt = (page, index) => page.locator(`article[data-image-key][data-index="${index}"]`);
|
||||
const boxAt = (page, index) => cardAt(page, index).getByRole('checkbox', { name: /^Select / });
|
||||
const checkedIndexes = page => page.locator('article[data-image-key]').evaluateAll(nodes => nodes
|
||||
.filter(node => node.querySelector('.image-overlays input[type="checkbox"]').checked)
|
||||
.map(node => Number(node.dataset.index)).sort((a, b) => a - b));
|
||||
const range = (from, to) => Array.from({ length: to - from + 1 }, (_, index) => from + index);
|
||||
async function bulkReset(page, request) {
|
||||
await page.addInitScript(() => { window.IntersectionObserver = undefined; });
|
||||
await expect(await request.post('/__test__/reset', { data: { extra_images: 200 } })).toBeOK();
|
||||
await page.goto(workspace);
|
||||
await expect(cards(page)).toHaveCount(40);
|
||||
}
|
||||
/** A touch press on `locator` the way a finger makes one: pointerdown, (held), pointerup. */
|
||||
const touchDown = locator => locator.evaluate(node => {
|
||||
const { left, top } = node.getBoundingClientRect();
|
||||
node.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, isPrimary: true, pointerType: 'touch', clientX: left + 5, clientY: top + 5 }));
|
||||
});
|
||||
const touchUpAndClick = locator => locator.evaluate(node => {
|
||||
node.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, isPrimary: true, pointerType: 'touch' }));
|
||||
// A tap's click counts one press (`detail` 1); a keyboard click (and `HTMLElement.click()`) counts none.
|
||||
node.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, detail: 1 }));
|
||||
});
|
||||
async function touchTap(locator) {
|
||||
await touchDown(locator);
|
||||
await touchUpAndClick(locator);
|
||||
}
|
||||
|
||||
test('Shift+click selects a range across an appended batch in Grid, List and Masonry', async ({ page, request }) => {
|
||||
await bulkReset(page, request);
|
||||
await page.getByRole('button', { name: 'Load more images', exact: true }).click();
|
||||
await expect(caption(page)).toContainText('80 of 202 images');
|
||||
for (const mode of ['Grid', 'List', 'Masonry']) {
|
||||
await page.getByRole('group', { name: 'Image layout', exact: true }).getByRole('button', { name: mode, exact: true }).click();
|
||||
// The anchor is the last image toggled on its own (List hides the concept's checkbox: the card carries it).
|
||||
await cardAt(page, 3).locator('.media-info h3').click({ modifiers: ['ControlOrMeta'] });
|
||||
await expect(selectedCount(page)).toHaveText('1 selected');
|
||||
// The range runs into the second batch (index 40 on), and backwards from the same anchor.
|
||||
await cardAt(page, 45).locator('.media-info h3').click({ modifiers: ['Shift'] });
|
||||
await expect(selectedCount(page)).toHaveText('43 selected');
|
||||
expect(await checkedIndexes(page)).toEqual(range(3, 45));
|
||||
await cardAt(page, 0).locator('.media-info h3').click({ modifiers: ['Shift'] });
|
||||
await expect(selectedCount(page)).toHaveText('46 selected');
|
||||
expect(await checkedIndexes(page)).toEqual(range(0, 45));
|
||||
await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
if (mode !== 'List') {
|
||||
// A Shift+click on a checkbox gives its range the box's new state: here, unchecked.
|
||||
await boxAt(page, 3).uncheck();
|
||||
await boxAt(page, 10).click({ modifiers: ['Shift'] });
|
||||
await expect(boxAt(page, 10)).not.toBeChecked();
|
||||
await expect(selectedCount(page)).toHaveText('38 selected');
|
||||
expect(await checkedIndexes(page)).toEqual([0, 1, 2, ...range(11, 45)]);
|
||||
}
|
||||
await bulkbar(page).getByRole('button', { name: 'Clear', exact: true }).click();
|
||||
await expect(bulkbar(page)).toBeEmpty();
|
||||
}
|
||||
});
|
||||
|
||||
test('Ctrl/Cmd+click toggles a card without opening it, and a plain click still opens the viewer', async ({ page }) => {
|
||||
await page.goto(workspace);
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
const first = cards(page).first();
|
||||
const inspectorBefore = await details(page).innerText();
|
||||
await first.getByRole('button', { name: 'Open same.png', exact: true }).click({ modifiers: ['ControlOrMeta'] });
|
||||
await expect(selectedCount(page)).toHaveText('1 selected');
|
||||
await expect(checkbox(first, 'same.png')).toBeChecked();
|
||||
await cards(page).last().getByRole('button', { name: 'Inspect same.png', exact: true }).click({ modifiers: ['ControlOrMeta'] });
|
||||
await expect(selectedCount(page)).toHaveText('2 selected');
|
||||
await star(first, 'same.png').click({ modifiers: ['ControlOrMeta'] });
|
||||
await expect(selectedCount(page)).toHaveText('1 selected');
|
||||
await expect(star(first, 'same.png')).toHaveAttribute('aria-pressed', 'false');
|
||||
await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
expect(await details(page).innerText()).toBe(inspectorBefore);
|
||||
// A plain click keeps its meaning: the image opens the viewer.
|
||||
await first.getByRole('button', { name: 'Open same.png', exact: true }).click();
|
||||
await expect(page.locator('#viewer-dialog[open]')).toHaveCount(1);
|
||||
await expect(selectedCount(page)).toHaveText('1 selected');
|
||||
});
|
||||
|
||||
test('Ctrl/Cmd+A, Select all N matching and Add tags tag every matching image on the server', async ({ page, request }) => {
|
||||
await bulkReset(page, request);
|
||||
// Ctrl/Cmd+A in the search box stays the field's own.
|
||||
await search(page).focus();
|
||||
await page.keyboard.press('ControlOrMeta+a');
|
||||
await expect(bulkbar(page)).toBeEmpty();
|
||||
await cardAt(page, 0).getByRole('button', { name: /^Open / }).focus();
|
||||
await page.keyboard.press('ControlOrMeta+a');
|
||||
await expect(selectedCount(page)).toHaveText('40 selected');
|
||||
const offer = bulkbar(page).getByRole('button', { name: 'Select all 202 matching', exact: true });
|
||||
await expect(offer).toBeVisible();
|
||||
await expect(bulkbar(page).locator('.bulkbar > *').nth(1)).toHaveText('Select all 202 matching');
|
||||
await offer.click();
|
||||
await expect(selectedCount(page)).toHaveText('All 202 matching selected');
|
||||
await expect(offer).toHaveCount(0);
|
||||
await expect(bulkbar(page).getByRole('button', { name: 'Export metadata', exact: true })).toHaveAttribute('aria-disabled', 'true');
|
||||
// A later batch arrives checked.
|
||||
await page.getByRole('button', { name: 'Load more images', exact: true }).click();
|
||||
await expect(caption(page)).toContainText('80 of 202 images');
|
||||
await expect(boxAt(page, 70)).toBeChecked();
|
||||
await expect(selectedCount(page)).toHaveText('All 202 matching selected');
|
||||
// Unchecking one image leaves "all matching": the other loaded images stay selected.
|
||||
await boxAt(page, 0).uncheck();
|
||||
await expect(selectedCount(page)).toHaveText('79 selected');
|
||||
await expect(bulkbar(page).getByRole('button', { name: 'Export metadata', exact: true })).not.toHaveAttribute('aria-disabled', 'true');
|
||||
await cardAt(page, 1).getByRole('button', { name: /^Open / }).focus();
|
||||
await page.keyboard.press('ControlOrMeta+a');
|
||||
await bulkbar(page).getByRole('button', { name: 'Select all 202 matching', exact: true }).click();
|
||||
await expect(selectedCount(page)).toHaveText('All 202 matching selected');
|
||||
// Add tags sends the listing filter, not 202 identities, and the server tags every match.
|
||||
await bulkbar(page).getByRole('button', { name: 'Add tags', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Tag selected images', exact: true });
|
||||
await dialog.getByRole('textbox', { name: 'Tags, separated by commas', exact: true }).fill('picked392');
|
||||
const started = page.waitForRequest(item => item.method() === 'POST' && new URL(item.url()).pathname === '/prompt_manager/jobs/image-tags');
|
||||
await dialog.getByRole('button', { name: 'Apply', exact: true }).click();
|
||||
expect((await started).postDataJSON()).toEqual({ mode: 'add', filter: { sort: 'newest' }, tags: ['picked392'] });
|
||||
await expect.poll(async () => (await (await request.get('/prompt_manager/images/output?mode=workspace&limit=1&tags=picked392')).json()).total,
|
||||
{ timeout: 20000 }).toBe(202);
|
||||
await expect(selectedCount(page)).toHaveText('All 202 matching selected');
|
||||
// Escape in the grid clears the selection.
|
||||
await cardAt(page, 2).getByRole('button', { name: /^Open / }).focus();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(bulkbar(page)).toBeEmpty();
|
||||
await expect(boxAt(page, 2)).not.toBeChecked();
|
||||
});
|
||||
|
||||
test('Shift+arrow keys extend the selection from the focused card, and Escape in the bar clears it', async ({ page, request }) => {
|
||||
await bulkReset(page, request);
|
||||
await cardAt(page, 0).getByRole('button', { name: /^Open / }).focus();
|
||||
await page.keyboard.press('Shift+ArrowRight');
|
||||
await page.keyboard.press('Shift+ArrowRight');
|
||||
await expect(cardAt(page, 2).getByRole('button', { name: /^Open / })).toBeFocused();
|
||||
await expect(selectedCount(page)).toHaveText('3 selected');
|
||||
expect(await checkedIndexes(page)).toEqual([0, 1, 2]);
|
||||
// A plain arrow moves without selecting; Shift+End reaches the last loaded card from the same anchor.
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(selectedCount(page)).toHaveText('3 selected');
|
||||
await page.keyboard.press('Shift+End');
|
||||
await expect(selectedCount(page)).toHaveText('40 selected');
|
||||
await bulkbar(page).getByRole('button', { name: 'Clear', exact: true }).focus();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(bulkbar(page)).toBeEmpty();
|
||||
await expect(page.locator('#workspace-main h1')).toBeFocused();
|
||||
});
|
||||
|
||||
test('a touch long-press starts selection mode, taps then toggle, and an empty selection ends it', async ({ page }, testInfo) => {
|
||||
await page.goto(workspace);
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
const [first, second] = [cards(page).first(), cards(page).last()];
|
||||
const open = card => card.getByRole('button', { name: 'Open same.png', exact: true });
|
||||
await touchDown(open(first));
|
||||
// The context menu of a held press belongs to the long press.
|
||||
expect(await open(first).evaluate(node => !node.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })))).toBe(true);
|
||||
// Held: the long press selects the card once its delay passed.
|
||||
await expect(selectedCount(page)).toHaveText('1 selected');
|
||||
await touchUpAndClick(open(first));
|
||||
await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
await expect(checkbox(first, 'same.png')).toBeChecked();
|
||||
// Taps now toggle instead of opening.
|
||||
if (testInfo.project.name === 'touch') await open(second).tap();
|
||||
else await touchTap(open(second));
|
||||
await expect(selectedCount(page)).toHaveText('2 selected');
|
||||
// A keyboard Enter keeps its meaning in touch selection mode: the image opens.
|
||||
await open(first).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page.locator('#viewer-dialog[open]')).toHaveCount(1);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#viewer-dialog[open]')).toHaveCount(0);
|
||||
await expect(selectedCount(page)).toHaveText('2 selected');
|
||||
await touchTap(open(first));
|
||||
await touchTap(open(second));
|
||||
await expect(bulkbar(page)).toBeEmpty();
|
||||
await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
// The selection is empty, so touch selection mode ended: a tap opens the image again.
|
||||
await touchTap(open(first));
|
||||
await expect(page.locator('#viewer-dialog[open]')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('all matching sets the category through the listing filter and survives a return visit as the loaded images', async ({ page, request }) => {
|
||||
await bulkReset(page, request);
|
||||
await cardAt(page, 0).getByRole('button', { name: /^Open / }).focus();
|
||||
await page.keyboard.press('ControlOrMeta+a');
|
||||
await bulkbar(page).getByRole('button', { name: 'Select all 202 matching', exact: true }).click();
|
||||
// The offer left with its press: focus moves to the bar's first action.
|
||||
await expect(bulkbar(page).getByRole('button', { name: 'Add tags', exact: true })).toBeFocused();
|
||||
await cardAt(page, 0).getByRole('button', { name: /^Open / }).focus();
|
||||
await page.keyboard.press('ControlOrMeta+a');
|
||||
await expect(selectedCount(page)).toHaveText('All 202 matching selected');
|
||||
// Export metadata waits for loaded images: a press does nothing.
|
||||
const exportButton = bulkbar(page).getByRole('button', { name: 'Export metadata', exact: true });
|
||||
await expect(exportButton).toHaveAttribute('title', 'Export metadata reads each image in the browser, so it works on loaded images only.');
|
||||
// No match linked (a stubbed answer): the nothing-linked sentence, and the selection stays.
|
||||
await page.route('**/prompt_manager/bulk/category', route => route.fulfill({ json: { success: true, updated_count: 0, prompt_count: 0, skipped: 202 } }), { times: 1 });
|
||||
const categorize = page.getByRole('dialog', { name: 'Categorize selected images', exact: true });
|
||||
await bulkbar(page).getByRole('button', { name: 'Set category', exact: true }).click();
|
||||
await categorize.getByRole('textbox', { name: 'Category', exact: true }).fill('Bulk392');
|
||||
await categorize.getByRole('button', { name: 'Apply', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText("None of the selected images is linked to a saved prompt, so there is no prompt category to set.");
|
||||
await expect(selectedCount(page)).toHaveText('All 202 matching selected');
|
||||
// The real write: one request with the filter, every matching image's positive changes.
|
||||
await bulkbar(page).getByRole('button', { name: 'Set category', exact: true }).click();
|
||||
await categorize.getByRole('textbox', { name: 'Category', exact: true }).fill('Bulk392');
|
||||
const posted = page.waitForRequest(item => item.method() === 'POST' && new URL(item.url()).pathname === '/prompt_manager/bulk/category');
|
||||
await categorize.getByRole('button', { name: 'Apply', exact: true }).click();
|
||||
expect((await posted).postDataJSON()).toEqual({ images_filter: { sort: 'newest' }, category: 'Bulk392' });
|
||||
await expect(toast(page)).toHaveText(/^Category set on \d+ prompts?\.$/);
|
||||
await expect(bulkbar(page)).toBeEmpty();
|
||||
const filtered = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=1&category=Bulk392')).json();
|
||||
expect(filtered.total).toBe(202);
|
||||
// A return visit keeps "all matching" as the loaded images, and the offer shows again.
|
||||
await cardAt(page, 0).getByRole('button', { name: /^Open / }).focus();
|
||||
await page.keyboard.press('ControlOrMeta+a');
|
||||
await bulkbar(page).getByRole('button', { name: 'Select all 202 matching', exact: true }).click();
|
||||
await page.evaluate(() => { window.location.hash = '/overview'; });
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
await page.evaluate(() => { window.location.hash = '/images'; });
|
||||
await expect(selectedCount(page)).toHaveText('40 selected');
|
||||
await expect(bulkbar(page).getByRole('button', { name: 'Select all 202 matching', exact: true })).toBeVisible();
|
||||
// Escape in the bar with nothing selected does nothing; with a selection it clears.
|
||||
await bulkbar(page).getByRole('button', { name: 'Clear', exact: true }).focus();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(bulkbar(page)).toBeEmpty();
|
||||
});
|
||||
|
||||
test('all matching over 10,000 images says so before any dialog or request', async ({ page, request }) => {
|
||||
await page.route(url => url.pathname === '/prompt_manager/images/output' && url.searchParams.get('limit') !== '1', async route => {
|
||||
const response = await route.fetch();
|
||||
const body = await response.json();
|
||||
await route.fulfill({ response, json: { ...body, total: 20002 } });
|
||||
});
|
||||
await bulkReset(page, request);
|
||||
await cardAt(page, 0).getByRole('button', { name: /^Open / }).focus();
|
||||
await page.keyboard.press('ControlOrMeta+a');
|
||||
await bulkbar(page).getByRole('button', { name: 'Select all 20,002 matching', exact: true }).click();
|
||||
await expect(selectedCount(page)).toHaveText('All 20,002 matching selected');
|
||||
let posted = 0;
|
||||
page.on('request', item => { if (item.method() === 'POST') posted += 1; });
|
||||
for (const action of ['Set category', 'Add tags', 'Auto-tag selected']) {
|
||||
await bulkbar(page).getByRole('button', { name: action, exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Narrow the view to at most 10,000 images.');
|
||||
await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
}
|
||||
expect(posted).toBe(0);
|
||||
});
|
||||
|
||||
@@ -36,33 +36,48 @@ function fixture() {
|
||||
const reply = (index, data) => requests[index].resolve({ json: async () => data });
|
||||
return { manager, requests, reply, notifications };
|
||||
}
|
||||
test('gallery review retains root identity and applies tags with the returned prompt ID', async () => {
|
||||
const SHA = 'a'.repeat(64);
|
||||
test('gallery review retains root identity and applies the tags to the image by its sha256 (#379)', async () => {
|
||||
const { manager, requests, reply } = fixture();
|
||||
const load = manager.loadNextReviewImage();
|
||||
assert.equal(requests[0].body.root_index, 1);
|
||||
reply(0, { success: true, prompt_id: 42, tags: ['smile'] });
|
||||
assert.equal(requests[0].body.existing, 'merge');
|
||||
reply(0, { success: true, prompt_id: 42, tags: ['smile'], sha256: SHA, image_tags: [] });
|
||||
await load;
|
||||
manager.loadNextReviewImage = () => {};
|
||||
const apply = manager.applyReviewTags();
|
||||
assert.deepEqual(requests[1].body, { prompt_id: 42, tags: ['smile'] });
|
||||
assert.equal(requests[1].url, '/prompt_manager/autotag/apply');
|
||||
assert.deepEqual(requests[1].body, ImageHelpers.autotagApplyBody(
|
||||
{ id: 'digest', path: 'same.png', root_index: 1 }, SHA, ['smile']));
|
||||
assert.equal('prompt_id' in requests[1].body, false);
|
||||
reply(1, { success: true });
|
||||
await apply;
|
||||
});
|
||||
test('unlinked images do not submit an invalid apply request or advance', async () => {
|
||||
test('an unlinked image applies to the image like a linked one', async () => {
|
||||
const { manager, requests, reply } = fixture();
|
||||
const load = manager.loadNextReviewImage();
|
||||
reply(0, { success: true, prompt_id: null, tags: ['smile'] });
|
||||
reply(0, { success: true, prompt_id: null, tags: ['smile'], sha256: SHA, image_tags: [] });
|
||||
await load;
|
||||
manager.loadNextReviewImage = () => {};
|
||||
const apply = manager.applyReviewTags();
|
||||
if (requests[1]) reply(1, { success: false, error: "Unexpected apply" });
|
||||
assert.equal(requests[1].body.sha256, SHA);
|
||||
assert.deepEqual(requests[1].body.tags, ['smile']);
|
||||
reply(1, { success: true });
|
||||
await apply;
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(manager.autoTagState.reviewIndex, 0);
|
||||
});
|
||||
test('an answer without a captured sha256 never sends an apply', async () => {
|
||||
const { manager, requests, reply } = fixture();
|
||||
const load = manager.loadNextReviewImage();
|
||||
reply(0, { success: true, prompt_id: 42, tags: ['smile'] });
|
||||
await load;
|
||||
manager.loadNextReviewImage = () => {};
|
||||
await manager.applyReviewTags();
|
||||
assert.equal(requests.filter(r => r.url === '/prompt_manager/autotag/apply').length, 0);
|
||||
});
|
||||
test('a failed apply keeps the current image available for retry', async () => {
|
||||
const { manager, requests, reply } = fixture();
|
||||
const load = manager.loadNextReviewImage();
|
||||
reply(0, { success: true, prompt_id: 42, tags: ['smile'] });
|
||||
reply(0, { success: true, prompt_id: 42, tags: ['smile'], sha256: SHA, image_tags: [] });
|
||||
await load;
|
||||
const apply = manager.applyReviewTags();
|
||||
reply(1, { success: false, error: 'Try again' });
|
||||
|
||||
@@ -5,6 +5,8 @@ const {
|
||||
formatImageCaption,
|
||||
workflowDocumentHtml,
|
||||
autotagSingleBody,
|
||||
autotagApplyBody,
|
||||
autotagCompleteMessage,
|
||||
fallbackImageSource,
|
||||
} = require("../../web/js/image-helpers.js");
|
||||
|
||||
@@ -121,3 +123,27 @@ test('metadata lookup preserves encoded filename characters and works behind a p
|
||||
assert.equal(metadataLookupUrl('/prompt_manager/images/serve/a.png?v=123'), '/prompt_manager/images/prompt/a.png');
|
||||
assert.equal(metadataLookupUrl('/elsewhere/a.png'), null);
|
||||
});
|
||||
|
||||
test("autotagSingleBody asks to skip auto-tagged images only when told (#379)", () => {
|
||||
assert.equal(autotagSingleBody({ id: 1 }, WD14).existing, "merge");
|
||||
assert.equal(autotagSingleBody({ id: 1 }, { ...WD14, existing: "skip" }).existing, "skip");
|
||||
assert.equal(autotagSingleBody({ id: 1 }, { ...WD14, existing: "odd" }).existing, "merge");
|
||||
});
|
||||
|
||||
test("autotagApplyBody names the image, its bytes and the tags, never a prompt (#379)", () => {
|
||||
const sha = "a".repeat(64);
|
||||
assert.deepEqual(autotagApplyBody({ id: 4, prompt_id: 7 }, sha, ["bird"]), { image_id: 4, sha256: sha, tags: ["bird"] });
|
||||
assert.deepEqual(autotagApplyBody({ id: "digest", path: "sub/a.png", root_index: 1 }, sha, []),
|
||||
{ path: "sub/a.png", root_index: 1, sha256: sha, tags: [] });
|
||||
assert.deepEqual(autotagApplyBody({ id: 2 }, undefined, null), { image_id: 2, sha256: "", tags: [] });
|
||||
const tags = ["x"];
|
||||
autotagApplyBody({ id: 1 }, sha, tags).tags.push("y");
|
||||
assert.deepEqual(tags, ["x"]);
|
||||
});
|
||||
|
||||
test("autotagCompleteMessage counts images (#379)", () => {
|
||||
assert.equal(autotagCompleteMessage(2), "Auto tagging complete! Tagged 2 images.");
|
||||
assert.equal(autotagCompleteMessage(1), "Auto tagging complete! Tagged 1 image.");
|
||||
assert.equal(autotagCompleteMessage(0), "Auto tagging complete! Tagged 0 images.");
|
||||
assert.equal(autotagCompleteMessage(undefined), "Auto tagging complete! Tagged 0 images.");
|
||||
});
|
||||
|
||||
@@ -97,6 +97,30 @@ test("connect reports a non-2xx response through onerror and never onmessage", a
|
||||
assert.equal(source.readyState, source.CLOSED);
|
||||
});
|
||||
|
||||
test("a non-2xx JSON answer's error sentence reaches onerror as serverMessage", async () => {
|
||||
let error = null;
|
||||
const fetchImpl = async () => new Response(
|
||||
JSON.stringify({ success: false, error: "Auto-tagging is already running" }),
|
||||
{ status: 409, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
const source = connect("/x", { fetchImpl });
|
||||
source.onerror = (err) => { error = err; };
|
||||
await until(() => error !== null);
|
||||
assert.match(error.message, /409/);
|
||||
assert.equal(error.status, 409);
|
||||
assert.equal(error.serverMessage, "Auto-tagging is already running");
|
||||
});
|
||||
|
||||
test("a non-2xx answer without a JSON error has no serverMessage", async () => {
|
||||
let error = null;
|
||||
const fetchImpl = async () => new Response("nope", { status: 500 });
|
||||
const source = connect("/x", { fetchImpl });
|
||||
source.onerror = (err) => { error = err; };
|
||||
await until(() => error !== null);
|
||||
assert.equal(error.status, 500);
|
||||
assert.equal(error.serverMessage, undefined);
|
||||
});
|
||||
|
||||
test("connect reports a network failure through onerror", async () => {
|
||||
let error = null;
|
||||
const source = connect("/x", { fetchImpl: async () => { throw new TypeError("Failed to fetch"); } });
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const source = import('../../web/js/workspace/images-multiselect.mjs');
|
||||
|
||||
const KEYS = Object.freeze(['0:a', '0:b', '0:c', '0:d', '1:e']);
|
||||
|
||||
test('a range runs from the anchor to the target in display order, either way', async () => {
|
||||
const { rangeKeys } = await source;
|
||||
assert.deepEqual(rangeKeys(KEYS, '0:b', '0:d'), ['0:b', '0:c', '0:d']);
|
||||
assert.deepEqual(rangeKeys(KEYS, '1:e', '0:c'), ['0:c', '0:d', '1:e']);
|
||||
assert.deepEqual(rangeKeys(KEYS, '0:c', '0:c'), ['0:c']);
|
||||
});
|
||||
|
||||
test('a range without a listed anchor is the target alone, and an unlisted target is empty', async () => {
|
||||
const { rangeKeys } = await source;
|
||||
assert.deepEqual(rangeKeys(KEYS, null, '0:c'), ['0:c']);
|
||||
assert.deepEqual(rangeKeys(KEYS, '9:gone', '0:c'), ['0:c']);
|
||||
assert.deepEqual(rangeKeys(KEYS, '0:a', '9:gone'), []);
|
||||
});
|
||||
|
||||
test('applying keys adds or removes them and keeps the same frozen set when nothing changes', async () => {
|
||||
const { applyKeys } = await source;
|
||||
const start = Object.freeze(new Set(['0:a']));
|
||||
const added = applyKeys(start, ['0:b', '0:c'], true);
|
||||
assert.deepEqual([...added], ['0:a', '0:b', '0:c']);
|
||||
assert.ok(Object.isFrozen(added));
|
||||
assert.deepEqual([...start], ['0:a']);
|
||||
const removed = applyKeys(added, ['0:a', '0:b'], false);
|
||||
assert.deepEqual([...removed], ['0:c']);
|
||||
assert.equal(applyKeys(removed, ['0:c'], true), removed);
|
||||
assert.equal(applyKeys(removed, ['0:a'], false), removed);
|
||||
});
|
||||
|
||||
test('every listed key is checked only when each one is in the set', async () => {
|
||||
const { everyChecked } = await source;
|
||||
assert.equal(everyChecked(new Set(KEYS), KEYS), true);
|
||||
assert.equal(everyChecked(new Set(KEYS.slice(1)), KEYS), false);
|
||||
assert.equal(everyChecked(new Set(['x', ...KEYS.slice(1)]), KEYS), false);
|
||||
assert.equal(everyChecked(new Set(), []), false);
|
||||
});
|
||||
|
||||
test('the select-all-matching offer needs every loaded image checked and more to load', async () => {
|
||||
const { offersAllMatching } = await source;
|
||||
const all = new Set(KEYS);
|
||||
assert.equal(offersAllMatching(all, KEYS, { total: 9, hasMore: true }), true);
|
||||
assert.equal(offersAllMatching(all, KEYS, { total: 5, hasMore: false }), false);
|
||||
assert.equal(offersAllMatching(new Set(KEYS.slice(1)), KEYS, { total: 9, hasMore: true }), false);
|
||||
assert.equal(offersAllMatching(new Set(), [], { total: 9, hasMore: true }), false);
|
||||
});
|
||||
|
||||
test('the counts read "N selected" and "All N matching selected", grouped', async () => {
|
||||
const { matchingLabel, matchingOffer } = await source;
|
||||
assert.equal(matchingLabel(12345), 'All 12,345 matching selected');
|
||||
assert.equal(matchingOffer(12345), 'Select all 12,345 matching');
|
||||
});
|
||||
|
||||
function fakeTimers() {
|
||||
const timers = new Map();
|
||||
let next = 1;
|
||||
return {
|
||||
set: (callback, delay) => { timers.set(next, { callback, delay }); return next++; },
|
||||
clear: id => timers.delete(id),
|
||||
fire: () => { for (const [id, { callback }] of [...timers]) { timers.delete(id); callback(); } },
|
||||
pending: () => timers.size,
|
||||
delays: () => [...timers.values()].map(timer => timer.delay),
|
||||
};
|
||||
}
|
||||
|
||||
test('a held press fires once after the delay and swallows the click that follows', async () => {
|
||||
const { createLongPress, LONG_PRESS_MS } = await source;
|
||||
const timers = fakeTimers(), fired = [];
|
||||
const press = createLongPress({ onLongPress: target => fired.push(target), setTimer: timers.set, clearTimer: timers.clear });
|
||||
press.down(10, 10, 'card-a');
|
||||
assert.deepEqual(timers.delays(), [LONG_PRESS_MS]);
|
||||
assert.equal(LONG_PRESS_MS, 500);
|
||||
press.move(14, 13);
|
||||
timers.fire();
|
||||
assert.deepEqual(fired, ['card-a']);
|
||||
press.up();
|
||||
assert.equal(press.consumeClick(), true);
|
||||
assert.equal(press.consumeClick(), false);
|
||||
});
|
||||
|
||||
test('moving past the slop, lifting early or cancelling never fires', async () => {
|
||||
const { createLongPress, LONG_PRESS_SLOP } = await source;
|
||||
assert.equal(LONG_PRESS_SLOP, 10);
|
||||
const timers = fakeTimers(), fired = [];
|
||||
const press = createLongPress({ onLongPress: target => fired.push(target), setTimer: timers.set, clearTimer: timers.clear });
|
||||
press.down(0, 0, 'a'); press.move(0, 11);
|
||||
assert.equal(timers.pending(), 0);
|
||||
press.down(0, 0, 'b'); press.up();
|
||||
assert.equal(timers.pending(), 0);
|
||||
press.down(0, 0, 'c');
|
||||
assert.equal(press.pending(), true);
|
||||
press.cancel();
|
||||
assert.equal(press.pending(), false);
|
||||
assert.equal(timers.pending(), 0);
|
||||
press.move(0, 0); press.up(); press.cancel();
|
||||
assert.deepEqual(fired, []);
|
||||
assert.equal(press.consumeClick(), false);
|
||||
// A new press drops a long press whose click never came.
|
||||
press.down(0, 0, 'd'); timers.fire();
|
||||
press.down(0, 0, 'e');
|
||||
assert.equal(press.consumeClick(), false);
|
||||
timers.fire();
|
||||
assert.deepEqual(fired, ['d', 'e']);
|
||||
// A cancel (a mouse press on a hybrid device) forgets a fired press whose click never came.
|
||||
press.cancel();
|
||||
assert.equal(press.consumeClick(), false);
|
||||
});
|
||||
@@ -494,3 +494,11 @@ test('defaults apply to a new visit but not to the current view or a saved view'
|
||||
assert.deepEqual(initialImagesView({}), { layout: 'grid', density: 'comfortable' });
|
||||
assert.deepEqual(initialImagesView({ preferences: {} }), { layout: 'grid', density: 'comfortable' });
|
||||
});
|
||||
|
||||
test('listingFilter is the listing query as an object, without paging (#392)', async () => {
|
||||
const { listingFilter } = await source;
|
||||
assert.deepEqual(listingFilter({q:'cat', root:'1', subfolder:'a/b', category:'Animals', tags:[' x ','y'], media:'video', sort:'oldest', favorites:true}),
|
||||
{q:'cat', root:'1', subfolder:'a/b', category:'Animals', tags:'x,y', media:'video', sort:'oldest', favorites:'1'});
|
||||
assert.deepEqual(listingFilter({root:'all', q:'', tags:[], media:'all'}), {sort:'newest'});
|
||||
assert.throws(() => listingFilter({sort:'sideways'}), TypeError);
|
||||
});
|
||||
|
||||
@@ -196,3 +196,29 @@ test('a preset starts the Tag image dialog on the chosen model and options', asy
|
||||
assert.throws(() => createImageTaggingController({ api, target, preset: { model: 'gguf', options: { instructions: '' } } }), TypeError);
|
||||
assert.throws(() => createImageTaggingController({ api, target, preset: { model: 'gguf', options: { nope: 1 } } }), TypeError);
|
||||
});
|
||||
|
||||
test('all matching sends the listing filter instead of targets, up to 10,000 images (#392)', async () => {
|
||||
const { openAddTags, openAutoTag, MAX_FILTER_IMAGES, TOO_MANY_MATCHING } = module();
|
||||
assert.equal(MAX_FILTER_IMAGES, 10000);
|
||||
assert.equal(TOO_MANY_MATCHING, 'Narrow the view to at most 10,000 images.');
|
||||
const toasts = [], posted = [], tagged = [];
|
||||
let ended = null;
|
||||
const store = { start: async (kind, body) => { posted.push([kind, body]); return { job: { id: JOB_ID, state: 'running' } }; },
|
||||
find: () => ended, subscribe: () => () => {} };
|
||||
const filter = { q: 'cat', sort: 'newest' };
|
||||
// The loaded images can be more than 500: the filter, not the identities, is sent.
|
||||
const loaded = Array.from({ length: 600 }, (_, index) => image(`bulk/${index}.png`));
|
||||
let dialog = null;
|
||||
openAddTags({ scope: pageScope(), store, images: loaded, filter, count: 1234, notify: text => toasts.push(text),
|
||||
onTagged: keys => tagged.push(keys.size), openInput: args => { dialog = args; } });
|
||||
assert.ok(dialog, 'the Add tags dialog opens');
|
||||
ended = { id: JOB_ID, state: 'finished', counters: { tagged: 3 } };
|
||||
await dialog.onApply('sunset');
|
||||
assert.deepEqual(posted, [['image-tags', { mode: 'add', filter, tags: ['sunset'] }]]);
|
||||
// The loaded files are read again when the job tagged any.
|
||||
assert.deepEqual(tagged, [600]);
|
||||
openAddTags({ scope: pageScope(), store, images: loaded, filter, count: 10001, notify: text => toasts.push(text),
|
||||
openInput: () => assert.fail('no dialog over the cap') });
|
||||
openAutoTag({ api: {}, scope: pageScope(), store, images: loaded, filter, count: 10001, notify: text => toasts.push(text) });
|
||||
assert.deepEqual(toasts, [TOO_MANY_MATCHING, TOO_MANY_MATCHING]);
|
||||
});
|
||||
|
||||
@@ -363,23 +363,20 @@ class TestModelGate(SuggestTestCase):
|
||||
prompt_id = self._save_prompt("batch prompt")
|
||||
self.api.db.link_image_to_prompt(prompt_id, str(self.output_dir / "sub/a.png"))
|
||||
started, proceed = threading.Event(), threading.Event()
|
||||
|
||||
def slow_generate(image_path, **kwargs):
|
||||
started.set()
|
||||
proceed.wait(WAIT)
|
||||
return ["batch"]
|
||||
|
||||
self.tagger.generate_tags = slow_generate
|
||||
# The legacy batch is the autotag job (#379): it suggests per image.
|
||||
self.tagger.block = (started, proceed)
|
||||
batch = asyncio.ensure_future(
|
||||
self.client.post("/prompt_manager/autotag/start?model_type=wd14-vit")
|
||||
)
|
||||
try:
|
||||
await asyncio.get_running_loop().run_in_executor(None, started.wait, WAIT)
|
||||
calls = len(self.tagger.calls)
|
||||
status, body = await self.suggest(self.suggest_body(observed, "gguf"))
|
||||
self.assertEqual((status, body["code"]), (409, "model-busy"))
|
||||
self.assertEqual(self.tagger.calls, [])
|
||||
self.assertEqual(len(self.tagger.calls), calls)
|
||||
self.assertEqual(self.tagger.loaded, "wd14-vit") # not swapped
|
||||
finally:
|
||||
self.tagger.block = None
|
||||
proceed.set()
|
||||
response = await batch
|
||||
await response.read()
|
||||
|
||||
+69
-411
@@ -5,6 +5,7 @@ no network request is made.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
@@ -34,6 +35,7 @@ from aiohttp.test_utils import AioHTTPTestCase
|
||||
|
||||
from database.operations import PromptDatabase
|
||||
from py.api import PromptManagerAPI
|
||||
from py.api.autotag_routes import MODEL_BUSY_ERROR
|
||||
from py.api.jobs import JobBusy
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
@@ -70,6 +72,7 @@ class FakeAutotagService:
|
||||
self.status_calls = 0
|
||||
self.status_threads = []
|
||||
self.status_paths = {}
|
||||
self.suggest_calls = 0
|
||||
|
||||
def get_models_status(self):
|
||||
self.status_calls += 1
|
||||
@@ -115,6 +118,25 @@ class FakeAutotagService:
|
||||
raise self.generate_error
|
||||
return list(self.tags)
|
||||
|
||||
def generate_tag_suggestions(
|
||||
self,
|
||||
image_path,
|
||||
*,
|
||||
limit,
|
||||
prompt=None,
|
||||
general_threshold=None,
|
||||
character_threshold=None,
|
||||
):
|
||||
"""The suggestion entry point (#379): the fixed tags, at most ``limit``."""
|
||||
self.suggest_calls += 1
|
||||
tags = self.generate_tags(
|
||||
image_path,
|
||||
prompt=prompt,
|
||||
general_threshold=general_threshold,
|
||||
character_threshold=character_threshold,
|
||||
)
|
||||
return [(name, None) for name in tags[:limit]]
|
||||
|
||||
|
||||
class AutotagAPITestCase(AioHTTPTestCase):
|
||||
"""Stands up the PromptManager routes with a temp DB, temp output dir and a
|
||||
@@ -175,8 +197,17 @@ class AutotagAPITestCase(AioHTTPTestCase):
|
||||
)
|
||||
|
||||
def _make_image(self, directory, name="img.png"):
|
||||
"""A small, valid image (the legacy review captures and probes it)."""
|
||||
from PIL import Image
|
||||
|
||||
path = directory / name
|
||||
path.write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shade = sum(name.encode()) % 256
|
||||
fmt = {".jpg": "JPEG", ".jpeg": "JPEG", ".webp": "WEBP"}.get(
|
||||
path.suffix.lower(), "PNG"
|
||||
)
|
||||
with Image.new("RGB", (4, 4), (shade, 64, 128)) as image:
|
||||
image.save(path, fmt)
|
||||
return path
|
||||
|
||||
def _link_image(self, prompt_id, path):
|
||||
@@ -216,10 +247,9 @@ class TestAutotagSingleContainment(AutotagAPITestCase):
|
||||
self.assertEqual(data["tags"], ["1girl", "smile"])
|
||||
self.assertEqual(data["prompt_id"], prompt_id)
|
||||
self.assertEqual(len(self.service.generate_calls), 1)
|
||||
self.assertEqual(
|
||||
os.path.realpath(self.service.generate_calls[0][0]),
|
||||
os.path.realpath(str(image)),
|
||||
)
|
||||
# #379: the model reads a private capture of the image, never the file.
|
||||
self.assertNotIn(str(self.output_dir), self.service.generate_calls[0][0])
|
||||
self.assertEqual(data["sha256"], hashlib.sha256(image.read_bytes()).hexdigest())
|
||||
|
||||
async def test_path_inside_output_dir_is_tagged(self):
|
||||
image = self._make_image(self.output_dir)
|
||||
@@ -449,238 +479,6 @@ class TestAutotagSideEffectRoutesArePostOnly(AutotagAPITestCase):
|
||||
self.assertTrue((await resp.json())["success"])
|
||||
|
||||
|
||||
class TestAutotagBatch(AutotagAPITestCase):
|
||||
"""Batch tagging over the DB image list with SSE progress reporting."""
|
||||
|
||||
def _seed(self):
|
||||
"""Three prompts: A (one image), B (two images), C (missing file)."""
|
||||
a = self._save_prompt("prompt a")
|
||||
b = self._save_prompt("prompt b")
|
||||
c = self._save_prompt("prompt c")
|
||||
self._link_image(a, self._make_image(self.output_dir, "a1.png"))
|
||||
self._link_image(b, self._make_image(self.output_dir, "b1.png"))
|
||||
self._link_image(b, self._make_image(self.output_dir, "b2.png"))
|
||||
self._link_image(c, self.output_dir / "missing.png")
|
||||
return a, b, c
|
||||
|
||||
async def _start(self, query="model_type=gguf", body=None):
|
||||
if body is None:
|
||||
resp = await self.client.request(
|
||||
"POST", f"/prompt_manager/autotag/start?{query}"
|
||||
)
|
||||
else:
|
||||
resp = await self._post_json(f"/prompt_manager/autotag/start?{query}", body)
|
||||
return resp, self._sse_events(await resp.text())
|
||||
|
||||
async def test_batch_tags_each_prompt_once_and_reports_counts(self):
|
||||
a, b, c = self._seed()
|
||||
|
||||
resp, events = await self._start()
|
||||
|
||||
self.assertEqual(resp.status, 200)
|
||||
done = events[-1]
|
||||
self.assertEqual(done["type"], "complete")
|
||||
self.assertEqual(done["processed"], 2)
|
||||
self.assertEqual(done["tagged"], 2)
|
||||
self.assertEqual(done["skipped"], 2)
|
||||
self.assertEqual(done["errors"], 0)
|
||||
self.assertEqual(len(self.service.generate_calls), 2)
|
||||
self.assertEqual(self.api.db.get_prompt_by_id(a)["tags"], ["1girl", "smile"])
|
||||
self.assertEqual(self.api.db.get_prompt_by_id(b)["tags"], ["1girl", "smile"])
|
||||
self.assertEqual(self.api.db.get_prompt_by_id(c)["tags"], [])
|
||||
progress = [e for e in events if e["type"] == "progress"]
|
||||
self.assertTrue(all(0 <= e["progress"] <= 100 for e in progress))
|
||||
self.assertTrue(any("processed" in e for e in progress))
|
||||
|
||||
async def test_rows_outside_output_dirs_are_skipped_and_counted(self):
|
||||
inside = self._save_prompt("inside")
|
||||
outside = self._save_prompt("outside")
|
||||
self._link_image(inside, self._make_image(self.output_dir, "in.png"))
|
||||
secret = self._make_image(self.secret_dir, "password.png")
|
||||
self._link_image(outside, secret)
|
||||
|
||||
_, events = await self._start()
|
||||
|
||||
done = events[-1]
|
||||
self.assertEqual(done["type"], "complete")
|
||||
self.assertEqual(done["processed"], 1)
|
||||
self.assertEqual(done["tagged"], 1)
|
||||
self.assertEqual(done["skipped"], 1)
|
||||
self.assertEqual(done["skipped_outside_roots"], 1)
|
||||
self.assertEqual(len(self.service.generate_calls), 1)
|
||||
self.assertEqual(
|
||||
os.path.realpath(self.service.generate_calls[0][0]),
|
||||
os.path.realpath(str(self.output_dir / "in.png")),
|
||||
)
|
||||
self.assertEqual(self.api.db.get_prompt_by_id(outside)["tags"], [])
|
||||
progress = [e for e in events if e["type"] == "progress" and "skipped" in e]
|
||||
self.assertTrue(progress)
|
||||
self.assertTrue(all("skipped_outside_roots" in e for e in progress))
|
||||
body = json.dumps(events)
|
||||
self.assertNotIn(str(self.secret_dir), body)
|
||||
|
||||
async def test_second_start_while_running_is_409(self):
|
||||
self._seed()
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def slow_generate(image_path, **kwargs):
|
||||
started.set()
|
||||
release.wait(5)
|
||||
return ["slow"]
|
||||
|
||||
self.service.generate_tags = slow_generate
|
||||
first = asyncio.ensure_future(self._start())
|
||||
try:
|
||||
await asyncio.get_event_loop().run_in_executor(None, started.wait, 5)
|
||||
self.assertTrue(started.is_set())
|
||||
|
||||
resp = await self.client.request(
|
||||
"POST", "/prompt_manager/autotag/start?model_type=gguf"
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status, 409)
|
||||
self.assertEqual(
|
||||
await resp.json(),
|
||||
{"success": False, "error": "Auto-tagging is already running"},
|
||||
)
|
||||
finally:
|
||||
release.set()
|
||||
resp1, events = await first
|
||||
self.assertEqual(resp1.status, 200)
|
||||
self.assertEqual(events[-1]["type"], "complete")
|
||||
|
||||
async def test_lock_is_released_after_a_run(self):
|
||||
self._seed()
|
||||
|
||||
resp1, _ = await self._start()
|
||||
resp2, events = await self._start()
|
||||
|
||||
self.assertEqual(resp1.status, 200)
|
||||
self.assertEqual(resp2.status, 200)
|
||||
self.assertEqual(events[-1]["type"], "complete")
|
||||
|
||||
async def test_lock_is_released_when_the_run_fails(self):
|
||||
self._seed()
|
||||
self.service.load_error = RuntimeError("no model")
|
||||
|
||||
_, events = await self._start()
|
||||
self.assertEqual(events[-1]["type"], "error")
|
||||
|
||||
self.service.load_error = None
|
||||
resp, events = await self._start()
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertEqual(events[-1]["type"], "complete")
|
||||
|
||||
async def test_already_tagged_prompts_are_skipped_unless_asked(self):
|
||||
p = self._save_prompt("tagged", tags=["cat"])
|
||||
self._link_image(p, self._make_image(self.output_dir, "t.png"))
|
||||
|
||||
_, events = await self._start("model_type=gguf&skip_tagged=true")
|
||||
self.assertEqual(events[-1]["skipped"], 1)
|
||||
self.assertEqual(self.service.generate_calls, [])
|
||||
|
||||
_, events = await self._start("model_type=gguf&skip_tagged=false")
|
||||
self.assertEqual(events[-1]["tagged"], 1)
|
||||
self.assertEqual(
|
||||
self.api.db.get_prompt_by_id(p)["tags"], ["cat", "1girl", "smile"]
|
||||
)
|
||||
|
||||
async def test_prompt_already_holding_the_tags_counts_as_skipped(self):
|
||||
p = self._save_prompt("same", tags=["1girl", "smile"])
|
||||
self._link_image(p, self._make_image(self.output_dir, "s.png"))
|
||||
|
||||
_, events = await self._start("model_type=gguf&skip_tagged=false")
|
||||
|
||||
self.assertEqual(events[-1]["skipped"], 1)
|
||||
self.assertEqual(events[-1]["tagged"], 0)
|
||||
|
||||
async def test_generation_errors_are_counted_and_do_not_abort(self):
|
||||
self._seed()
|
||||
self.service.generate_error = RuntimeError("boom")
|
||||
|
||||
_, events = await self._start()
|
||||
|
||||
self.assertEqual(events[-1]["type"], "complete")
|
||||
# a1, b1 and b2 each fail: a failed prompt is retried on its next image
|
||||
self.assertEqual(events[-1]["errors"], 3)
|
||||
self.assertEqual(events[-1]["tagged"], 0)
|
||||
|
||||
async def test_json_body_parameters_are_accepted(self):
|
||||
self._seed()
|
||||
|
||||
_, events = await self._start(
|
||||
"", {"model_type": "wd14-vit", "general_threshold": 0.5, "prompt": "p"}
|
||||
)
|
||||
|
||||
self.assertEqual(events[-1]["type"], "complete")
|
||||
self.assertEqual(self.service.load_calls, [("wd14-vit", True)])
|
||||
self.assertEqual(self.service.generate_calls[0][2], 0.5)
|
||||
self.assertEqual(self.service.custom_prompt, "p")
|
||||
|
||||
async def test_keep_in_memory_false_unloads_after_batch(self):
|
||||
self._seed()
|
||||
|
||||
_, events = await self._start("model_type=gguf&keep_in_memory=false")
|
||||
|
||||
self.assertEqual(events[-1]["model_loaded"], False)
|
||||
self.assertEqual(self.service.unload_calls, 1)
|
||||
|
||||
async def test_model_not_downloaded_is_an_error_event(self):
|
||||
self.service.downloaded = False
|
||||
|
||||
_, events = await self._start()
|
||||
|
||||
self.assertEqual(events[-1]["type"], "error")
|
||||
self.assertEqual(self.service.load_calls, [])
|
||||
|
||||
async def test_load_failure_is_an_error_event_without_details(self):
|
||||
self.service.load_error = RuntimeError("/srv/models/secret.gguf is corrupt")
|
||||
|
||||
_, events = await self._start()
|
||||
|
||||
self.assertEqual(events[-1]["type"], "error")
|
||||
self.assertNotIn("/srv/models", events[-1]["message"])
|
||||
|
||||
async def test_non_numeric_threshold_is_400(self):
|
||||
resp = await self.client.request(
|
||||
"POST", "/prompt_manager/autotag/start?general_threshold=abc"
|
||||
)
|
||||
self.assertEqual(resp.status, 400)
|
||||
|
||||
async def test_invalid_json_body_is_400(self):
|
||||
resp = await self.client.request(
|
||||
"POST",
|
||||
"/prompt_manager/autotag/start",
|
||||
data="{not json",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
self.assertEqual(resp.status, 400)
|
||||
|
||||
async def test_client_disconnect_stops_the_batch_and_unloads(self):
|
||||
self._seed()
|
||||
real_write = web.StreamResponse.write
|
||||
state = {"writes": 0}
|
||||
|
||||
async def flaky_write(response, data):
|
||||
state["writes"] += 1
|
||||
if b"Processing " in data:
|
||||
raise ConnectionResetError("Cannot write to closing transport")
|
||||
return await real_write(response, data)
|
||||
|
||||
with patch.object(web.StreamResponse, "write", flaky_write):
|
||||
try:
|
||||
resp = await self.client.request(
|
||||
"POST", "/prompt_manager/autotag/start?keep_in_memory=false"
|
||||
)
|
||||
await resp.read()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.assertEqual(len(self.service.generate_calls), 1)
|
||||
self.assertEqual(self.service.unload_calls, 1)
|
||||
|
||||
|
||||
class TestAutotagModelsAndDownloadErrors(AutotagAPITestCase):
|
||||
|
||||
async def test_models_error_is_500(self):
|
||||
@@ -834,7 +632,15 @@ class TestAutotagSingleEdgeCases(AutotagAPITestCase):
|
||||
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertEqual(self.service.load_calls, [("wd14-vit", True)])
|
||||
self.assertEqual(self.service.custom_prompt, "terse")
|
||||
# #379: the request's prompt is for this call only.
|
||||
self.assertEqual(self.service.custom_prompt, "")
|
||||
resp = await self._post_json(
|
||||
"/prompt_manager/autotag/single",
|
||||
{"path": str(image), "model_type": "gguf", "prompt": "terse"},
|
||||
)
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertEqual(self.service.generate_calls[-1][1], "terse")
|
||||
self.assertEqual(self.service.custom_prompt, "")
|
||||
|
||||
async def test_image_id_without_stored_path_is_404(self):
|
||||
prompt_id = self._save_prompt()
|
||||
@@ -850,64 +656,6 @@ class TestAutotagSingleEdgeCases(AutotagAPITestCase):
|
||||
self.assertEqual(resp.status, 404)
|
||||
|
||||
|
||||
class TestApplyAutotag(AutotagAPITestCase):
|
||||
|
||||
async def test_missing_prompt_id_is_400(self):
|
||||
resp = await self._post_json("/prompt_manager/autotag/apply", {"tags": ["a"]})
|
||||
self.assertEqual(resp.status, 400)
|
||||
|
||||
async def test_no_tags_is_a_noop(self):
|
||||
resp = await self._post_json("/prompt_manager/autotag/apply", {"prompt_id": 1})
|
||||
data = await resp.json()
|
||||
self.assertTrue(data["success"])
|
||||
self.assertEqual(data["message"], "No tags to apply")
|
||||
|
||||
async def test_unknown_prompt_is_404(self):
|
||||
resp = await self._post_json(
|
||||
"/prompt_manager/autotag/apply", {"prompt_id": 999, "tags": ["a"]}
|
||||
)
|
||||
self.assertEqual(resp.status, 404)
|
||||
|
||||
async def test_tags_are_merged_without_duplicates(self):
|
||||
prompt_id = self._save_prompt(tags=["cat"])
|
||||
|
||||
resp = await self._post_json(
|
||||
"/prompt_manager/autotag/apply",
|
||||
{"prompt_id": prompt_id, "tags": ["cat", "dog"]},
|
||||
)
|
||||
|
||||
data = await resp.json()
|
||||
self.assertEqual(data["added_tags"], ["dog"])
|
||||
self.assertEqual(data["total_tags"], 2)
|
||||
self.assertEqual(
|
||||
self.api.db.get_prompt_by_id(prompt_id)["tags"], ["cat", "dog"]
|
||||
)
|
||||
|
||||
async def test_string_tags_from_db_are_split(self):
|
||||
prompt_id = self._save_prompt()
|
||||
with patch.object(
|
||||
self.api.db,
|
||||
"get_prompt_by_id",
|
||||
return_value={"id": prompt_id, "tags": "cat, dog"},
|
||||
):
|
||||
resp = await self._post_json(
|
||||
"/prompt_manager/autotag/apply",
|
||||
{"prompt_id": prompt_id, "tags": ["dog", "owl"]},
|
||||
)
|
||||
data = await resp.json()
|
||||
self.assertEqual(data["added_tags"], ["owl"])
|
||||
self.assertEqual(data["total_tags"], 3)
|
||||
|
||||
async def test_db_error_is_500(self):
|
||||
with patch.object(
|
||||
self.api.db, "get_prompt_by_id", side_effect=RuntimeError("db")
|
||||
):
|
||||
resp = await self._post_json(
|
||||
"/prompt_manager/autotag/apply", {"prompt_id": 1, "tags": ["a"]}
|
||||
)
|
||||
self.assertEqual(resp.status, 500)
|
||||
|
||||
|
||||
class TestAutotagErrorBodiesHideServerPaths(AutotagAPITestCase):
|
||||
"""Every autotag handler reports exceptions through the shared public helper."""
|
||||
|
||||
@@ -932,9 +680,12 @@ class TestAutotagErrorBodiesHideServerPaths(AutotagAPITestCase):
|
||||
await self._assert_hidden(resp)
|
||||
|
||||
async def test_apply_error(self):
|
||||
with patch.object(self.api.db, "get_prompt_by_id", side_effect=self._locked()):
|
||||
image = self._make_image(self.output_dir)
|
||||
digest = hashlib.sha256(image.read_bytes()).hexdigest()
|
||||
with patch.object(self.api.db, "add_image_tags", side_effect=self._locked()):
|
||||
resp = await self._post_json(
|
||||
"/prompt_manager/autotag/apply", {"prompt_id": 1, "tags": ["a"]}
|
||||
"/prompt_manager/autotag/apply",
|
||||
{"path": str(image), "sha256": digest, "tags": ["a"]},
|
||||
)
|
||||
await self._assert_hidden(resp)
|
||||
|
||||
@@ -1008,10 +759,13 @@ class TestLegacyRoutesShareTheModelGate(AutotagAPITestCase):
|
||||
"POST", "/prompt_manager/autotag/download/gguf"
|
||||
)
|
||||
)
|
||||
await self._busy(
|
||||
await self.client.request(
|
||||
"POST", "/prompt_manager/autotag/start?model_type=gguf"
|
||||
)
|
||||
# The legacy batch is read as a stream: one error frame (#379).
|
||||
resp = await self.client.request(
|
||||
"POST", "/prompt_manager/autotag/start?model_type=gguf"
|
||||
)
|
||||
self.assertEqual(
|
||||
self._sse_events(await resp.text()),
|
||||
[{"type": "error", "status": "error", "message": MODEL_BUSY_ERROR}],
|
||||
)
|
||||
await self._busy(
|
||||
await self.client.request("POST", "/prompt_manager/autotag/unload")
|
||||
@@ -1026,7 +780,7 @@ class TestLegacyRoutesShareTheModelGate(AutotagAPITestCase):
|
||||
self.assertEqual(self.service.unload_calls, 0)
|
||||
self.assertEqual(self.service.get_loaded_model_type(), "wd14-vit")
|
||||
|
||||
# A refused start released the batch lock: the next start runs.
|
||||
# A refused start left no job behind: the next start runs.
|
||||
resp = await self.client.request(
|
||||
"POST", "/prompt_manager/autotag/start?model_type=gguf"
|
||||
)
|
||||
@@ -1238,7 +992,7 @@ if __name__ == "__main__":
|
||||
|
||||
|
||||
class TestGalleryReviewContract(AutotagAPITestCase):
|
||||
async def test_root_relative_gallery_targets_generate_and_apply_to_their_prompt(
|
||||
async def test_root_relative_gallery_targets_generate_and_apply_to_their_image(
|
||||
self,
|
||||
):
|
||||
second = self.tmp_root / "second-output"
|
||||
@@ -1255,16 +1009,22 @@ class TestGalleryReviewContract(AutotagAPITestCase):
|
||||
self.assertEqual(response.status, 200)
|
||||
data = await response.json()
|
||||
self.assertEqual(data["prompt_id"], pid)
|
||||
self.assertEqual(
|
||||
self.service.generate_calls[-1][0], str((root / "same.png").resolve())
|
||||
)
|
||||
response = await self._post_json(
|
||||
"/prompt_manager/autotag/apply",
|
||||
{"prompt_id": data["prompt_id"], "tags": data["tags"]},
|
||||
{
|
||||
"path": "same.png",
|
||||
"root_index": index,
|
||||
"sha256": data["sha256"],
|
||||
"tags": data["tags"],
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
# The image's own tags; the prompt is never written (#379).
|
||||
self.assertEqual(self.api.db.get_prompt_by_id(pid)["tags"], [])
|
||||
locator = os.path.normcase(os.path.realpath(str(root / "same.png")))
|
||||
self.assertEqual(
|
||||
set(self.api.db.get_prompt_by_id(pid)["tags"]), set(data["tags"])
|
||||
self.api.db.read_image_tags(locator, data["sha256"])["tags"],
|
||||
data["tags"],
|
||||
)
|
||||
|
||||
async def test_gallery_unlinked_image_returns_no_prompt_id(self):
|
||||
@@ -2058,108 +1818,6 @@ class TestTagOutputImagesJob(BatchJobTestCase):
|
||||
self.assertTrue(inside)
|
||||
|
||||
|
||||
# The legacy batch's per-image frames over BatchJobTestCase's library, newest
|
||||
# first, with every throttle check passing (pinned from main's generator).
|
||||
GOLDEN_LEGACY_ROWS = [
|
||||
{
|
||||
"type": "progress",
|
||||
"progress": 24,
|
||||
"status": "Processing 1/6...",
|
||||
"processed": 1,
|
||||
"tagged": 1,
|
||||
"skipped": 0,
|
||||
"skipped_outside_roots": 0,
|
||||
},
|
||||
{
|
||||
"type": "progress",
|
||||
"progress": 38,
|
||||
"status": "Skipping 2/6 (outside allowed directories)...",
|
||||
"processed": 1,
|
||||
"tagged": 1,
|
||||
"skipped": 1,
|
||||
"skipped_outside_roots": 1,
|
||||
},
|
||||
{
|
||||
"type": "progress",
|
||||
"progress": 52,
|
||||
"status": "Skipping 3/6 (file missing)...",
|
||||
"processed": 1,
|
||||
"tagged": 1,
|
||||
"skipped": 2,
|
||||
"skipped_outside_roots": 1,
|
||||
},
|
||||
{
|
||||
"type": "progress",
|
||||
"progress": 66,
|
||||
"status": "Processing 4/6...",
|
||||
"processed": 2,
|
||||
"tagged": 2,
|
||||
"skipped": 2,
|
||||
"skipped_outside_roots": 1,
|
||||
},
|
||||
{
|
||||
"type": "progress",
|
||||
"progress": 80,
|
||||
"status": "Skipping 5/6 (prompt already processed)...",
|
||||
"processed": 2,
|
||||
"tagged": 2,
|
||||
"skipped": 3,
|
||||
"skipped_outside_roots": 1,
|
||||
},
|
||||
{
|
||||
"type": "progress",
|
||||
"progress": 95,
|
||||
"status": "Skipping 6/6 (already tagged)...",
|
||||
"processed": 2,
|
||||
"tagged": 2,
|
||||
"skipped": 4,
|
||||
"skipped_outside_roots": 1,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class TestLegacyBatchFrames(BatchJobTestCase):
|
||||
|
||||
async def test_legacy_batch_events_unchanged(self):
|
||||
"""Golden frames of the legacy batch over the seeded library."""
|
||||
self._seed()
|
||||
ticks = iter(range(1000)) # every throttle check sees a second pass
|
||||
with patch("py.api.autotag_routes._monotonic", side_effect=lambda: next(ticks)):
|
||||
resp = await self.client.post(
|
||||
"/prompt_manager/autotag/start?model_type=wd14-vit&skip_tagged=true"
|
||||
)
|
||||
frames = self._sse_events(await resp.text()) # the stream runs here
|
||||
self.assertEqual(
|
||||
frames,
|
||||
[
|
||||
{"type": "progress", "progress": 0, "status": "Loading model..."},
|
||||
{
|
||||
"type": "progress",
|
||||
"progress": 5,
|
||||
"status": "Model loaded. Fetching all images from database...",
|
||||
},
|
||||
{
|
||||
"type": "progress",
|
||||
"progress": 10,
|
||||
"status": "Found 6 images. Processing...",
|
||||
},
|
||||
*GOLDEN_LEGACY_ROWS,
|
||||
{
|
||||
"type": "complete",
|
||||
"progress": 100,
|
||||
"processed": 2,
|
||||
"tagged": 2,
|
||||
"skipped": 4,
|
||||
"errors": 0,
|
||||
"skipped_outside_roots": 1,
|
||||
"status": "Complete",
|
||||
"model_status": "Model kept in memory",
|
||||
"model_loaded": True,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class TestTagOutputImagesEdges(BatchJobTestCase):
|
||||
|
||||
async def test_cancel_during_backup_loads_no_model(self):
|
||||
|
||||
@@ -2335,3 +2335,162 @@ class TestWorkspaceImageTags(ImageAPITestCase):
|
||||
)
|
||||
]
|
||||
self.assertFalse([step for step in plan if step.startswith("SCAN")], plan)
|
||||
|
||||
|
||||
class TestListingFilterSelection(ImageAPITestCase):
|
||||
"""#392: "Select all N matching" resolves the listing filter on the server."""
|
||||
|
||||
async def tearDownAsync(self):
|
||||
self.api.db.close_all()
|
||||
await super().tearDownAsync()
|
||||
|
||||
def media(self, name, mtime=100):
|
||||
target = self.output_dir / name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
make_png(target)
|
||||
os.utime(target, (mtime, mtime))
|
||||
return target
|
||||
|
||||
async def listing(self, **params):
|
||||
response = await self.client.get(
|
||||
"/prompt_manager/images/output",
|
||||
params={"mode": "workspace", "limit": 500, **params},
|
||||
)
|
||||
return (await response.json())["images"]
|
||||
|
||||
async def test_resolve_lists_every_match_in_listing_order_beyond_a_page(self):
|
||||
for number in range(7):
|
||||
self.media(f"bulk/{number:02}.png", 100 + number)
|
||||
self.media("other/x.png", 500)
|
||||
from py.api.images import parse_listing_filter
|
||||
|
||||
params = parse_listing_filter({"subfolder": "bulk", "sort": "newest"})
|
||||
resolved = await self.api.resolve_listing_filter(params, cap=100)
|
||||
|
||||
listed = await self.listing(subfolder="bulk", sort="newest")
|
||||
self.assertEqual(
|
||||
[list(target) for target in resolved.targets],
|
||||
[[i["root_index"], i["root_scope"], i["path"]] for i in listed],
|
||||
)
|
||||
self.assertEqual(len(resolved.targets), 7)
|
||||
self.assertFalse(resolved.truncated)
|
||||
|
||||
async def test_resolve_reports_linked_positives_and_unlinked_count(self):
|
||||
first = self.media("a.png", 300)
|
||||
second = self.media("b.png", 200)
|
||||
self.media("c.png", 100)
|
||||
prompt = self._save_prompt("shared positive")
|
||||
self.api.db.link_image_to_prompt(prompt, str(first))
|
||||
self.api.db.link_image_to_prompt(prompt, str(second))
|
||||
from py.api.images import parse_listing_filter
|
||||
|
||||
resolved = await self.api.resolve_listing_filter(parse_listing_filter({}), 100)
|
||||
|
||||
self.assertEqual(resolved.positive_ids, [prompt])
|
||||
self.assertEqual(resolved.unlinked, 1)
|
||||
|
||||
async def test_resolve_marks_more_than_the_cap_as_truncated(self):
|
||||
for number in range(4):
|
||||
self.media(f"{number}.png", 100 + number)
|
||||
from py.api.images import parse_listing_filter
|
||||
|
||||
resolved = await self.api.resolve_listing_filter(parse_listing_filter({}), 3)
|
||||
|
||||
self.assertTrue(resolved.truncated)
|
||||
|
||||
def test_parse_listing_filter_rejects_anything_but_listing_strings(self):
|
||||
from py.api.images import parse_listing_filter
|
||||
|
||||
self.assertEqual(
|
||||
parse_listing_filter({"q": "cat", "tags": "a,b", "root": "0"}),
|
||||
{"q": "cat", "tags": "a,b", "root": "0"},
|
||||
)
|
||||
for bad in (
|
||||
None,
|
||||
[],
|
||||
"q=cat",
|
||||
{"mode": "workspace"},
|
||||
{"cursor": "x"},
|
||||
{"path": "a.png"},
|
||||
{"q": 1},
|
||||
{"q": "x" * 2049},
|
||||
{"sort": "sideways"},
|
||||
{"media": "audio"},
|
||||
{"favorites": "yes"},
|
||||
{"subfolder": "../up"},
|
||||
{"root": "-1"},
|
||||
):
|
||||
with self.subTest(bad=bad):
|
||||
with self.assertRaises(ValueError):
|
||||
params = parse_listing_filter(bad)
|
||||
# Root bounds need the roots: the resolver checks them.
|
||||
from py.api.images import _workspace_root
|
||||
|
||||
_workspace_root(params, [self.output_dir])
|
||||
|
||||
async def test_bulk_category_takes_the_listing_filter(self):
|
||||
first = self.media("keep/a.png", 300)
|
||||
second = self.media("keep/b.png", 200)
|
||||
self.media("keep/c.png", 100)
|
||||
other = self.media("skip/d.png", 400)
|
||||
prompt = self._save_prompt("in the filter")
|
||||
outside = self._save_prompt("outside the filter")
|
||||
self.api.db.link_image_to_prompt(prompt, str(first))
|
||||
self.api.db.link_image_to_prompt(prompt, str(second))
|
||||
self.api.db.link_image_to_prompt(outside, str(other))
|
||||
|
||||
response = await self.client.post(
|
||||
"/prompt_manager/bulk/category",
|
||||
json={"images_filter": {"subfolder": "keep"}, "category": "Kept"},
|
||||
)
|
||||
data = await response.json()
|
||||
|
||||
self.assertEqual(response.status, 200, data)
|
||||
self.assertEqual(
|
||||
(data["updated_count"], data["prompt_count"], data["skipped"]), (1, 1, 1)
|
||||
)
|
||||
self.assertEqual(self.api.db.get_prompt_by_id(prompt)["category"], "Kept")
|
||||
self.assertNotEqual(self.api.db.get_prompt_by_id(outside)["category"], "Kept")
|
||||
|
||||
async def test_bulk_category_filter_with_no_linked_image_changes_nothing(self):
|
||||
self.media("a.png")
|
||||
|
||||
response = await self.client.post(
|
||||
"/prompt_manager/bulk/category",
|
||||
json={"images_filter": {}, "category": "Kept"},
|
||||
)
|
||||
data = await response.json()
|
||||
|
||||
self.assertEqual(response.status, 200, data)
|
||||
self.assertEqual(
|
||||
(data["updated_count"], data["prompt_count"], data["skipped"]), (0, 0, 1)
|
||||
)
|
||||
|
||||
async def test_bulk_category_refuses_a_bad_or_doubled_filter(self):
|
||||
self.media("a.png")
|
||||
prompt = self._save_prompt("p")
|
||||
for body in (
|
||||
{"images_filter": {"sort": "sideways"}, "category": "x"},
|
||||
{"images_filter": "all", "category": "x"},
|
||||
{"images_filter": {}, "prompt_ids": [prompt], "category": "x"},
|
||||
{"images_filter": {"root": "9"}, "category": "x"},
|
||||
):
|
||||
with self.subTest(body=body):
|
||||
response = await self.client.post(
|
||||
"/prompt_manager/bulk/category", json=body
|
||||
)
|
||||
self.assertEqual(response.status, 400, await response.text())
|
||||
|
||||
async def test_bulk_category_refuses_a_renumbered_source(self):
|
||||
self.media("a.png")
|
||||
|
||||
response = await self.client.post(
|
||||
"/prompt_manager/bulk/category",
|
||||
json={
|
||||
"images_filter": {"root": "0", "root_scope": "f" * 64},
|
||||
"category": "x",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status, 409)
|
||||
self.assertEqual((await response.json())["code"], "source-changed")
|
||||
|
||||
@@ -0,0 +1,617 @@
|
||||
"""The legacy pages and the Tag image dialog tag images, never prompts (#379, PR 2).
|
||||
|
||||
- ``POST /autotag/start`` (legacy admin and gallery batch) starts the
|
||||
``autotag`` job titled "Tag output images (legacy page)" and relays it as
|
||||
the legacy frames; closing the stream cancels it.
|
||||
- ``POST /autotag/single`` suggests on a capture with the saved cap and
|
||||
answers the captured ``sha256``; ``existing: "skip"`` answers
|
||||
``already_tagged`` for an auto-tagged image without the model gate.
|
||||
- ``POST /autotag/apply`` unions into that image's tags and marks it; a
|
||||
``prompt_id`` body is refused.
|
||||
- A Tag image dialog save after Generate suggestions marks the image.
|
||||
|
||||
No real model runs: the per-image fake from test_autotag_batch answers.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
from unittest.mock import patch
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
try:
|
||||
from test_autotag_batch import BatchTestCase, _absolute, _strings
|
||||
except ImportError:
|
||||
from tests.test_autotag_batch import BatchTestCase, _absolute, _strings
|
||||
|
||||
from database.execution_history_schema import rotate_history_epoch
|
||||
from py import model_gate
|
||||
from py.api import autotag_routes, image_tag_jobs
|
||||
from py.config import AutotagConfig
|
||||
|
||||
START = "/prompt_manager/autotag/start"
|
||||
SINGLE = "/prompt_manager/autotag/single"
|
||||
APPLY = "/prompt_manager/autotag/apply"
|
||||
SUGGEST = "/prompt_manager/image-annotations/suggest"
|
||||
OBSERVE = "/prompt_manager/image-annotations/observe"
|
||||
TAGS = "/prompt_manager/image-annotations/tags"
|
||||
WAIT = 30.0
|
||||
LEGACY_TITLE = "Tag output images (legacy page)"
|
||||
|
||||
|
||||
def frames_of(text):
|
||||
return [
|
||||
json.loads(line[len("data: ") :])
|
||||
for line in text.splitlines()
|
||||
if line.startswith("data: ")
|
||||
]
|
||||
|
||||
|
||||
class LegacyTestCase(BatchTestCase):
|
||||
async def legacy(self, query="model_type=wd14-vit&skip_tagged=true"):
|
||||
response = await self.client.post(f"{START}?{query}")
|
||||
return response, frames_of(await response.text())
|
||||
|
||||
def digest(self, path):
|
||||
return hashlib.sha256((self.output_dir / path).read_bytes()).hexdigest()
|
||||
|
||||
async def single(self, body):
|
||||
response = await self.client.post(SINGLE, json=body)
|
||||
return response.status, await response.json()
|
||||
|
||||
async def apply(self, body):
|
||||
response = await self.client.post(APPLY, json=body)
|
||||
return response.status, await response.json()
|
||||
|
||||
def snapshot_prompts(self):
|
||||
conn = sqlite3.connect(self._temp_db.name)
|
||||
try:
|
||||
return [
|
||||
conn.execute(f"SELECT * FROM {t} ORDER BY 1,2").fetchall()
|
||||
for t in ("prompts", "prompt_tags")
|
||||
]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestLegacyBatch(LegacyTestCase):
|
||||
async def test_the_legacy_batch_tags_each_image_and_relays_frames(self):
|
||||
prompt = self.prompt("one prompt", tags=["beach"])
|
||||
a = self.image("a.png", (1, 2, 3), sees=["bird"])
|
||||
b = self.image("b.png", (4, 5, 6), sees=["waves"])
|
||||
self.link(prompt, a)
|
||||
self.link(prompt, b)
|
||||
before = self.snapshot_prompts()
|
||||
ticks = iter(range(1000))
|
||||
with patch.object(
|
||||
autotag_routes, "_monotonic", side_effect=lambda: next(ticks)
|
||||
):
|
||||
response, frames = await self.legacy()
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(self.stored("a.png")["tags"], ["bird"])
|
||||
self.assertEqual(self.stored("b.png")["tags"], ["waves"])
|
||||
self.assertEqual(self.snapshot_prompts(), before)
|
||||
phases = [f["status"] for f in frames if f.get("progress") in (0, 5, 8)]
|
||||
self.assertEqual(
|
||||
phases,
|
||||
sorted(
|
||||
phases,
|
||||
key=[
|
||||
"Taking a safety backup…",
|
||||
"Loading model…",
|
||||
"Listing output images…",
|
||||
].index,
|
||||
),
|
||||
)
|
||||
self.assertIn("Listing output images…", phases)
|
||||
rows = [f for f in frames if f["type"] == "progress" and "processed" in f]
|
||||
self.assertTrue(rows)
|
||||
for row in rows:
|
||||
self.assertEqual(
|
||||
set(row),
|
||||
{"type", "progress", "status", "processed", "tagged", "skipped"},
|
||||
)
|
||||
self.assertTrue(10 <= row["progress"] <= 95, row)
|
||||
self.assertEqual(
|
||||
frames[-1],
|
||||
{
|
||||
"type": "complete",
|
||||
"progress": 100,
|
||||
"processed": 2,
|
||||
"tagged": 2,
|
||||
"skipped": 0,
|
||||
"errors": 0,
|
||||
"skipped_outside_roots": 0,
|
||||
"status": "Complete",
|
||||
"model_status": "Model kept in memory",
|
||||
"model_loaded": True,
|
||||
},
|
||||
)
|
||||
job = self.api._jobs.list()[0]
|
||||
self.assertEqual(
|
||||
(job.title, job.kind, job.state), (LEGACY_TITLE, "autotag", "finished")
|
||||
)
|
||||
self.assertEqual([s for s in _strings(frames) if _absolute(s)], [])
|
||||
|
||||
# Skip: the marked images are skipped before the model runs.
|
||||
self.tagger.calls.clear()
|
||||
_, again = await self.legacy()
|
||||
self.assertEqual(again[-1]["skipped"], 2)
|
||||
self.assertEqual(again[-1]["processed"], 0)
|
||||
self.assertEqual(self.tagger.calls, [])
|
||||
|
||||
async def test_no_still_images_and_keep_in_memory_false(self):
|
||||
_, frames = await self.legacy("model_type=wd14-vit&keep_in_memory=false")
|
||||
self.assertEqual(frames[-1]["status"], "No still images found")
|
||||
self.assertEqual(frames[-1]["model_loaded"], False)
|
||||
self.assertEqual(frames[-1]["model_status"], "Model unloaded")
|
||||
self.assertEqual(self.tagger.unload_calls, 1)
|
||||
|
||||
async def test_language_model_options_come_from_the_page_or_the_template(self):
|
||||
self.image("a.png", (1, 1, 1))
|
||||
await self.legacy("model_type=gguf&prompt=Tags%20only")
|
||||
self.assertEqual(self.tagger.calls[-1]["prompt"], "Tags only")
|
||||
self.assertEqual(self.tagger.calls[-1]["limit"], 10)
|
||||
self.image("b.png", (2, 2, 2))
|
||||
await self.relist()
|
||||
await self.legacy("model_type=gguf&prompt=%20")
|
||||
self.assertEqual(self.tagger.calls[-1]["prompt"], AutotagConfig.TEMPLATE)
|
||||
self.assertEqual(self.tagger.custom_prompt, "legacy prompt")
|
||||
|
||||
async def test_each_refusal_is_one_error_frame(self):
|
||||
self.tagger.downloaded["wd14-vit"] = False
|
||||
_, frames = await self.legacy()
|
||||
self.assertEqual(len(frames), 1)
|
||||
self.assertEqual(frames[0]["type"], "error")
|
||||
self.assertEqual(frames[0]["status"], "error")
|
||||
self.assertIn("WD14 ViT is not downloaded", frames[0]["message"])
|
||||
self.tagger.downloaded["wd14-vit"] = True
|
||||
|
||||
_, frames = await self.legacy("model_type=wd14-vit&general_threshold=2")
|
||||
self.assertEqual(
|
||||
frames,
|
||||
[
|
||||
{
|
||||
"type": "error",
|
||||
"status": "error",
|
||||
"message": "general_threshold must be between 0 and 1",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
owner = model_gate.new_owner("image-annotations")
|
||||
self.assertTrue(model_gate.try_acquire(owner))
|
||||
try:
|
||||
_, frames = await self.legacy()
|
||||
finally:
|
||||
model_gate.release(owner)
|
||||
self.assertEqual(frames[0]["message"], autotag_routes.MODEL_BUSY_ERROR)
|
||||
self.assertEqual(self.api._jobs.list(), [])
|
||||
self.assertIsNone(model_gate.holder())
|
||||
|
||||
response = await self.client.post(f"{START}?general_threshold=abc")
|
||||
self.assertEqual(response.status, 400)
|
||||
|
||||
async def test_busy_jobs_refuse_the_legacy_batch(self):
|
||||
self.image("a.png", (1, 1, 1))
|
||||
entered, proceed = threading.Event(), threading.Event()
|
||||
self.tagger.block = (entered, proceed)
|
||||
status, data = await self.start_batch()
|
||||
self.assertEqual(status, 202, data)
|
||||
job = self.api._jobs.get(data["job"]["id"])
|
||||
try:
|
||||
await self.wait_for(entered)
|
||||
_, frames = await self.legacy()
|
||||
self.assertEqual(
|
||||
frames,
|
||||
[
|
||||
{
|
||||
"type": "error",
|
||||
"status": "error",
|
||||
"message": "Tag output images already running",
|
||||
}
|
||||
],
|
||||
)
|
||||
finally:
|
||||
self.tagger.block = None
|
||||
proceed.set()
|
||||
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
|
||||
|
||||
# A second legacy batch keeps its JSON 409.
|
||||
self.image("b.png", (2, 2, 2))
|
||||
await self.relist()
|
||||
entered, proceed = threading.Event(), threading.Event()
|
||||
self.tagger.block = (entered, proceed)
|
||||
first = asyncio.ensure_future(
|
||||
self.legacy("model_type=wd14-vit&skip_tagged=false")
|
||||
)
|
||||
try:
|
||||
await self.wait_for(entered)
|
||||
second = await self.client.post(f"{START}?model_type=wd14-vit")
|
||||
self.assertEqual(second.status, 409)
|
||||
self.assertEqual(
|
||||
await second.json(),
|
||||
{"success": False, "error": "Auto-tagging is already running"},
|
||||
)
|
||||
finally:
|
||||
self.tagger.block = None
|
||||
proceed.set()
|
||||
_, frames = await first
|
||||
self.assertEqual(frames[-1]["type"], "complete")
|
||||
|
||||
async def test_a_legacy_start_that_loses_the_race_gets_the_json_409(self):
|
||||
# Two starts can both pass the title check before either registers its
|
||||
# job; the loser's busy refusal names the legacy batch and answers the
|
||||
# same JSON 409 as a second legacy batch.
|
||||
refusal = image_tag_jobs.Refusal(
|
||||
409,
|
||||
"busy",
|
||||
f"{autotag_routes.LEGACY_BATCH_TITLE} already running",
|
||||
web.json_response({}, status=409),
|
||||
)
|
||||
|
||||
async def refused(**_kwargs):
|
||||
return None, refusal
|
||||
|
||||
with patch.object(self.api, "_start_autotag", side_effect=refused):
|
||||
response = await self.client.post(f"{START}?model_type=wd14-vit")
|
||||
self.assertEqual(response.status, 409)
|
||||
self.assertEqual(
|
||||
await response.json(),
|
||||
{"success": False, "error": "Auto-tagging is already running"},
|
||||
)
|
||||
|
||||
async def test_closing_the_stream_cancels_the_job(self):
|
||||
for n in range(3):
|
||||
self.image(f"{n}.png", (n, n, n))
|
||||
real_write = web.StreamResponse.write
|
||||
|
||||
async def flaky_write(response, data):
|
||||
if b'"processed"' in data:
|
||||
raise ConnectionResetError("Cannot write to closing transport")
|
||||
return await real_write(response, data)
|
||||
|
||||
with patch.object(web.StreamResponse, "write", flaky_write):
|
||||
try:
|
||||
response = await self.client.post(f"{START}?model_type=wd14-vit")
|
||||
await response.read()
|
||||
except Exception: # nosec B110 - the client sees the dropped stream
|
||||
pass
|
||||
job = self.api._jobs.list()[0]
|
||||
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
|
||||
self.assertEqual(job.state, "cancelled")
|
||||
self.assertLess(job.counters["tagged"], 3)
|
||||
|
||||
async def test_a_quiet_job_repeats_its_last_frame(self):
|
||||
self.image("a.png", (1, 1, 1))
|
||||
entered, proceed = threading.Event(), threading.Event()
|
||||
self.tagger.block = (entered, proceed)
|
||||
with patch.object(autotag_routes, "LEGACY_REPEAT_AFTER", 0.05):
|
||||
run = asyncio.ensure_future(self.legacy())
|
||||
await self.wait_for(entered)
|
||||
await asyncio.sleep(0.3)
|
||||
self.tagger.block = None
|
||||
proceed.set()
|
||||
_, frames = await run
|
||||
statuses = [f.get("status") for f in frames]
|
||||
repeated = [s for s in statuses if s and statuses.count(s) > 1]
|
||||
self.assertTrue(repeated, statuses)
|
||||
self.assertEqual(frames[-1]["type"], "complete")
|
||||
|
||||
async def test_a_cancel_from_activity_ends_with_a_cancelled_frame(self):
|
||||
for n in range(2):
|
||||
self.image(f"{n}.png", (n, n, n))
|
||||
entered, proceed = threading.Event(), threading.Event()
|
||||
self.tagger.block = (entered, proceed)
|
||||
run = asyncio.ensure_future(self.legacy())
|
||||
try:
|
||||
await self.wait_for(entered)
|
||||
job = self.api._jobs.active("autotag")
|
||||
await self.client.post(f"/prompt_manager/jobs/{job.id}/cancel")
|
||||
finally:
|
||||
self.tagger.block = None
|
||||
proceed.set()
|
||||
_, frames = await run
|
||||
self.assertEqual(
|
||||
frames[-1],
|
||||
{
|
||||
"type": "cancelled",
|
||||
"message": "Cancelled. Tags already added were kept.",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TestLegacySingleAndApply(LegacyTestCase):
|
||||
async def test_single_suggests_on_a_capture_and_apply_tags_only_that_image(self):
|
||||
prompt = self.prompt("shared prompt", tags=["keep"])
|
||||
a = self.image("a.png", (1, 2, 3), sees=["bird", "sky"])
|
||||
b = self.image("b.png", (4, 5, 6), sees=["cat"])
|
||||
image_a = self.link(prompt, a)
|
||||
self.link(prompt, b)
|
||||
self.saved_set("b.png", b.read_bytes(), ["hand"])
|
||||
before = self.snapshot_prompts()
|
||||
status, data = await self.single(
|
||||
{"image_id": image_a, "model_type": "wd14-vit"}
|
||||
)
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertEqual(data["tags"], ["bird", "sky"])
|
||||
self.assertEqual(data["sha256"], self.digest("a.png"))
|
||||
self.assertEqual(data["image_tags"], [])
|
||||
self.assertEqual(data["prompt_id"], prompt)
|
||||
self.assertEqual(self.tagger.custom_prompt, "legacy prompt")
|
||||
# Inference read the private capture, not the file.
|
||||
self.assertNotIn(str(self.output_dir), self.tagger.read_paths[-1])
|
||||
status, applied = await self.apply(
|
||||
{"image_id": image_a, "sha256": data["sha256"], "tags": ["bird"]}
|
||||
)
|
||||
self.assertEqual(
|
||||
(status, applied),
|
||||
(200, {"success": True, "added_tags": ["bird"], "total_tags": 1}),
|
||||
)
|
||||
self.assertEqual(self.stored("a.png")["tags"], ["bird"])
|
||||
self.assertEqual(self.stored("b.png")["tags"], ["hand"])
|
||||
self.assertEqual(self.snapshot_prompts(), before)
|
||||
self.assertEqual(self.marked(), {self.locator("a.png")})
|
||||
|
||||
async def test_skip_answers_already_tagged_without_the_gate(self):
|
||||
a = self.image("a.png", (1, 2, 3), sees=["bird"])
|
||||
await self.run_batch()
|
||||
self.tagger.calls.clear()
|
||||
owner = model_gate.new_owner("image-tag-job")
|
||||
self.assertTrue(model_gate.try_acquire(owner))
|
||||
try:
|
||||
status, data = await self.single(
|
||||
{
|
||||
"path": "a.png",
|
||||
"root_index": 0,
|
||||
"existing": "skip",
|
||||
"model_type": "wd14-vit",
|
||||
}
|
||||
)
|
||||
finally:
|
||||
model_gate.release(owner)
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertEqual(data["already_tagged"], True)
|
||||
self.assertEqual(data["image_tags"], ["bird"])
|
||||
self.assertEqual(self.tagger.calls, [])
|
||||
# Hand tags alone do not count; merge always suggests.
|
||||
b = self.image("b.png", (9, 9, 9))
|
||||
self.saved_set("b.png", b.read_bytes(), ["hand"])
|
||||
status, data = await self.single(
|
||||
{"path": str(b), "existing": "skip", "model_type": "wd14-vit"}
|
||||
)
|
||||
self.assertNotIn("already_tagged", data)
|
||||
self.assertEqual(data["image_tags"], ["hand"])
|
||||
status, data = await self.single(
|
||||
{"path": str(a), "existing": "merge", "model_type": "wd14-vit"}
|
||||
)
|
||||
self.assertNotIn("already_tagged", data)
|
||||
|
||||
async def test_single_uses_the_saved_cap_and_the_refusal_table(self):
|
||||
self.image("a.png", (1, 2, 3))
|
||||
AutotagConfig.SUGGESTION_LIMIT = 3
|
||||
status, data = await self.single(
|
||||
{"path": "a.png", "root_index": 0, "model_type": "wd14-vit"}
|
||||
)
|
||||
self.assertEqual((status, len(data["tags"])), (200, 3))
|
||||
(self.output_dir / "broken.png").write_bytes(b"not an image")
|
||||
await self.relist()
|
||||
status, data = await self.single(
|
||||
{"path": "broken.png", "root_index": 0, "model_type": "wd14-vit"}
|
||||
)
|
||||
self.assertEqual((status, data["code"]), (415, "not-a-still"))
|
||||
self.assertIn(self.locator("broken.png"), self.marked())
|
||||
self.tagger.downloaded["gguf"] = False
|
||||
status, data = await self.single(
|
||||
{"path": "a.png", "root_index": 0, "model_type": "gguf"}
|
||||
)
|
||||
self.assertEqual((status, data["code"]), (409, "model-unavailable"))
|
||||
self.api._capture_store().max_file = 16
|
||||
status, data = await self.single(
|
||||
{"path": "a.png", "root_index": 0, "model_type": "wd14-vit"}
|
||||
)
|
||||
self.assertEqual((status, data["code"]), (413, "too-large"))
|
||||
self.api._capture_store().max_file = 2**20
|
||||
owner = model_gate.new_owner("image-annotations")
|
||||
self.assertTrue(model_gate.try_acquire(owner))
|
||||
try:
|
||||
status, data = await self.single(
|
||||
{"path": "a.png", "root_index": 0, "model_type": "wd14-vit"}
|
||||
)
|
||||
finally:
|
||||
model_gate.release(owner)
|
||||
self.assertEqual((status, data["code"]), (409, "model-busy"))
|
||||
|
||||
async def test_apply_body_and_answer_table(self):
|
||||
a = self.image("a.png", (1, 2, 3))
|
||||
digest = self.digest("a.png")
|
||||
status, data = await self.apply({"prompt_id": 1, "tags": ["a"]})
|
||||
self.assertEqual(
|
||||
(status, data["error"]), (400, "This page is out of date. Reload it.")
|
||||
)
|
||||
status, _ = await self.apply(
|
||||
{"path": "a.png", "root_index": 0, "sha256": "x", "tags": ["a"]}
|
||||
)
|
||||
self.assertEqual(status, 400)
|
||||
status, _ = await self.apply(
|
||||
{"path": "a.png", "root_index": 0, "sha256": digest, "tags": ["bad,name"]}
|
||||
)
|
||||
self.assertEqual(status, 400)
|
||||
status, _ = await self.apply({"path": "a.png", "sha256": digest, "tags": []})
|
||||
self.assertEqual(status, 403) # a bare relative path is not inside a root
|
||||
# An empty reviewed list only marks the image.
|
||||
status, data = await self.apply({"path": str(a), "sha256": digest, "tags": []})
|
||||
self.assertEqual((status, data["added_tags"]), (200, []))
|
||||
self.assertEqual(self.marked(), {self.locator("a.png")})
|
||||
# The file changed since its tags were generated.
|
||||
status, data = await self.apply(
|
||||
{"path": str(a), "sha256": "0" * 64, "tags": ["x"]}
|
||||
)
|
||||
self.assertEqual((status, data["code"]), (409, "file-changed"))
|
||||
# Too many tags.
|
||||
full = self.image("full.png", (7, 7, 7))
|
||||
self.saved_set("full.png", full.read_bytes(), [f"t{i}" for i in range(100)])
|
||||
status, data = await self.apply(
|
||||
{"path": str(full), "sha256": self.digest("full.png"), "tags": ["one_more"]}
|
||||
)
|
||||
self.assertEqual((status, data["code"]), (409, "tag-limit"))
|
||||
# Gone.
|
||||
status, data = await self.apply(
|
||||
{"path": "gone.png", "root_index": 0, "sha256": digest, "tags": ["x"]}
|
||||
)
|
||||
self.assertIn(status, (403, 404))
|
||||
|
||||
async def test_apply_after_a_restore_writes_nothing(self):
|
||||
a = self.image("a.png", (1, 2, 3))
|
||||
digest = self.digest("a.png")
|
||||
real = self.api.db.get_execution_epoch
|
||||
|
||||
def stale_epoch():
|
||||
epoch = real()
|
||||
conn = sqlite3.connect(self._temp_db.name)
|
||||
try:
|
||||
rotate_history_epoch(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
return epoch
|
||||
|
||||
with patch.object(self.api.db, "get_execution_epoch", side_effect=stale_epoch):
|
||||
status, data = await self.apply(
|
||||
{"path": str(a), "sha256": digest, "tags": ["x"]}
|
||||
)
|
||||
self.assertEqual((status, data["code"]), (409, "storage-changed"))
|
||||
self.assertEqual(self.stored("a.png")["tags"], [])
|
||||
|
||||
async def test_image_id_and_path_bodies_reach_the_same_bytes(self):
|
||||
prompt = self.prompt("p")
|
||||
a = self.image("sub/a.png", (1, 2, 3), sees=["x"])
|
||||
image_id = self.link(prompt, a)
|
||||
_, by_id = await self.single({"image_id": image_id, "model_type": "wd14-vit"})
|
||||
_, by_path = await self.single(
|
||||
{"path": "sub/a.png", "root_index": 0, "model_type": "wd14-vit"}
|
||||
)
|
||||
self.assertEqual(by_id["sha256"], by_path["sha256"])
|
||||
status, _ = await self.apply(
|
||||
{"image_id": image_id, "sha256": by_id["sha256"], "tags": ["x"]}
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
status, data = await self.apply(
|
||||
{
|
||||
"path": "sub/a.png",
|
||||
"root_index": 0,
|
||||
"sha256": by_path["sha256"],
|
||||
"tags": ["y"],
|
||||
}
|
||||
)
|
||||
self.assertEqual((status, data["total_tags"]), (200, 2))
|
||||
|
||||
|
||||
class TestDialogMarks(LegacyTestCase):
|
||||
"""DEC4: a Tag image dialog save after Generate suggestions marks the image."""
|
||||
|
||||
def target(self, path):
|
||||
return {
|
||||
"root_index": 0,
|
||||
"root_scope": self.api._gallery_root_scope(self.output_dir, 0),
|
||||
"path": path,
|
||||
}
|
||||
|
||||
async def observe(self, path):
|
||||
response = await self.client.post(OBSERVE, json={"target": self.target(path)})
|
||||
data = await response.json()
|
||||
self.assertEqual(response.status, 200, data)
|
||||
return data
|
||||
|
||||
async def suggest(self, observed, path):
|
||||
response = await self.client.post(
|
||||
SUGGEST,
|
||||
json={
|
||||
"target": self.target(path),
|
||||
"capture_token": observed["capture"]["token"],
|
||||
"expected_epoch": observed["library_epoch"],
|
||||
"model_type": "wd14-vit",
|
||||
"options": {"general_threshold": 0.35, "character_threshold": 0.85},
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status, 200, await response.text())
|
||||
|
||||
async def save(self, observed, path, tags, revision=None):
|
||||
response = await self.client.put(
|
||||
TAGS,
|
||||
json={
|
||||
"target": self.target(path),
|
||||
"capture_token": observed["capture"]["token"],
|
||||
"sha256": observed["capture"]["sha256"],
|
||||
"expected_epoch": observed["library_epoch"],
|
||||
"expected_tag_revision": (
|
||||
observed["tag_revision"] if revision is None else revision
|
||||
),
|
||||
"tags": tags,
|
||||
},
|
||||
)
|
||||
return response.status, await response.json()
|
||||
|
||||
async def test_save_after_generate_marks_and_a_skip_batch_leaves_it(self):
|
||||
self.image("d.png", (1, 2, 3), sees=["bird", "sky"])
|
||||
observed = await self.observe("d.png")
|
||||
await self.suggest(observed, "d.png")
|
||||
status, _ = await self.save(observed, "d.png", ["sky"]) # bird removed
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(self.marked(), {self.locator("d.png")})
|
||||
job = await self.run_batch(existing="skip")
|
||||
self.assertEqual(self.rows(job, "skipped"), [("d.png", "Already auto-tagged")])
|
||||
self.assertEqual(self.stored("d.png")["tags"], ["sky"])
|
||||
|
||||
async def test_save_without_generate_does_not_mark(self):
|
||||
self.image("e.png", (1, 2, 3))
|
||||
observed = await self.observe("e.png")
|
||||
status, _ = await self.save(observed, "e.png", ["hand"])
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(self.marked(), set())
|
||||
|
||||
async def test_a_conflict_then_a_saved_retry_marks(self):
|
||||
f = self.image("f.png", (1, 2, 3))
|
||||
observed = await self.observe("f.png")
|
||||
await self.suggest(observed, "f.png")
|
||||
self.saved_set("f.png", f.read_bytes(), ["other"]) # another writer
|
||||
status, data = await self.save(observed, "f.png", ["mine"])
|
||||
self.assertEqual((status, data["code"]), (409, "tag-conflict"))
|
||||
self.assertEqual(self.marked(), set())
|
||||
status, _ = await self.save(
|
||||
observed, "f.png", ["mine"], revision=data["tag_revision"]
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(self.marked(), {self.locator("f.png")})
|
||||
|
||||
async def test_another_capture_does_not_inherit_the_mark(self):
|
||||
self.image("g.png", (1, 2, 3))
|
||||
self.image("h.png", (4, 5, 6))
|
||||
first = await self.observe("g.png")
|
||||
await self.suggest(first, "g.png")
|
||||
second = await self.observe("h.png")
|
||||
status, _ = await self.save(second, "h.png", ["x"])
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(self.marked(), set())
|
||||
self.assertIn(first["capture"]["token"], self.api._suggested_tokens)
|
||||
|
||||
async def test_gone_tokens_are_dropped_on_the_next_insert_without_touching_clocks(
|
||||
self,
|
||||
):
|
||||
self.image("i.png", (1, 2, 3))
|
||||
self.image("j.png", (4, 5, 6))
|
||||
first = await self.observe("i.png")
|
||||
await self.suggest(first, "i.png")
|
||||
store = self.api._capture_store()
|
||||
store.release(first["capture"]["token"])
|
||||
second = await self.observe("j.png")
|
||||
seen = []
|
||||
real_get = store.get
|
||||
with patch.object(
|
||||
store, "get", side_effect=lambda t: seen.append(t) or real_get(t)
|
||||
):
|
||||
await self.suggest(second, "j.png")
|
||||
self.assertEqual(seen, [])
|
||||
self.assertEqual(self.api._suggested_tokens, {second["capture"]["token"]})
|
||||
@@ -1398,50 +1398,6 @@ class TestModelHousekeeping(DatabaseTestCase):
|
||||
self.assertIsNot(self.db.model.get_connection(), failing)
|
||||
|
||||
|
||||
class TestAutotagBatchCandidates(DatabaseTestCase):
|
||||
"""#328: the batch's candidate read never loads workflow documents."""
|
||||
|
||||
def test_autotag_candidates_skip_workflow_data(self):
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
first = self.db.save_prompt(
|
||||
text="a", tags=["one", "two"], prompt_hash=generate_prompt_hash("a")
|
||||
)
|
||||
second = self.db.save_prompt(text="b", prompt_hash=generate_prompt_hash("b"))
|
||||
old = self.db.link_image_to_prompt(
|
||||
first, "/out/a.png", {"workflow": {"big": "x" * 1000}}
|
||||
)
|
||||
new = self.db.link_image_to_prompt(second, "/out/b.png")
|
||||
statements = []
|
||||
conn = self.db.model.get_connection() # this thread's own connection
|
||||
conn.set_trace_callback(statements.append)
|
||||
try:
|
||||
rows = self.db.autotag_batch_candidates()
|
||||
finally:
|
||||
conn.set_trace_callback(None)
|
||||
self.assertEqual(
|
||||
rows[0],
|
||||
{
|
||||
"id": new,
|
||||
"image_path": "/out/b.png",
|
||||
"prompt_id": second,
|
||||
"prompt_tags": [],
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
{**rows[1], "prompt_tags": sorted(rows[1]["prompt_tags"])},
|
||||
{
|
||||
"id": old,
|
||||
"image_path": "/out/a.png",
|
||||
"prompt_id": first,
|
||||
"prompt_tags": ["one", "two"],
|
||||
},
|
||||
)
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertTrue(statements)
|
||||
self.assertFalse(any("workflow_data" in sql for sql in statements))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ engine is B2's fake service.
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import unittest
|
||||
@@ -828,3 +829,108 @@ class TestFixRound(ImageTagJobTestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestFilterStart(ImageTagJobTestCase):
|
||||
"""#392: "Select all N matching" starts the job with the listing filter."""
|
||||
|
||||
async def test_filter_start_tags_every_matching_file_in_listing_order(self):
|
||||
for number in range(6):
|
||||
path = self.png(f"bulk/{number:02}.png", color=(number, 1, 1))
|
||||
os.utime(path, (100 + number, 100 + number))
|
||||
self.png("other/x.png")
|
||||
|
||||
job = await self.run_job(
|
||||
{
|
||||
"mode": "add",
|
||||
"filter": {"subfolder": "bulk", "sort": "oldest"},
|
||||
"tags": ["picked"],
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(job.scope, {"mode": "add", "images": 6})
|
||||
self.assertEqual(job.counters["tagged"], 6)
|
||||
self.assertEqual(
|
||||
[path for path, _detail in self.rows(job, "tagged")],
|
||||
[f"bulk/{number:02}.png" for number in range(6)],
|
||||
)
|
||||
self.assertEqual(self.stored("bulk/03.png")["tags"], ["picked"])
|
||||
self.assertEqual(self.stored("other/x.png")["tags"], [])
|
||||
|
||||
async def test_auto_filter_start_runs_the_model_on_the_matching_files(self):
|
||||
self.png("bulk/a.png")
|
||||
self.png("other/b.png", color=(1, 2, 3))
|
||||
|
||||
job = await self.run_job(
|
||||
{
|
||||
"mode": "auto",
|
||||
"filter": {"subfolder": "bulk"},
|
||||
"model_type": "wd14-vit",
|
||||
"options": dict(WD14),
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(job.scope["images"], 1)
|
||||
self.assertEqual(job.counters["tagged"], 1)
|
||||
self.assertEqual(self.stored("other/b.png")["tags"], [])
|
||||
|
||||
async def test_filter_with_no_match_is_refused_without_a_job(self):
|
||||
self.png("a.png")
|
||||
|
||||
status, data = await self.start(
|
||||
{"mode": "add", "filter": {"subfolder": "missing"}, "tags": ["x"]}
|
||||
)
|
||||
|
||||
self.assertEqual((status, data["code"]), (400, "no-images"))
|
||||
self.assertEqual(self.api._jobs.list(), [])
|
||||
|
||||
async def test_filter_over_the_cap_is_refused_without_a_job(self):
|
||||
from py.api import image_tag_jobs
|
||||
|
||||
for number in range(3):
|
||||
self.png(f"{number}.png", color=(number, 0, 0))
|
||||
with patch.object(image_tag_jobs, "MAX_FILTER_TARGETS", 2):
|
||||
status, data = await self.start(
|
||||
{"mode": "add", "filter": {}, "tags": ["x"]}
|
||||
)
|
||||
|
||||
self.assertEqual((status, data["code"]), (400, "too-many-images"))
|
||||
self.assertEqual(self.api._jobs.list(), [])
|
||||
self.assertEqual(image_tag_jobs.MAX_FILTER_TARGETS, 10_000)
|
||||
|
||||
async def test_filter_bodies_are_validated(self):
|
||||
self.png("a.png")
|
||||
target = self.target("a.png")
|
||||
for body in (
|
||||
{"mode": "add", "filter": {}, "targets": [target], "tags": ["x"]},
|
||||
{"mode": "add", "filter": "all", "tags": ["x"]},
|
||||
{"mode": "add", "filter": {"sort": "sideways"}, "tags": ["x"]},
|
||||
{"mode": "add", "filter": {"cursor": "x"}, "tags": ["x"]},
|
||||
{"mode": "add", "filter": {"root": "7"}, "tags": ["x"]},
|
||||
{"mode": "add", "filter": {}, "tags": []},
|
||||
):
|
||||
with self.subTest(body=body):
|
||||
status, data = await self.start(body)
|
||||
self.assertEqual(status, 400, data)
|
||||
self.assertEqual(self.api._jobs.list(), [])
|
||||
|
||||
async def test_a_filter_whose_source_was_renumbered_is_refused(self):
|
||||
self.png("a.png")
|
||||
scope = self.target("a.png")["root_scope"]
|
||||
|
||||
job = await self.run_job(
|
||||
{"mode": "add", "filter": {"root": "0", "root_scope": scope}, "tags": ["x"]}
|
||||
)
|
||||
self.assertEqual(job.counters["tagged"], 1)
|
||||
status, data = await self.start(
|
||||
{
|
||||
"mode": "add",
|
||||
"filter": {"root": "0", "root_scope": "f" * 64},
|
||||
"tags": ["x"],
|
||||
}
|
||||
)
|
||||
self.assertEqual((status, data["code"]), (409, "source-changed"))
|
||||
status, data = await self.start(
|
||||
{"mode": "add", "filter": {"root_scope": scope}, "tags": ["x"]}
|
||||
)
|
||||
self.assertEqual(status, 400, data)
|
||||
|
||||
@@ -535,7 +535,9 @@ class TestHeldJobStates(_LegacyHoldCase):
|
||||
self.assertIn("event: error", text)
|
||||
self.assertEqual(await self._listed(), [("thumbnails", "failed")])
|
||||
|
||||
async def test_legacy_autotag_that_ends_in_error_is_failed(self):
|
||||
async def test_legacy_autotag_refused_at_start_leaves_no_job(self):
|
||||
# #379: the legacy batch is the autotag job; a model that is not
|
||||
# downloaded refuses the start (one error frame) and registers nothing.
|
||||
service = MagicMock()
|
||||
service.get_models_status.return_value = {} # model not downloaded
|
||||
with patch("py.autotag.get_autotag_service", return_value=service):
|
||||
@@ -545,7 +547,7 @@ class TestHeldJobStates(_LegacyHoldCase):
|
||||
text = await resp.text()
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertIn('"type": "error"', text)
|
||||
self.assertEqual(await self._listed(), [("autotag", "failed")])
|
||||
self.assertEqual(await self._listed(), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user