Merge remote-tracking branch 'origin/main' into fix/379-tag-search
# Conflicts: # database/annotations.py
This commit is contained in:
@@ -676,3 +676,73 @@ export function measureInspector(selector) {
|
||||
primary:node.classList.contains('ca-primary'),minHeight:getComputedStyle(node).minHeight})),
|
||||
};
|
||||
}
|
||||
|
||||
// #249 System S7 Task 20, shared since S12 (#329): the maintenance plan dialog's measurement and its D100 mapping,
|
||||
// moved unchanged from system-maintenance-concept.spec.mjs so the rotated-log review uses the same ones.
|
||||
/** Runs in the page: the open review dialog (plan or duplicates): its title, body and the concept's tokens. */
|
||||
export function measureReview() {
|
||||
const dialog = document.querySelector('dialog#modal[open]');
|
||||
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
|
||||
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
|
||||
const text = node => node?.textContent.trim() ?? null;
|
||||
const body = dialog.querySelector('#modal-body');
|
||||
const grid = body.querySelector(':scope > dl.meta-grid');
|
||||
const compare = body.querySelector('.compare-grid');
|
||||
const item = compare?.querySelector('.compare-item');
|
||||
const notes = [...body.querySelectorAll(':scope > p.small')];
|
||||
const note = notes.at(-1);
|
||||
const actions = body.querySelector(':scope > .dialog-actions');
|
||||
return {
|
||||
title: text(dialog.querySelector('#modal-title')),
|
||||
tree: [...body.children].map(name),
|
||||
heading: body.querySelector(':scope > h3') && { text: text(body.querySelector(':scope > h3')),
|
||||
...css(body.querySelector(':scope > h3'), 'fontSize', 'fontWeight', 'color', 'marginBottom') },
|
||||
grid: grid && { rows: [...grid.children].map(row => [text(row.querySelector('dt')), text(row.querySelector('dd'))]),
|
||||
...css(grid, 'display', 'gridTemplateColumns', 'columnGap', 'rowGap'),
|
||||
dt: css(grid.querySelector('dt'), 'fontSize', 'color', 'textTransform'), dd: css(grid.querySelector('dd'), 'fontSize', 'color') },
|
||||
details: body.querySelector(':scope > details') && { summary: text(body.querySelector(':scope > details > summary')),
|
||||
tree: [...body.querySelector(':scope > details').children].map(name) },
|
||||
backup: body.querySelector(':scope > .notice') && { tree: [...body.querySelector(':scope > .notice').children].map(name),
|
||||
text: [...body.querySelectorAll(':scope > .notice > div:first-child > *')].map(node => [node.localName, text(node)]),
|
||||
buttons: [...body.querySelectorAll(':scope > .notice button')].map(text) },
|
||||
compare: compare && { tree: [...compare.children].map(name), ...css(compare, 'display', 'gridTemplateColumns', 'columnGap'),
|
||||
items: [...compare.children].map(entry => ({ tree: [...entry.children].map(name), label: text(entry.querySelector('label')),
|
||||
checked: entry.querySelector('input[type=radio]').checked, image: entry.querySelector('img')?.className ?? null })),
|
||||
item: css(item, 'paddingTop', 'paddingLeft', 'backgroundColor', 'borderTopWidth', 'borderTopColor', 'borderTopLeftRadius'),
|
||||
image: css(item.querySelector('img'), 'width', 'objectFit'),
|
||||
label: css(item.querySelector('label'), 'fontSize', 'color'),
|
||||
radio: { name: item.querySelector('input[type=radio]').name.startsWith('keep-') ? 'keep-*' : 'other',
|
||||
type: item.querySelector('input').type } },
|
||||
note: note && { text: text(note), ...css(note, 'fontSize', 'color', 'marginTop') },
|
||||
actions: { tree: [...actions.children].map(name), ...css(actions, 'display', 'justifyContent', 'columnGap', 'marginTop'),
|
||||
buttons: [...actions.children].map(item => ({ text: text(item), className: item.className,
|
||||
...css(item, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopColor') })) },
|
||||
};
|
||||
}
|
||||
|
||||
export const BACKUP_BOX = Object.freeze({ tree: ['div', 'div.row-actions'], text: [['h3', 'Back up the database first (recommended)'],
|
||||
['p', 'Downloads a copy of the prompts database before this operation changes any records.']], buttons: ['Back up now'] });
|
||||
|
||||
/**
|
||||
* D100: the server's plan in the concept's dialog: the operation's row title as its `h3` (the concept prints its
|
||||
* `data-plan` name), the four rows with the plan's values, the listed records (`details`), the hold line in the
|
||||
* concept's `p.small`, the D55 backup box before the actions, and "Apply reviewed operation". No sample note.
|
||||
*/
|
||||
export function mapPlanConcept(reference, title, plan) {
|
||||
const backup = plan.backup.policy === 'off' ? 'No safety backup (the policy is off)'
|
||||
: `Create and verify before changes (${plan.backup.destination_label})`;
|
||||
const tree = ['h3', 'dl.meta-grid', ...(plan.details.length ? ['details'] : []), 'p.small', 'div.notice', 'div.dialog-actions'];
|
||||
const [cancel, apply] = reference.actions.buttons;
|
||||
return {
|
||||
...reference,
|
||||
tree,
|
||||
heading: { ...reference.heading, text: title },
|
||||
grid: { ...reference.grid, rows: [['Scope', plan.scope.label],
|
||||
['Protected', plan.protected.reasons.length ? plan.protected.reasons.join(' · ') : 'Nothing is protected'],
|
||||
['Backup', backup], ['Media on disk', plan.media.label]] },
|
||||
details: plan.details.length ? { summary: `Listed records (${plan.details.length})`, tree: ['summary', 'pre'] } : null,
|
||||
backup: BACKUP_BOX,
|
||||
note: { ...reference.note, text: 'Nothing changes until you apply. This review holds for 15 minutes.' },
|
||||
actions: { ...reference.actions, buttons: [cancel, { ...apply, text: 'Apply reviewed operation' }] },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { SIZES, THEMES, seedConcept, openConcept, openProduction, diffInventory, mutate, measureReview, mapPlanConcept }
|
||||
from './concept-inventory.mjs';
|
||||
|
||||
// #249 System S12 Task 7 (#329): System › Diagnostics & logs (app.js renderSystem 694–695) and its log cleanup review
|
||||
// (the concept's `maintenancePlan("rotated logs")`, 953–958) against the immutable concept, on the concept-shaped
|
||||
// fixture with the logger writing into this server's own root and seeded with the concept's sample (four lines, a
|
||||
// 1.4 MB active log, three rotated copies, console logging off). Each concept measurement is mapped only by the recorded
|
||||
// deviations (D109, D110, and D100 for the review), each commented where it applies; after that the difference must be
|
||||
// empty, and a removed term, a renamed button, a reordered option, a primary Refresh, a squeezed select or a swapped
|
||||
// token must be noticed.
|
||||
test.use({ locale: 'en-US', timezoneId: 'UTC' });
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
await seedConcept(request);
|
||||
await expect(await request.post('/__test__/system/reset')).toBeOK();
|
||||
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
|
||||
await expect(await request.post('/__test__/logs/reset')).toBeOK();
|
||||
await expect(await request.post('/__test__/logs/seed', { data: { mode: 'concept' } })).toBeOK();
|
||||
page.conceptErrors = [];
|
||||
page.on('pageerror', error => page.conceptErrors.push(error.message));
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, request, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
await request.post('/__test__/jobs/restart');
|
||||
await request.post('/__test__/logs/reset');
|
||||
await request.post('/__test__/system/reset');
|
||||
expect(page.conceptErrors).toEqual([]);
|
||||
});
|
||||
|
||||
const REPORT_URL = '/prompt_manager/system/diagnostics';
|
||||
const PREVIEW_URL = '/prompt_manager/maintenance/preview';
|
||||
const LINE_FORMAT = /^\d\d:\d\d:\d\d [A-Z]+ +\S/;
|
||||
|
||||
/** Runs in the page: Diagnostics & logs under `mainSelector`: every surface's text, controls, geometry and tokens. */
|
||||
function measureDiagnostics([mainSelector, lineFormat]) {
|
||||
const format = new RegExp(lineFormat);
|
||||
const layout = document.querySelector(`${mainSelector} .settings-layout`);
|
||||
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
|
||||
const tokens = (node, ...names) => Object.fromEntries(names.map(key => [key, getComputedStyle(node).getPropertyValue(key).trim()]));
|
||||
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
|
||||
const text = node => node?.textContent.trim() ?? null;
|
||||
const button = node => ({ text: text(node), className: node.className });
|
||||
const control = row => {
|
||||
const select = row.querySelector(':scope > select');
|
||||
if (select) return { kind: 'select', id: select.id, options: [...select.options].map(option => option.textContent),
|
||||
selected: select.selectedOptions[0]?.textContent ?? null, disabled: select.disabled };
|
||||
const toggle = row.querySelector(':scope > * input[type="checkbox"]');
|
||||
return toggle ? { kind: 'switch', id: toggle.id, wrapper: name(toggle.parentElement), checked: toggle.checked,
|
||||
disabled: toggle.disabled } : null;
|
||||
};
|
||||
const operations = parent => [...parent.querySelectorAll(':scope > .operation')].map(row => ({ tree: [...row.children].map(name),
|
||||
title: text(row.querySelector('h3')), description: text(row.querySelector('p')),
|
||||
actions: [...row.querySelectorAll('.row-actions > button')].map(button) }));
|
||||
const toolbarItem = node => ({ name: name(node), text: node.localName === 'select' || node.localName === 'input' ? null : text(node),
|
||||
for: node.localName === 'label' ? node.htmlFor : null, id: node.id || null,
|
||||
options: node.localName === 'select' ? [...node.options].map(option => option.textContent) : null,
|
||||
// The chosen option, so a wrong default level is noticed.
|
||||
selected: node.localName === 'select' ? node.selectedOptions[0]?.textContent ?? null : null,
|
||||
input: node.localName === 'input' ? [node.type, node.min, node.max, node.value] : null,
|
||||
className: node.localName === 'button' ? node.className : null });
|
||||
const surfaces = [...layout.querySelectorAll(':scope > section.surface')];
|
||||
const pre = layout.querySelector('pre');
|
||||
const lines = (pre?.textContent ?? '').split('\n').filter(Boolean);
|
||||
const details = layout.querySelector('details');
|
||||
const firstRow = layout.querySelector('.setting-row');
|
||||
const toolbar = layout.querySelector('.toolbar');
|
||||
return {
|
||||
layout: [...layout.children].map(name),
|
||||
surfaces: surfaces.map(surface => {
|
||||
const heading = surface.querySelector(':scope > .panel-heading');
|
||||
const grid = surface.querySelector(':scope > .meta-grid');
|
||||
return {
|
||||
tree: [...surface.children].map(name),
|
||||
heading: text(surface.querySelector(':scope > h2, :scope > .panel-heading > h2')),
|
||||
panelHeading: heading && [...heading.children].map(node => [name(node), text(node)]),
|
||||
grid: grid && [...grid.children].map(entry => [name(entry), text(entry.querySelector('dt')), text(entry.querySelector('dd'))]),
|
||||
buttons: [...surface.querySelectorAll(':scope > button')].map(button),
|
||||
toolbar: surface.querySelector(':scope > .toolbar') && [...surface.querySelector(':scope > .toolbar').children].map(toolbarItem),
|
||||
pre: surface.querySelector(':scope > pre') && { tabIndex: pre.tabIndex, tree: [...pre.children].map(name), lines,
|
||||
format: lines.every(line => format.test(line)) },
|
||||
notes: [...surface.querySelectorAll(':scope > p')].map(node => [name(node), text(node), node.getAttribute('role'),
|
||||
node.getAttribute('aria-label')]),
|
||||
rows: [...surface.querySelectorAll(':scope > .setting-row')].map(row => ({ tree: [...row.children].map(name),
|
||||
text: [...row.firstElementChild.children].map(node => [name(node), text(node), node.localName === 'label' ? node.htmlFor : null]),
|
||||
control: control(row) })),
|
||||
details: surface.querySelector(':scope > details') && {
|
||||
summary: text(details.querySelector(':scope > summary')), tree: [...details.children].map(name),
|
||||
notes: [...details.querySelectorAll(':scope > p')].map(node => [name(node), text(node)]),
|
||||
buttons: [...details.querySelectorAll(':scope > button')].map(button),
|
||||
files: [...details.querySelectorAll(':scope > .operation-list > .operation')].map(row => ({
|
||||
title: text(row.querySelector('h3')), detail: text(row.querySelector('p')), actions: [...row.querySelectorAll('.row-actions > button')].map(item =>
|
||||
[text(item), item.getAttribute('aria-label')]) })),
|
||||
operations: operations(details) },
|
||||
operations: operations(surface),
|
||||
};
|
||||
}),
|
||||
tokens: tokens(surfaces[0], '--line', '--panel', '--accent'),
|
||||
surface: css(surfaces[1], 'paddingTop', 'paddingLeft', 'backgroundColor', 'borderTopWidth', 'borderTopColor', 'borderTopLeftRadius',
|
||||
'marginTop'),
|
||||
grid: css(layout.querySelector('.meta-grid'), 'display', 'gridTemplateColumns', 'columnGap', 'rowGap'),
|
||||
toolbar: css(toolbar, 'display', 'flexWrap', 'alignItems', 'columnGap', 'rowGap', 'marginBottom'),
|
||||
pre: css(pre, 'fontFamily', 'fontSize', 'backgroundColor', 'borderTopColor', 'borderTopLeftRadius', 'paddingTop', 'overflowX'),
|
||||
row: css(firstRow, 'display', 'alignItems', 'justifyContent', 'columnGap', 'paddingTop', 'paddingBottom', 'borderTopWidth',
|
||||
'borderTopColor'),
|
||||
label: css(firstRow.querySelector('label'), 'fontSize', 'fontWeight', 'color'),
|
||||
description: css(firstRow.querySelector('p'), 'fontSize', 'color', 'marginTop'),
|
||||
select: css(firstRow.querySelector('select'), 'fontSize', 'minWidth', 'maxWidth', 'backgroundColor', 'color', 'borderTopColor'),
|
||||
summary: css(details.querySelector('summary'), 'fontSize', 'fontWeight', 'color', 'cursor'),
|
||||
small: css(details.querySelector(':scope > p.small'), 'fontSize', 'color'),
|
||||
// Whether each select is as wide as the browser lays out a copy holding only its chosen option, i.e. it shows
|
||||
// that option whole (the browser's own select sizing, arrow included).
|
||||
selectsFit: [...layout.querySelectorAll('select')].map(select => {
|
||||
const copy = select.cloneNode(false);
|
||||
copy.removeAttribute('id');
|
||||
copy.append(new Option(select.selectedOptions[0]?.textContent ?? ''));
|
||||
Object.assign(copy.style, { position: 'absolute', visibility: 'hidden', width: 'max-content', minWidth: '0',
|
||||
maxWidth: 'none', flex: 'none' });
|
||||
select.parentElement.append(copy);
|
||||
const needed = copy.getBoundingClientRect().width;
|
||||
copy.remove();
|
||||
return select.getBoundingClientRect().width + 0.5 >= needed;
|
||||
}),
|
||||
pageScroll: document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||
};
|
||||
}
|
||||
|
||||
const plural = (count, one, many = `${one}s`) => `${count.toLocaleString('en-US')} ${count === 1 ? one : many}`;
|
||||
/** The server's size label (1 KB = 1024 bytes), as the section prints it. */
|
||||
function sizeText(bytes) {
|
||||
if (bytes < 1024) return plural(bytes, 'byte');
|
||||
if (bytes < 2 ** 20) return `${(bytes / 2 ** 10).toFixed(1)} KB`;
|
||||
if (bytes < 2 ** 30) return `${(bytes / 2 ** 20).toFixed(1)} MB`;
|
||||
return `${(bytes / 2 ** 30).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
const KINDS = Object.freeze({ active: 'Active log', rotated: 'Rotated copy', other: 'Other file' });
|
||||
const RENDERER_NONE = 'No ComfyUI window is open in this browser, so its renderer is not known.';
|
||||
const COPY = Object.freeze({
|
||||
testLink: 'Write a placeholder image record to check that linking works.',
|
||||
runtimeLevel: 'How much the server writes to the log. Applied until the server restarts.',
|
||||
console: 'Print Prompt Manager’s warnings and errors in the ComfyUI console (everything at DEBUG). Applied until the server restarts.',
|
||||
clear: 'Empty the active log and delete its rotated copies.',
|
||||
retention: 'Rotates at 10 MB · keeps 5 rotated copies · file logging on · memory buffer 1,000 entries',
|
||||
});
|
||||
|
||||
/**
|
||||
* The concept's Diagnostics & logs with the recorded deviations applied, for the server's `report` and the seeded
|
||||
* `files` (`/logs/files`). The concept's own sample values the fixture reproduces stay as they are: the four log lines,
|
||||
* the Viewer.js line, "prompt_manager.log · 1.4 MB · rotated copies: 3", INFO and console logging off.
|
||||
*/
|
||||
function mapDiagnosticsConcept(reference, { report, files, stats }) {
|
||||
const [diagnostics, logs] = reference.surfaces;
|
||||
const [levelLabel, level, refresh, download] = logs.toolbar;
|
||||
const [runtime, consoleRow] = logs.rows;
|
||||
const database = report.checks.database;
|
||||
const { configured, watching, unavailable } = report.sources;
|
||||
const sources = configured === 0 ? `ComfyUI output folder · ${watching ? 'watching' : 'manual scan'}`
|
||||
: unavailable > 0 ? `${plural(configured, 'source')} · ${unavailable} not available`
|
||||
: `${plural(configured, 'source')} · ${watching} watching`;
|
||||
const errors = (stats.level_counts.ERROR ?? 0) + (stats.level_counts.CRITICAL ?? 0);
|
||||
return {
|
||||
...reference,
|
||||
surfaces: [
|
||||
{ ...diagnostics,
|
||||
// D110: the Test image link operation follows the concept's Image-link diagnostics.
|
||||
tree: [...diagnostics.tree, 'div.operation'],
|
||||
// D110: live values. Database and Sources from `GET /system/diagnostics`, Host renderers is D106's
|
||||
// renderer line (no ComfyUI window in this browser); the pinned Viewer.js line is the concept's own.
|
||||
grid: diagnostics.grid.map(([entry, term, value]) => [entry, term, {
|
||||
Database: `Readable · ${sizeText(report.database_info.database_size_bytes)} · ${plural(database.image_count, 'image')}`,
|
||||
Sources: sources,
|
||||
'Host renderers': RENDERER_NONE }[term] ?? value]),
|
||||
// D110: the buttons drop the concept's "sample".
|
||||
buttons: diagnostics.buttons.map(item => ({ ...item, text: item.text.replace('Download sample diagnostic report',
|
||||
'Download diagnostic report') })),
|
||||
operations: [
|
||||
// D110: "Inspect" opens the read-only Inspect a file page.
|
||||
...diagnostics.operations.map(row => ({ ...row, actions: row.actions.map(item => ({ ...item, text: 'Inspect' })) })),
|
||||
// D110: the legacy synthetic link (S12-11), enabled: the fixture has a most recent prompt.
|
||||
{ ...diagnostics.operations[0], title: 'Test image link', description: COPY.testLink,
|
||||
actions: [{ ...diagnostics.operations[0].actions[0], text: 'Create test link…' }] },
|
||||
] },
|
||||
{ ...logs,
|
||||
// D109: the statistics line follows the viewer.
|
||||
tree: [logs.tree[0], logs.tree[1], logs.tree[2], 'p.small', ...logs.tree.slice(3)],
|
||||
toolbar: [
|
||||
levelLabel,
|
||||
// D109: all six minimum levels (the concept has three).
|
||||
{ ...level, options: ['All levels', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] },
|
||||
// D109: the added Lines control, 1–500, default 100.
|
||||
{ name: 'label.small', text: 'Lines', for: 'log-limit', id: null, options: null, selected: null, input: null,
|
||||
className: null },
|
||||
{ name: 'input', text: null, for: null, id: 'log-limit', options: null, selected: null,
|
||||
input: ['number', '1', '500', '100'], className: null },
|
||||
refresh,
|
||||
// D109: the active log, not a sample.
|
||||
{ ...download, text: 'Download log' },
|
||||
],
|
||||
// D109: "Log statistics" (errors are ERROR + CRITICAL).
|
||||
notes: [['p.small', `${plural(stats.buffer_count, 'entry', 'entries')} in memory · ${plural(errors, 'error')} · `
|
||||
+ `${plural(stats.level_counts.WARNING ?? 0, 'warning')} · ${plural(stats.log_files_count, 'file')} · `
|
||||
+ `${sizeText(stats.total_log_size)} on disk`, 'status', 'Log statistics']],
|
||||
rows: [
|
||||
// D109: CRITICAL after the concept's levels; the value lasts until the server restarts.
|
||||
{ ...runtime, text: [runtime.text[0], ['p', COPY.runtimeLevel, null]],
|
||||
control: { ...runtime.control, options: [...runtime.control.options, 'CRITICAL'] } },
|
||||
// D109: what the console setting does and that it lasts until the server restarts (off, as the
|
||||
// concept's sample, by the fixture's seed).
|
||||
{ ...consoleRow, text: [consoleRow.text[0], ['p', COPY.console, null]] },
|
||||
],
|
||||
details: { ...logs.details,
|
||||
// D109: the retention line, the files and Clear all logs follow the concept's line and review.
|
||||
tree: [...logs.details.tree, 'p.small', 'div.operation-list', 'div.operation'],
|
||||
notes: [...logs.details.notes, ['p.small', COPY.retention]],
|
||||
files: files.map(row => ({ title: row.filename, detail: `${sizeText(row.size)} · ${row.modified.slice(0, 16)
|
||||
.replace('T', ' ')} · ${KINDS[row.kind]}`, actions: [['Download', `Download ${row.filename}`]] })),
|
||||
operations: [{ tree: ['div', 'div.row-actions'], title: 'Clear all logs', description: COPY.clear,
|
||||
actions: [{ text: 'Clear all logs…', className: 'danger' }] }] } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function conceptDiagnostics(page, theme) {
|
||||
await openConcept(page, 'system', theme);
|
||||
await page.locator('#main .section-nav').getByRole('button', { name: 'Diagnostics & logs', exact: true }).click();
|
||||
await expect(page.locator('#main .settings-layout > section.surface')).toHaveCount(2);
|
||||
await page.mouse.move(0, 0);
|
||||
return page.evaluate(measureDiagnostics, ['#main', LINE_FORMAT.source]);
|
||||
}
|
||||
|
||||
/** Production's section once every read answered, with the server's report, files and statistics it shows. */
|
||||
async function productionDiagnostics(page, request, theme) {
|
||||
await openProduction(page, 'system/diagnostics', theme);
|
||||
const main = page.locator('#workspace-main');
|
||||
await expect(main.locator('.settings-layout > section.surface')).toHaveCount(2);
|
||||
await expect(main.locator('.meta-grid dd')).not.toContainText(['Loading…']);
|
||||
await expect(main.locator('.meta-grid dd').last()).toHaveText(RENDERER_NONE);
|
||||
await expect(main.locator('#log-lines')).not.toHaveText('Loading…');
|
||||
await expect(main.locator('#log-stats')).not.toHaveText('Loading…');
|
||||
await expect(main.locator('details > p.small')).not.toContainText(['Loading…']);
|
||||
await expect(main.locator('#runtime-level')).toBeEnabled();
|
||||
await expect(main.locator('.operation').filter({ hasText: 'Create test link…' }).locator('button')).not.toHaveAttribute('aria-disabled', 'true');
|
||||
await page.mouse.move(0, 0);
|
||||
const production = await page.evaluate(measureDiagnostics, ['#workspace-main', LINE_FORMAT.source]);
|
||||
const read = async url => (await request.get(url)).json();
|
||||
const report = await read(REPORT_URL);
|
||||
const { files } = await read('/prompt_manager/logs/files');
|
||||
const { stats } = await read('/prompt_manager/logs/stats');
|
||||
return { production, report, files, stats };
|
||||
}
|
||||
|
||||
for (const theme of THEMES) for (const [width, height] of SIZES) {
|
||||
test(`diagnostics and logs match the concept ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
|
||||
await page.setViewportSize({ width, height });
|
||||
const concept = await conceptDiagnostics(page, theme);
|
||||
await page.screenshot({ path: testInfo.outputPath(`concept-diagnostics-${theme}-${width}.png`), fullPage: true });
|
||||
const { production, ...live } = await productionDiagnostics(page, request, theme);
|
||||
await page.screenshot({ path: testInfo.outputPath(`production-diagnostics-${theme}-${width}.png`), fullPage: true });
|
||||
expect(diffInventory(mapDiagnosticsConcept(concept, live), production)).toEqual([]);
|
||||
expect(production.surfaces[1].pre.format).toBe(true);
|
||||
expect(production.pageScroll).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
// Below the concept's smallest size: on a 320px phone the Level and Runtime log level selects still show their whole
|
||||
// option, without page scroll.
|
||||
for (const theme of THEMES) test(`diagnostics selects fit at 320 wide ${theme}`, async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 320, height: 700 });
|
||||
const { production } = await productionDiagnostics(page, request, theme);
|
||||
expect(production.selectsFit).toEqual([true, true]);
|
||||
expect(production.pageScroll).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* D100 with D109's rule for this plan: the server's `rotated_logs` plan in the concept's dialog, "Rotated log copies"
|
||||
* as its `h3`, the plan's grid values (its Backup row says none is needed), "Listed files (N)", the hold line, no D55
|
||||
* backup box (nothing in the library changes) and "Apply reviewed operation".
|
||||
*/
|
||||
function mapCleanupConcept(reference, plan) {
|
||||
const mapped = mapPlanConcept(reference, 'Rotated log copies', plan);
|
||||
return {
|
||||
...mapped,
|
||||
// D109: no backup box for a plan that changes no library record.
|
||||
tree: mapped.tree.filter(item => item !== 'div.notice'),
|
||||
grid: { ...mapped.grid, rows: mapped.grid.rows.map(([term, value]) =>
|
||||
[term, term === 'Backup' ? 'Not needed: no library record changes' : value]) },
|
||||
backup: null,
|
||||
// D109: the plan lists files, not records.
|
||||
details: { ...mapped.details, summary: `Listed files (${plan.details.length})` },
|
||||
};
|
||||
}
|
||||
|
||||
async function conceptCleanup(page, theme) {
|
||||
await conceptDiagnostics(page, theme);
|
||||
await page.locator('#main details > summary').click();
|
||||
await page.locator('#main [data-plan="rotated logs"]').click();
|
||||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Review maintenance plan');
|
||||
await page.mouse.move(0, 0);
|
||||
return page.evaluate(measureReview);
|
||||
}
|
||||
|
||||
async function productionCleanup(page, request, theme) {
|
||||
await productionDiagnostics(page, request, theme);
|
||||
await page.locator('#workspace-main details > summary').click();
|
||||
const answer = page.waitForResponse(response => response.url().endsWith(PREVIEW_URL));
|
||||
await page.locator('#workspace-main').getByRole('button', { name: 'Review log cleanup', exact: true }).click();
|
||||
const plan = await (await answer).json();
|
||||
await expect(page.locator('dialog#modal[open] #modal-body > h3')).toHaveText('Rotated log copies');
|
||||
await page.mouse.move(0, 0);
|
||||
return { plan, production: await page.evaluate(measureReview) };
|
||||
}
|
||||
|
||||
for (const theme of THEMES) for (const [width, height] of SIZES) {
|
||||
test(`log cleanup review matches the concept ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
|
||||
await page.setViewportSize({ width, height });
|
||||
const concept = await conceptCleanup(page, theme);
|
||||
expect(concept.heading.text).toBe('rotated logs');
|
||||
expect(concept.tree).toEqual(['p.demo-note', 'h3', 'dl.meta-grid', 'p.small', 'div.dialog-actions']);
|
||||
await page.screenshot({ path: testInfo.outputPath(`concept-log-cleanup-${theme}-${width}.png`) });
|
||||
const { plan, production } = await productionCleanup(page, request, theme);
|
||||
expect(plan.operation).toBe('rotated_logs');
|
||||
await page.screenshot({ path: testInfo.outputPath(`production-log-cleanup-${theme}-${width}.png`) });
|
||||
expect(diffInventory(mapCleanupConcept(concept, plan), production)).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
// Every path where production differs from the unmapped concept: D110's live values (Viewer.js is the concept's own),
|
||||
// renamed buttons and added operation, and D109's toolbar, statistics line, runtime rows and log files.
|
||||
const DEVIATED_PATHS = [
|
||||
'surfaces.0.buttons.0', 'surfaces.0.grid.0', 'surfaces.0.grid.1', 'surfaces.0.grid.3', 'surfaces.0.operations.0',
|
||||
'surfaces.0.operations.1', 'surfaces.0.operations.length', 'surfaces.0.tree.4', 'surfaces.0.tree.length',
|
||||
'surfaces.1.details.files', 'surfaces.1.details.notes', 'surfaces.1.details.operations', 'surfaces.1.details.tree',
|
||||
'surfaces.1.notes.0', 'surfaces.1.notes.length', 'surfaces.1.rows.0', 'surfaces.1.rows.1', 'surfaces.1.toolbar.1',
|
||||
'surfaces.1.toolbar.2', 'surfaces.1.toolbar.3', 'surfaces.1.toolbar.4', 'surfaces.1.toolbar.5', 'surfaces.1.toolbar.length',
|
||||
'surfaces.1.tree.3', 'surfaces.1.tree.5', 'surfaces.1.tree.6', 'surfaces.1.tree.length'].sort();
|
||||
|
||||
test('diagnostics comparison detects mutations', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const concept = await conceptDiagnostics(page, 'dark');
|
||||
const { production, ...live } = await productionDiagnostics(page, request, 'dark');
|
||||
const expected = mapDiagnosticsConcept(concept, live);
|
||||
expect(diffInventory(expected, production)).toEqual([]);
|
||||
// Unmapped, the section differs only where D109 and D110 are recorded.
|
||||
expect(diffInventory(concept, production).map(item => item.path.split('.').slice(0, 4).join('.'))
|
||||
.filter((path, index, all) => all.indexOf(path) === index).sort()).toEqual(DEVIATED_PATHS);
|
||||
const measure = () => page.evaluate(measureDiagnostics, ['#workspace-main', LINE_FORMAT.source]);
|
||||
const paths = async () => diffInventory(expected, await measure()).map(item => item.path);
|
||||
// A removed term.
|
||||
const undoTerm = await mutate(page, { remove: ['#workspace-main .meta-grid > div:nth-child(2)'] });
|
||||
expect(await paths()).toEqual(expect.arrayContaining(['surfaces.0.grid.length']));
|
||||
await undoTerm();
|
||||
// A renamed button.
|
||||
await page.evaluate(() => { document.querySelector('#workspace-main section.surface > button').textContent = 'Download report'; });
|
||||
expect(await paths()).toEqual(['surfaces.0.buttons.0.text']);
|
||||
await page.evaluate(() => { document.querySelector('#workspace-main section.surface > button').textContent = 'Download diagnostic report'; });
|
||||
// A reordered level option.
|
||||
await page.evaluate(() => { const select = document.querySelector('#workspace-main #log-level'); select.append(select.options[1]); });
|
||||
expect(await paths()).toEqual(expect.arrayContaining(['surfaces.1.toolbar.1.options.1']));
|
||||
await page.evaluate(() => { const select = document.querySelector('#workspace-main #log-level'); select.insertBefore(select.options[5], select.options[1]); });
|
||||
// A wrong default level.
|
||||
await page.evaluate(() => { document.querySelector('#workspace-main #log-level').value = 'INFO'; });
|
||||
expect(await paths()).toEqual(['surfaces.1.toolbar.1.selected']);
|
||||
await page.evaluate(() => { document.querySelector('#workspace-main #log-level').value = ''; });
|
||||
// A primary Refresh.
|
||||
await page.evaluate(() => { document.querySelectorAll('#workspace-main .toolbar > button')[0].classList.add('ca-primary'); });
|
||||
expect(await paths()).toEqual(expect.arrayContaining(['surfaces.1.toolbar.4.className']));
|
||||
await page.evaluate(() => { document.querySelectorAll('#workspace-main .toolbar > button')[0].classList.remove('ca-primary'); });
|
||||
// A select squeezed until its chosen option is cut.
|
||||
const undoSqueeze = await mutate(page, { css: '#workspace-main #runtime-level { max-width: 30px !important; min-width: 0 !important; }' });
|
||||
expect(await paths()).toEqual(expect.arrayContaining(['selectsFit.1']));
|
||||
await undoSqueeze();
|
||||
// A swapped token.
|
||||
const undoToken = await mutate(page, { css: '#workspace-main .settings-layout { --line: rgb(255, 0, 0); }' });
|
||||
expect(await paths()).toEqual(expect.arrayContaining(['tokens.--line', 'row.borderTopColor']));
|
||||
await undoToken();
|
||||
expect(diffInventory(expected, await measure())).toEqual([]);
|
||||
|
||||
// The log cleanup review: a changed grid term, a backup box that came back and a grid token are noticed.
|
||||
const conceptReview = await conceptCleanup(page, 'dark');
|
||||
const { plan } = await productionCleanup(page, request, 'dark');
|
||||
const expectedReview = mapCleanupConcept(conceptReview, plan);
|
||||
const reviewPaths = async () => diffInventory(expectedReview, await page.evaluate(measureReview)).map(item => item.path);
|
||||
expect(await reviewPaths()).toEqual([]);
|
||||
await page.evaluate(() => { document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[2].textContent = 'Safety'; });
|
||||
expect(await reviewPaths()).toEqual(['grid.rows.2.0']);
|
||||
await page.evaluate(() => { document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[2].textContent = 'Backup'; });
|
||||
const undoGrid = await mutate(page, { css: 'dialog#modal dl.meta-grid { column-gap: 3px; }' });
|
||||
expect(await reviewPaths()).toEqual(expect.arrayContaining(['grid.columnGap']));
|
||||
await undoGrid();
|
||||
expect(await reviewPaths()).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { SIZES, THEMES, seedConcept, openConcept, openProduction, diffInventory, mutate } from './concept-inventory.mjs';
|
||||
import { SIZES, THEMES, seedConcept, openConcept, openProduction, diffInventory, mutate, measureReview, mapPlanConcept }
|
||||
from './concept-inventory.mjs';
|
||||
|
||||
// #249 System S7 Task 20: System › Maintenance (app.js renderSystem 688–690), its plan dialog (`maintenancePlan`,
|
||||
// 953–958) for each operation and its duplicates dialog (`duplicates`, 1457–1461) against the immutable concept, on
|
||||
@@ -103,47 +104,6 @@ for (const theme of THEMES) for (const [width, height] of SIZES) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Runs in the page: the open review dialog (plan or duplicates): its title, body and the concept's tokens. */
|
||||
function measureReview() {
|
||||
const dialog = document.querySelector('dialog#modal[open]');
|
||||
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
|
||||
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
|
||||
const text = node => node?.textContent.trim() ?? null;
|
||||
const body = dialog.querySelector('#modal-body');
|
||||
const grid = body.querySelector(':scope > dl.meta-grid');
|
||||
const compare = body.querySelector('.compare-grid');
|
||||
const item = compare?.querySelector('.compare-item');
|
||||
const notes = [...body.querySelectorAll(':scope > p.small')];
|
||||
const note = notes.at(-1);
|
||||
const actions = body.querySelector(':scope > .dialog-actions');
|
||||
return {
|
||||
title: text(dialog.querySelector('#modal-title')),
|
||||
tree: [...body.children].map(name),
|
||||
heading: body.querySelector(':scope > h3') && { text: text(body.querySelector(':scope > h3')),
|
||||
...css(body.querySelector(':scope > h3'), 'fontSize', 'fontWeight', 'color', 'marginBottom') },
|
||||
grid: grid && { rows: [...grid.children].map(row => [text(row.querySelector('dt')), text(row.querySelector('dd'))]),
|
||||
...css(grid, 'display', 'gridTemplateColumns', 'columnGap', 'rowGap'),
|
||||
dt: css(grid.querySelector('dt'), 'fontSize', 'color', 'textTransform'), dd: css(grid.querySelector('dd'), 'fontSize', 'color') },
|
||||
details: body.querySelector(':scope > details') && { summary: text(body.querySelector(':scope > details > summary')),
|
||||
tree: [...body.querySelector(':scope > details').children].map(name) },
|
||||
backup: body.querySelector(':scope > .notice') && { tree: [...body.querySelector(':scope > .notice').children].map(name),
|
||||
text: [...body.querySelectorAll(':scope > .notice > div:first-child > *')].map(node => [node.localName, text(node)]),
|
||||
buttons: [...body.querySelectorAll(':scope > .notice button')].map(text) },
|
||||
compare: compare && { tree: [...compare.children].map(name), ...css(compare, 'display', 'gridTemplateColumns', 'columnGap'),
|
||||
items: [...compare.children].map(entry => ({ tree: [...entry.children].map(name), label: text(entry.querySelector('label')),
|
||||
checked: entry.querySelector('input[type=radio]').checked, image: entry.querySelector('img')?.className ?? null })),
|
||||
item: css(item, 'paddingTop', 'paddingLeft', 'backgroundColor', 'borderTopWidth', 'borderTopColor', 'borderTopLeftRadius'),
|
||||
image: css(item.querySelector('img'), 'width', 'objectFit'),
|
||||
label: css(item.querySelector('label'), 'fontSize', 'color'),
|
||||
radio: { name: item.querySelector('input[type=radio]').name.startsWith('keep-') ? 'keep-*' : 'other',
|
||||
type: item.querySelector('input').type } },
|
||||
note: note && { text: text(note), ...css(note, 'fontSize', 'color', 'marginTop') },
|
||||
actions: { tree: [...actions.children].map(name), ...css(actions, 'display', 'justifyContent', 'columnGap', 'marginTop'),
|
||||
buttons: [...actions.children].map(item => ({ text: text(item), className: item.className,
|
||||
...css(item, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopColor') })) },
|
||||
};
|
||||
}
|
||||
|
||||
// The concept's plan names (`data-plan`) and production's operation, row and button for each.
|
||||
const PLANS = Object.freeze([
|
||||
['duplicate prompts', 'duplicate_prompts', 'Duplicate prompt text'],
|
||||
@@ -151,32 +111,6 @@ const PLANS = Object.freeze([
|
||||
['unused prompt components', 'unused_components', 'Unused prompt components'],
|
||||
['missing-file links', 'missing_links', 'Missing-file links'],
|
||||
]);
|
||||
const BACKUP_BOX = Object.freeze({ tree: ['div', 'div.row-actions'], text: [['h3', 'Back up the database first (recommended)'],
|
||||
['p', 'Downloads a copy of the prompts database before this operation changes any records.']], buttons: ['Back up now'] });
|
||||
|
||||
/**
|
||||
* D100: the server's plan in the concept's dialog: the operation's row title as its `h3` (the concept prints its
|
||||
* `data-plan` name), the four rows with the plan's values, the listed records (`details`), the hold line in the
|
||||
* concept's `p.small`, the D55 backup box before the actions, and "Apply reviewed operation". No sample note.
|
||||
*/
|
||||
function mapPlanConcept(reference, title, plan) {
|
||||
const backup = plan.backup.policy === 'off' ? 'No safety backup (the policy is off)'
|
||||
: `Create and verify before changes (${plan.backup.destination_label})`;
|
||||
const tree = ['h3', 'dl.meta-grid', ...(plan.details.length ? ['details'] : []), 'p.small', 'div.notice', 'div.dialog-actions'];
|
||||
const [cancel, apply] = reference.actions.buttons;
|
||||
return {
|
||||
...reference,
|
||||
tree,
|
||||
heading: { ...reference.heading, text: title },
|
||||
grid: { ...reference.grid, rows: [['Scope', plan.scope.label],
|
||||
['Protected', plan.protected.reasons.length ? plan.protected.reasons.join(' · ') : 'Nothing is protected'],
|
||||
['Backup', backup], ['Media on disk', plan.media.label]] },
|
||||
details: plan.details.length ? { summary: `Listed records (${plan.details.length})`, tree: ['summary', 'pre'] } : null,
|
||||
backup: BACKUP_BOX,
|
||||
note: { ...reference.note, text: 'Nothing changes until you apply. This review holds for 15 minutes.' },
|
||||
actions: { ...reference.actions, buttons: [cancel, { ...apply, text: 'Apply reviewed operation' }] },
|
||||
};
|
||||
}
|
||||
|
||||
async function conceptPlan(page, theme, name) {
|
||||
await conceptMaintenance(page, theme);
|
||||
|
||||
@@ -33,7 +33,7 @@ test.afterEach(async ({ page, request, browserName }) => {
|
||||
const PREVIEW_URL = '/prompt_manager/autotag/batch/preview';
|
||||
const MODEL_OPTIONS = ['WD14 SwinV2 · ~400 MB', 'WD14 ViT · ~400 MB', 'GGUF JoyCaption · ~4.5 GB', 'HuggingFace JoyCaption · ~16 GB'];
|
||||
const SUGGESTIONS = 'From 1 to 100: the most suggestions Generate shows for one image.';
|
||||
const BATCH = 'Add tags to each prompt from its linked output images with the model above. Review the scope first.';
|
||||
const BATCH = 'Tag each output image from its own pixels with the model above. Prompts are not changed. Review the scope first.';
|
||||
|
||||
/** Runs in the page: Auto-tagging's surface under `mainSelector`: rows, field, actions, operation, geometry and tokens. */
|
||||
function measureTagging(mainSelector) {
|
||||
@@ -272,11 +272,19 @@ function measureReview() {
|
||||
}
|
||||
|
||||
const number = value => value.toLocaleString('en-US');
|
||||
/** DEC11 (#379): the Time row the preview's estimate reads as (WD14 on the test server's processor). */
|
||||
function timeRow(plan) {
|
||||
const minutes = Math.max(1, Math.ceil(plan.estimate_seconds / 60));
|
||||
const text = minutes < 90 ? `about ${minutes} min`
|
||||
: `about ${Math.floor(minutes / 60)} h${minutes % 60 ? ` ${minutes % 60} min` : ''}`;
|
||||
return `${text} on a processor; a graphics card can be faster.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* D134: the server's batch preview in the concept's review: "Review tag output images" with "Tag output images" as its
|
||||
* `h3`, the model, policy, counts and settings in the concept's `dl.meta-grid` (Backup in the concept's wording), the
|
||||
* upper-bound sentence in the concept's `p.small`, the D55 backup box and Cancel / "Start tagging".
|
||||
* D134 (#379): the server's batch preview in the concept's review: "Review tag output images" with "Tag output images"
|
||||
* as its `h3`, the model, policy, image counts, time estimate and settings in the concept's `dl.meta-grid` (Backup in
|
||||
* the concept's wording), the estimate sentence in the concept's `p.small`, the D55 backup box and Cancel / "Start
|
||||
* tagging".
|
||||
*/
|
||||
function mapBatchConcept(reference, plan) {
|
||||
const [cancel, start] = reference.actions.buttons;
|
||||
@@ -286,13 +294,14 @@ function mapBatchConcept(reference, plan) {
|
||||
tree: ['h3', 'dl.meta-grid', 'p.small', 'div.notice', 'div.dialog-actions'],
|
||||
heading: { ...reference.heading, text: 'Tag output images' },
|
||||
grid: { ...reference.grid, rows: [['Model', 'WD14 SwinV2 · ~400 MB'], ['Existing tags', 'Merge with existing'],
|
||||
['Images linked', number(plan.images)], ['Prompts', number(plan.prompts)], ['Already tagged', number(plan.already_tagged)],
|
||||
['Prompts to tag', `up to ${number(plan.to_tag)}`], ['Settings', 'General 0.35 · Character 0.85'],
|
||||
['Images', number(plan.images)], ['Already auto-tagged', number(plan.already_tagged)],
|
||||
['Images to tag', `about ${number(plan.to_tag)}`], ['Time', timeRow(plan)],
|
||||
['Settings', `General 0.35 · Character 0.85 · up to ${number(plan.suggestion_limit)} tags per image`],
|
||||
['Backup', 'Create and verify before changes']] },
|
||||
backup: { tree: ['div', 'div.row-actions'], text: [['h3', 'Back up the database first (recommended)'],
|
||||
['p', 'Downloads a copy of the prompts database before this operation changes any records.']], buttons: ['Back up now'] },
|
||||
note: { ...reference.note, text: 'Nothing is written until you start. Missing files and files outside the output folders '
|
||||
+ 'are counted, so the prompts to tag are an upper bound; Activity reports what was tagged.' },
|
||||
note: { ...reference.note, text: 'Nothing is written until you start. The number to tag is an estimate: images changed '
|
||||
+ 'since they were tagged are tagged again, and missing files are skipped. Activity reports what was tagged.' },
|
||||
actions: { ...reference.actions, buttons: [cancel, { ...start, text: 'Start tagging' }] },
|
||||
};
|
||||
}
|
||||
@@ -308,8 +317,8 @@ async function conceptReview(page, theme) {
|
||||
|
||||
async function productionReview(page, theme) {
|
||||
await productionTagging(page, theme);
|
||||
// Every concept-fixture prompt with an image is already tagged, so under the saved Skip there is nothing to start
|
||||
// (the Cancel-only review). Merge reviews a batch with work: the full review the concept's shell is compared with.
|
||||
// Merge reviews every listed still image (#379): a batch with work, the full review the concept's shell is compared
|
||||
// with.
|
||||
await page.locator('#workspace-main #tag-existing').selectOption('merge');
|
||||
const answer = page.waitForResponse(response => response.url().endsWith(PREVIEW_URL));
|
||||
await page.locator('#workspace-main .operation').getByRole('button', { name: 'Review', exact: true }).click();
|
||||
@@ -326,8 +335,9 @@ for (const theme of THEMES) for (const [width, height] of SIZES) {
|
||||
expect(concept.tree).toEqual(['p.demo-note', 'h3', 'dl.meta-grid', 'p.small', 'div.dialog-actions']);
|
||||
await page.screenshot({ path: testInfo.outputPath(`concept-plan-review-${theme}-${width}.png`) });
|
||||
const { plan, production } = await productionReview(page, theme);
|
||||
// Under Merge every prompt with a linked image is tagged again: the batch has work to review.
|
||||
// Under Merge every listed still image is tagged again: the batch has work to review.
|
||||
expect(plan.to_tag).toBeGreaterThan(0);
|
||||
expect(plan.estimate_device).toBe('cpu');
|
||||
await page.screenshot({ path: testInfo.outputPath(`production-tagging-review-${theme}-${width}.png`) });
|
||||
expect(diffInventory(mapBatchConcept(concept, plan), production)).toEqual([]);
|
||||
});
|
||||
@@ -372,9 +382,17 @@ test('tag output images review comparison detects mutations', async ({ page }) =
|
||||
const expected = mapBatchConcept(concept, plan);
|
||||
const paths = async () => diffInventory(expected, await page.evaluate(measureReview)).map(item => item.path);
|
||||
expect(await paths()).toEqual([]);
|
||||
await page.evaluate(() => { document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[5].textContent = 'To tag'; });
|
||||
expect(await paths()).toEqual(['grid.rows.5.0']);
|
||||
await page.evaluate(() => { document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[5].textContent = 'Prompts to tag'; });
|
||||
// Every label of the grid is compared (#379's rows included): a changed one is named by its row.
|
||||
for (const [index, [term]] of expected.grid.rows.entries()) {
|
||||
await page.evaluate(([at, label]) => {
|
||||
document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[at].textContent = label;
|
||||
}, [index, `${term}!`]);
|
||||
expect(await paths()).toEqual([`grid.rows.${index}.0`]);
|
||||
await page.evaluate(([at, label]) => {
|
||||
document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[at].textContent = label;
|
||||
}, [index, term]);
|
||||
}
|
||||
expect(await paths()).toEqual([]);
|
||||
const undoGrid = await mutate(page, { css: 'dialog#modal dl.meta-grid { column-gap: 3px; }' });
|
||||
expect(await paths()).toEqual(expect.arrayContaining(['grid.columnGap']));
|
||||
await undoGrid();
|
||||
|
||||
@@ -42,8 +42,13 @@ test('actual gallery form saves a prompt that the admin page can edit and reload
|
||||
await page.locator(`[data-action="edit"][data-prompt-id="${id}"]`).click();
|
||||
const editor = page.locator(`.prompt-text[data-id="${id}"]`);
|
||||
await editor.fill('Browser edited portrait');
|
||||
// The page shows the edit at once and saves it after: reload only once the save answered (a reload that wins the
|
||||
// race abandons the PUT, as seen on WebKit in CI).
|
||||
const savedEdit = page.waitForResponse(item => item.request().method() === 'PUT'
|
||||
&& item.url().endsWith(`/prompt_manager/prompts/${id}`));
|
||||
await editor.press('Enter');
|
||||
await expect(page.locator('.prompt-text').filter({ hasText: 'Browser edited portrait' })).toBeVisible();
|
||||
expect((await savedEdit).ok()).toBe(true);
|
||||
await page.reload();
|
||||
await expect(page.locator('.prompt-text').filter({ hasText: 'Browser edited portrait' })).toBeVisible();
|
||||
const saved = await request.post('/prompt_manager/save', { data: { text: 'Browser saved landscape' } });
|
||||
|
||||
@@ -315,22 +315,28 @@ test('Preview tagging review without a selection toasts the D115 sentence', asyn
|
||||
await expect(tagDialog(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Tag output images reviews the scope and starts a batch job', async ({ page, request }) => {
|
||||
test('Tag output images reviews the images, tags each one and never a prompt (#379)', async ({ page, request }) => {
|
||||
const before = await serverJson(request, '/__test__/tagging/snapshot');
|
||||
await openTagging(page);
|
||||
// The fixture's one prompt is already tagged: under Merge it is tagged again with what its images give.
|
||||
await page.locator('#tag-existing').selectOption('merge');
|
||||
// The saved policy is Skip: no image is auto-tagged yet, so every listed still image is to tag.
|
||||
const previewed = page.waitForResponse(item => new URL(item.url()).pathname === PREVIEW_URL);
|
||||
await reviewButton(page).click();
|
||||
const answer = await previewed;
|
||||
expect(answer.request().postDataJSON()).toEqual({ model_type: 'wd14-swinv2', existing: 'merge' });
|
||||
expect(answer.request().postDataJSON()).toEqual({ model_type: 'wd14-swinv2', existing: 'skip' });
|
||||
const plan = await answer.json();
|
||||
// Four still images (two linked, a copy, an unlinked one); the video is not a target.
|
||||
expect([plan.images, plan.already_tagged, plan.to_tag]).toEqual([4, 0, 4]);
|
||||
expect(plan.prompts).toBeUndefined();
|
||||
await expect(dialog(page).locator('#modal-title')).toHaveText('Review tag output images');
|
||||
await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Tag output images');
|
||||
expect(await gridOf(page)).toEqual([['Model', 'WD14 SwinV2 · ~400 MB'], ['Existing tags', 'Merge with existing'],
|
||||
['Images linked', String(plan.images)], ['Prompts', String(plan.prompts)], ['Already tagged', String(plan.already_tagged)],
|
||||
['Prompts to tag', `up to ${plan.to_tag}`], ['Settings', 'General 0.35 · Character 0.85'],
|
||||
expect(await gridOf(page)).toEqual([['Model', 'WD14 SwinV2 · ~400 MB'], ['Existing tags', 'Skip already tagged'],
|
||||
['Images', '4'], ['Already auto-tagged', '0'], ['Images to tag', 'about 4'],
|
||||
['Time', 'about 1 min on a processor; a graphics card can be faster.'],
|
||||
['Settings', `General 0.35 · Character 0.85 · up to ${plan.suggestion_limit} tags per image`],
|
||||
['Backup', 'Create and verify before changes']]);
|
||||
await expect(dialog(page).locator('#modal-body > p.small')).toHaveText('Nothing is written until you start. The number to '
|
||||
+ 'tag is an estimate: images changed since they were tagged are tagged again, and missing files are skipped. '
|
||||
+ 'Activity reports what was tagged.');
|
||||
await expect(dialog(page).locator('#modal-body > .notice h3')).toHaveText('Back up the database first (recommended)');
|
||||
// The review wrote nothing.
|
||||
expect(await serverJson(request, '/__test__/tagging/snapshot')).toEqual(before);
|
||||
@@ -338,15 +344,23 @@ test('Tag output images reviews the scope and starts a batch job', async ({ page
|
||||
const started = page.waitForResponse(item => new URL(item.url()).pathname === '/prompt_manager/jobs/autotag');
|
||||
await dialog(page).getByRole('button', { name: 'Start tagging', exact: true }).click();
|
||||
const start = await started;
|
||||
expect(start.request().postDataJSON()).toEqual({ model_type: 'wd14-swinv2', existing: 'merge',
|
||||
expect(start.request().postDataJSON()).toEqual({ model_type: 'wd14-swinv2', existing: 'skip',
|
||||
options: { general_threshold: 0.35, character_threshold: 0.85 } });
|
||||
const { job } = await start.json();
|
||||
await expect(page).toHaveURL(`${workspace}#/activity/${job.id}`);
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Tag output images');
|
||||
const finished = await ended(request, job.id);
|
||||
expect(finished.state).toBe('finished');
|
||||
const after = await serverJson(request, '/__test__/tagging/snapshot');
|
||||
expect(after.prompt_tags.length).toBeGreaterThan(before.prompt_tags.length);
|
||||
expect(finished.status_line).toBe('Tagged 4 images');
|
||||
// Each image got its own tags; no prompt, pair or link row changed.
|
||||
expect(await serverJson(request, '/__test__/tagging/snapshot')).toEqual(before);
|
||||
|
||||
// Under Skip every image is now auto-tagged: nothing to start.
|
||||
await openTagging(page);
|
||||
await reviewButton(page).click();
|
||||
await expect(dialog(page).locator('#modal-body > p.small')).toHaveText('Every output image is already auto-tagged.');
|
||||
expect((await gridOf(page)).slice(2, 5)).toEqual([['Images', '4'], ['Already auto-tagged', '4'], ['Images to tag', 'about 0']]);
|
||||
await expect(dialog(page).locator('.dialog-actions button')).toHaveText(['Cancel']);
|
||||
});
|
||||
|
||||
test('Replace after review shows only Cancel with the reason', async ({ page, request }) => {
|
||||
@@ -355,18 +369,13 @@ test('Replace after review shows only Cancel with the reason', async ({ page, re
|
||||
await page.locator('#tag-existing').selectOption('replace_after_review');
|
||||
await reviewButton(page).click();
|
||||
await expect(dialog(page).locator('#modal-title')).toHaveText('Review tag output images');
|
||||
expect((await gridOf(page)).map(([term]) => term)).toEqual(['Model', 'Existing tags', 'Images linked', 'Prompts', 'Already tagged',
|
||||
'Prompts to tag', 'Settings', 'Backup']);
|
||||
expect((await gridOf(page)).map(([term]) => term)).toEqual(['Model', 'Existing tags', 'Images', 'Already auto-tagged',
|
||||
'Images to tag', 'Time', 'Settings', 'Backup']);
|
||||
await expect(dialog(page).locator('#modal-body > p.small')).toHaveText('Replacing tags needs a review of each image. '
|
||||
+ 'Use Preview tagging review, or choose Skip or Merge for a batch run.');
|
||||
await expect(dialog(page).locator('.dialog-actions button')).toHaveText(['Cancel']);
|
||||
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
// Under Skip the fixture's only prompt is already tagged: nothing to start either.
|
||||
await page.locator('#tag-existing').selectOption('skip');
|
||||
await reviewButton(page).click();
|
||||
await expect(dialog(page).locator('#modal-body > p.small')).toHaveText('Every prompt with an output image is already tagged.');
|
||||
await expect(dialog(page).locator('.dialog-actions button')).toHaveText(['Cancel']);
|
||||
expect(await serverJson(request, '/__test__/tagging/snapshot')).toEqual(before);
|
||||
expect((await serverJson(request, '/prompt_manager/jobs')).jobs.filter(item => item.kind === 'autotag')).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { seedConcept } from './concept-inventory.mjs';
|
||||
import { readCoverageTable, parseControl, CONTROL_EXEMPTIONS } from '../../tools/vendor/legacy-coverage.mjs';
|
||||
|
||||
// System › Overview (#249 System S2 Task 6) on the concept-shaped real fixture: the metrics, rows, Statistics dialog and
|
||||
// Health report read the server, never sample values (D93, D95, D96). The hooks are workspace_system_fixture.py's.
|
||||
@@ -794,3 +795,45 @@ test('reset restores defaults without touching other keys', async ({ page }) =>
|
||||
expect(stored).toEqual({ gridColumns: 5, defaultLimit: 200, thumbnailPromptDismissed: 'kept', workspaceLayout: 'grid',
|
||||
videoMute: true, showImageInfo: true });
|
||||
});
|
||||
|
||||
// TESTING.md "Legacy System capability coverage" (#249 System S12, #329): each `covered` row's native control is on its
|
||||
// System section, found by role and accessible name (a `term` by its text, a disclosure by its summary). Collapsed
|
||||
// `details` are opened first, so a control inside one counts once it is shown. tests/vendor/legacy-coverage.test.mjs
|
||||
// checks the rest of each row.
|
||||
test('every covered legacy System capability has its native control', async ({ page, request }) => {
|
||||
// Download is offered for a model that is not on disk ("Download again" otherwise): the selected default's file is
|
||||
// missing here.
|
||||
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: ['wd14-swinv2'] } })).toBeOK();
|
||||
try {
|
||||
await findControls(page);
|
||||
} finally {
|
||||
await request.post('/__test__/tagging/reset', { data: {} });
|
||||
}
|
||||
});
|
||||
|
||||
async function findControls(page) {
|
||||
const covered = readCoverageTable().filter(row => row.Status === 'covered')
|
||||
.map(row => ({ capability: row.Capability, ...parseControl(row['Native control']) }));
|
||||
// A covered row without a control is one of the recorded exemptions, never silently skipped.
|
||||
expect(covered.filter(row => !row.section).map(row => row.capability)).toEqual(Object.keys(CONTROL_EXEMPTIONS));
|
||||
const controls = covered.filter(row => row.section);
|
||||
expect(controls.length).toBeGreaterThan(40);
|
||||
const sections = [...new Set(controls.map(row => row.section))];
|
||||
for (const section of sections) {
|
||||
await page.goto(`${workspace}#/system/${section}`);
|
||||
const main = page.locator('#workspace-main');
|
||||
await expect(layoutOf(page).locator(':scope > *').first()).toBeVisible();
|
||||
for (const details of await main.locator('details:not([open]) > summary').all()) await details.click();
|
||||
for (const { capability, role, name, row } of controls.filter(item => item.section === section)) {
|
||||
// A name several rows share is looked up inside the row the table names.
|
||||
const within = row ? main.locator('.operation').filter({ has: page.locator(`h3:text-is("${row}")`) }) : main;
|
||||
const exact = new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`);
|
||||
// A `dt` (term) and a `details` disclosure (group) have no name from content: the term's text, the summary's.
|
||||
const control = role === 'term' ? within.getByRole('term').filter({ hasText: exact })
|
||||
: role === 'group' ? within.locator('details').filter({ has: page.locator(':scope > summary', { hasText: exact }) })
|
||||
.or(within.getByRole('group', { name, exact: true }))
|
||||
: within.getByRole(role, { name, exact: true });
|
||||
await expect(control.first(), `${capability}: ${section} · ${role} · "${name}"${row ? ` in "${row}"` : ''}`).toBeVisible();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,11 @@ real log folder. Every route refuses a log folder outside the server root.
|
||||
more INFO records older still), ``active`` bytes into the active log,
|
||||
rotated copies ``prompt_manager.log.<n>`` of the listed sizes (fixed
|
||||
times), on POSIX a symlink ``prompt_manager.log.<n+1>`` to a file outside
|
||||
the folder (``link``) and a user's ``prompt_manager.log.bak`` (``other``);
|
||||
the folder (``link``) and a user's ``prompt_manager.log.bak`` (``other``).
|
||||
``{mode: "concept"}`` reproduces the concept's Logs sample (app.js 695)
|
||||
for the comparison: the buffer holds only its four lines, the active log
|
||||
is 1.4 MB with three rotated copies ("prompt_manager.log · 1.4 MB · rotated
|
||||
copies: 3") and console logging is off, as the concept's toggle;
|
||||
- ``POST /__test__/logs/rotate`` rolls the copies over as the handler does
|
||||
(``.2`` → ``.3``, ``.1`` → ``.2``, a new ``.1``), so a reviewed plan no
|
||||
longer matches;
|
||||
@@ -48,6 +52,10 @@ LEVEL_RECORDS = (
|
||||
("09:30:03", logging.CRITICAL, "The database could not be opened"),
|
||||
)
|
||||
ROTATED_TIME = datetime.datetime(2026, 10, 25, 14, 2)
|
||||
# The concept's "prompt_manager.log · 1.4 MB · rotated copies: 3": the active
|
||||
# log stays under 1.45 MB while the server writes its own lines.
|
||||
CONCEPT_ACTIVE = int(1.4 * 1024 * 1024)
|
||||
CONCEPT_ROTATED = (3072, 2048, 1024)
|
||||
MAX_SEED_BYTES = 64 * 1024 * 1024
|
||||
DEFAULT_CONFIG = {
|
||||
"level": "INFO",
|
||||
@@ -152,7 +160,25 @@ def register_diagnostics_commands(app, routes, api, root: Path):
|
||||
with manager._buffer_lock:
|
||||
manager._log_buffer.clear()
|
||||
|
||||
def seed_concept() -> None:
|
||||
manager = _manager()
|
||||
log_dir = folder()
|
||||
manager.update_config({"console_logging": False})
|
||||
with manager._buffer_lock:
|
||||
manager._log_buffer.clear()
|
||||
for index, (time_text, level, message) in enumerate(CONCEPT_RECORDS):
|
||||
record = _record(time_text, level, message, index)
|
||||
manager.add_to_buffer(record, record.getMessage())
|
||||
with open(log_dir / ACTIVE, "ab") as handle:
|
||||
handle.write(b"x" * CONCEPT_ACTIVE)
|
||||
for number, size in enumerate(CONCEPT_ROTATED, start=1):
|
||||
moment = ROTATED_TIME - datetime.timedelta(hours=number)
|
||||
_touch(log_dir / f"{ACTIVE}.{number}", size, moment)
|
||||
|
||||
def seed(data) -> None:
|
||||
if data.get("mode") == "concept":
|
||||
seed_concept()
|
||||
return
|
||||
manager = _manager()
|
||||
log_dir = folder()
|
||||
with_records = data.get("records", True) is not False
|
||||
|
||||
@@ -360,19 +360,23 @@ test('Preview tagging review opens the Tag image dialog with the section’s val
|
||||
} finally { scope.dispose(); dom.restore(); }
|
||||
});
|
||||
|
||||
const PLAN = { images: 3, prompts: 2, already_tagged: 1, to_tag: 1,
|
||||
const PLAN = { images: 3, already_tagged: 1, to_tag: 2, estimate_seconds: 3.2, estimate_device: 'cpu', suggestion_limit: 10,
|
||||
model: { type: 'wd14-swinv2', label: 'WD14 SwinV2', size_label: '~400 MB', downloaded: true }, existing: 'skip',
|
||||
review_required: false, backup: { policy: 'on' }, disk: { free_bytes: 10 * 2 ** 30, required_bytes: 2 ** 20, sufficient: true } };
|
||||
|
||||
test('the batch review grid, body and Cancel-only reasons (D134, CUJ 4–5)', async () => {
|
||||
const { batchGrid, batchBody, batchCloseNote, existingLabel, REPLACE_NOTE, NOTHING_TO_TAG, NO_LINKED_IMAGES } = await tagging;
|
||||
test('the batch review grid, body and Cancel-only reasons (D134, #379, CUJ 4–5)', async () => {
|
||||
const { batchGrid, batchBody, batchCloseNote, existingLabel, REPLACE_NOTE, NOTHING_TO_TAG, NO_IMAGES } = await tagging;
|
||||
assert.deepEqual(batchGrid(PLAN, HOST), [['Model', 'WD14 SwinV2 · ~400 MB'], ['Existing tags', 'Skip already tagged'],
|
||||
['Images linked', '3'], ['Prompts', '2'], ['Already tagged', '1'], ['Prompts to tag', 'up to 1'],
|
||||
['Settings', 'General 0.35 · Character 0.85'], ['Backup', 'Create and verify before changes']]);
|
||||
['Images', '3'], ['Already auto-tagged', '1'], ['Images to tag', 'about 2'],
|
||||
['Time', 'about 1 min on a processor; a graphics card can be faster.'],
|
||||
['Settings', 'General 0.35 · Character 0.85 · up to 10 tags per image'], ['Backup', 'Create and verify before changes']]);
|
||||
const llm = { ...HOST, autotag_model_type: 'gguf' };
|
||||
assert.deepEqual(batchGrid({ ...PLAN, model: { type: 'gguf' }, backup: { policy: 'off' } }, llm).slice(-2),
|
||||
[['Settings', 'Template of 27 characters'], ['Backup', 'No safety backup (the policy is off)']]);
|
||||
assert.deepEqual(batchGrid({ ...PLAN, model: { type: 'gguf' }, backup: { policy: 'off' }, estimate_seconds: null,
|
||||
estimate_device: null, suggestion_limit: 4 }, llm).slice(-3),
|
||||
[['Time', 'Language-model engines take longer per image. Activity shows the time left once tagging starts.'],
|
||||
['Settings', 'Template of 27 characters · up to 4 tags per image'], ['Backup', 'No safety backup (the policy is off)']]);
|
||||
assert.equal(batchGrid({}, HOST)[2][1], '0');
|
||||
assert.equal(batchGrid({}, HOST)[6][1], 'General 0.35 · Character 0.85');
|
||||
assert.equal(existingLabel('odd'), 'odd');
|
||||
assert.deepEqual(batchBody(HOST), { model_type: 'wd14-swinv2', existing: 'skip',
|
||||
options: { general_threshold: 0.35, character_threshold: 0.85 } });
|
||||
@@ -385,9 +389,31 @@ test('the batch review grid, body and Cancel-only reasons (D134, CUJ 4–5)', as
|
||||
assert.equal(batchCloseNote({ ...PLAN, model: { ...PLAN.model, downloaded: false } }), 'WD14 SwinV2 is not downloaded. Download it first.');
|
||||
assert.equal(batchCloseNote({ ...PLAN, disk: { free_bytes: 2 ** 20, required_bytes: 2 ** 21, sufficient: false } }),
|
||||
'Not enough free disk space: the safety backup needs about 2.0 MB and 1.0 MB is free. Nothing is written until there is room.');
|
||||
assert.equal(batchCloseNote({ ...PLAN, prompts: 0, to_tag: 0 }), NO_LINKED_IMAGES);
|
||||
assert.equal(batchCloseNote({ ...PLAN, images: 0, to_tag: 0 }), NO_IMAGES);
|
||||
assert.equal(batchCloseNote({ ...PLAN, images: undefined }), NO_IMAGES);
|
||||
assert.equal(NO_IMAGES, 'The output folders contain no images to tag.');
|
||||
assert.equal(batchCloseNote({ ...PLAN, to_tag: 0 }), NOTHING_TO_TAG);
|
||||
assert.equal(NOTHING_TO_TAG, 'Every prompt with an output image is already tagged.');
|
||||
assert.equal(NOTHING_TO_TAG, 'Every output image is already auto-tagged.');
|
||||
});
|
||||
|
||||
test('the review Time row (DEC11, #379): minutes, hours and the engine it runs on', async () => {
|
||||
const { durationText, timeText, TIME_UNKNOWN } = await tagging;
|
||||
assert.equal(durationText(1), 'about 1 min');
|
||||
assert.equal(durationText(0), 'about 1 min');
|
||||
assert.equal(durationText(89), 'about 89 min');
|
||||
assert.equal(durationText(90), 'about 1 h 30 min');
|
||||
assert.equal(durationText(120), 'about 2 h');
|
||||
assert.equal(durationText(631), 'about 10 h 31 min');
|
||||
assert.equal(durationText(Number.NaN), 'about 1 min');
|
||||
// 23,645 images at 1.6 s on a processor (the design's CUJ 1).
|
||||
assert.equal(timeText({ estimate_seconds: 37832, estimate_device: 'cpu' }),
|
||||
'about 10 h 31 min on a processor; a graphics card can be faster.');
|
||||
assert.equal(timeText({ estimate_seconds: 59, estimate_device: 'gpu' }), 'about 1 min on the graphics card.');
|
||||
assert.equal(timeText({ estimate_seconds: 0, estimate_device: 'cpu' }), 'about 1 min on a processor; a graphics card can be faster.');
|
||||
for (const plan of [{ estimate_seconds: null }, {}, { estimate_seconds: -1 }, { estimate_seconds: Infinity }, null]) {
|
||||
assert.equal(timeText(plan), TIME_UNKNOWN);
|
||||
}
|
||||
assert.equal(TIME_UNKNOWN, 'Language-model engines take longer per image. Activity shows the time left once tagging starts.');
|
||||
});
|
||||
|
||||
test('showBatchPlan opens the review with the backup box, or the Cancel-only review', async () => {
|
||||
|
||||
@@ -35,6 +35,8 @@ REVIEW_TABLES = (
|
||||
"media_review_items",
|
||||
"media_review_metadata",
|
||||
)
|
||||
# The auto-tag marks (#379) cite annotation bytes too; an old backup lacks them.
|
||||
CITING_TABLES = REVIEW_TABLES + ("media_image_autotags",)
|
||||
# The exact DDL the plan pins; the module must produce precisely these objects.
|
||||
DDL = {
|
||||
METADATA: (
|
||||
@@ -214,7 +216,7 @@ class AnnotationSchemaTests(unittest.TestCase):
|
||||
"""
|
||||
with closing(sqlite3.connect(path)) as raw, raw:
|
||||
raw.execute("PRAGMA foreign_keys=OFF")
|
||||
for table in REVIEW_TABLES + tuple(reversed(TABLES)):
|
||||
for table in CITING_TABLES + tuple(reversed(TABLES)):
|
||||
raw.execute(f"DROP TABLE {table}")
|
||||
|
||||
def test_fresh_install_and_reopen_is_idempotent(self):
|
||||
|
||||
@@ -575,6 +575,142 @@ class CurrentImageTagsTests(AnnotationTestCase):
|
||||
self.assertEqual(self.db.image_tag_locators(ids), set(locators))
|
||||
|
||||
|
||||
class AutotagMarkTests(AnnotationTestCase):
|
||||
"""The auto-tagged mark (#379): per byte record, written with the tags."""
|
||||
|
||||
def marked(self):
|
||||
conn = self.db.model.get_connection()
|
||||
return sorted(
|
||||
row[0] for row in conn.execute("SELECT * FROM media_image_autotags")
|
||||
)
|
||||
|
||||
def byte_id(self, loc, digest=DIGEST_A):
|
||||
conn = self.db.model.get_connection()
|
||||
return conn.execute(
|
||||
"SELECT b.id FROM media_annotation_bytes b JOIN media_annotation_files f "
|
||||
"ON f.id=b.file_id WHERE f.locator=? AND b.sha256=?",
|
||||
(loc, digest),
|
||||
).fetchone()[0]
|
||||
|
||||
def test_hand_tags_never_mark(self):
|
||||
loc = self.locator("a.png")
|
||||
self.replace(loc, tags=["keeper"])
|
||||
self.add(loc, tags=["more"])
|
||||
self.assertEqual(self.marked(), [])
|
||||
self.assertEqual(self.db.autotag_state({loc: (SIZE, MTIME)}), {loc: False})
|
||||
self.assertEqual(self.db.autotagged_locators(), set())
|
||||
|
||||
def test_model_union_marks_including_replayed_and_empty_results(self):
|
||||
loc = self.locator("a.png")
|
||||
receipt = self.add(loc, tags=["bird"], mark_autotagged=True)
|
||||
self.assertEqual((receipt["added"], receipt["replayed"]), (1, False))
|
||||
self.assertEqual(self.marked(), [self.byte_id(loc)])
|
||||
# A replayed union and an empty result still mark (idempotently).
|
||||
other = self.locator("b.png")
|
||||
self.replace(other, DIGEST_B, ["keeper"])
|
||||
replayed = self.add(other, DIGEST_B, ["KEEPER"], mark_autotagged=True)
|
||||
self.assertTrue(replayed["replayed"])
|
||||
empty = self.locator("c.png")
|
||||
nothing = self.add(empty, tags=[], mark_autotagged=True)
|
||||
self.assertEqual((nothing["tags"], nothing["added"]), ([], 0))
|
||||
self.add(empty, tags=[], mark_autotagged=True)
|
||||
self.assertEqual(
|
||||
self.marked(),
|
||||
sorted(
|
||||
[self.byte_id(loc), self.byte_id(other, DIGEST_B), self.byte_id(empty)]
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
self.db.autotag_state(
|
||||
{loc: (SIZE, MTIME), other: (SIZE, MTIME), empty: (SIZE, MTIME)}
|
||||
),
|
||||
{loc: True, other: True, empty: True},
|
||||
)
|
||||
self.schema_clean()
|
||||
|
||||
def test_empty_union_without_mark_is_refused(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.add(self.locator("a.png"), tags=[])
|
||||
self.assertEqual(self.counts()["media_annotation_files"], 0)
|
||||
|
||||
def test_tag_limit_union_writes_no_tag_and_no_mark(self):
|
||||
loc = self.locator("a.png")
|
||||
self.replace(loc, tags=[f"t{i}" for i in range(100)])
|
||||
before = self.rows(*ANNOTATION_TABLES, "tags")
|
||||
with self.assertRaises(AnnotationCommandError) as ctx:
|
||||
self.add(loc, tags=["one more"], mark_autotagged=True)
|
||||
self.assertEqual(ctx.exception.code, "tag-limit")
|
||||
self.assertEqual(self.rows(*ANNOTATION_TABLES, "tags"), before)
|
||||
self.assertEqual(self.marked(), [])
|
||||
|
||||
def test_replace_marks_on_saved_and_replayed_receipts_not_on_conflict(self):
|
||||
loc = self.locator("a.png")
|
||||
saved = self.replace(loc, tags=["a"], mark_autotagged=True)
|
||||
self.assertFalse(saved["replayed"])
|
||||
self.assertEqual(self.marked(), [self.byte_id(loc)])
|
||||
other = self.locator("b.png")
|
||||
self.replace(other, DIGEST_B, ["x"])
|
||||
replayed = self.replace(
|
||||
other, DIGEST_B, ["x"], revision=1, mark_autotagged=True
|
||||
)
|
||||
self.assertTrue(replayed["replayed"])
|
||||
third = self.locator("c.png")
|
||||
self.replace(third, tags=["y"])
|
||||
with self.assertRaises(AnnotationCommandError):
|
||||
self.replace(third, tags=["z"], revision=5, mark_autotagged=True)
|
||||
self.assertEqual(
|
||||
self.marked(), sorted([self.byte_id(loc), self.byte_id(other, DIGEST_B)])
|
||||
)
|
||||
|
||||
def test_mark_follows_the_bytes_not_the_file(self):
|
||||
loc = self.locator("a.png")
|
||||
self.add(loc, tags=["bird"], mark_autotagged=True)
|
||||
# The file's bytes changed: the new record is current and unmarked.
|
||||
self.replace(loc, DIGEST_B, ["hand"], mtime_ns=MTIME + 1)
|
||||
self.assertEqual(self.db.autotag_state({loc: (SIZE, MTIME + 1)}), {loc: False})
|
||||
self.assertEqual(self.db.autotag_state({loc: (SIZE, MTIME)}), {loc: True})
|
||||
# The review's estimate counts any marked record of the locator.
|
||||
self.assertEqual(self.db.autotagged_locators(), {loc})
|
||||
missing = self.locator("none.png")
|
||||
self.assertEqual(self.db.autotag_state({missing: (1, 1)}), {missing: False})
|
||||
|
||||
def test_state_and_tags_choose_the_same_current_record(self):
|
||||
loc = self.locator("a.png")
|
||||
# Two records with the same verified fingerprint: the newest wins.
|
||||
self.replace(loc, DIGEST_A, ["old bytes"])
|
||||
with patch.object(
|
||||
annotations_module, "utc_now_iso", return_value="2099-01-01T00:00:00"
|
||||
):
|
||||
self.add(loc, DIGEST_B, ["new bytes"], mark_autotagged=True)
|
||||
stats = {loc: (SIZE, MTIME)}
|
||||
self.assertEqual(self.db.current_image_tags(stats), {loc: ["new bytes"]})
|
||||
self.assertEqual(self.db.autotag_state(stats), {loc: True})
|
||||
with patch.object(
|
||||
annotations_module, "utc_now_iso", return_value="2199-01-01T00:00:00"
|
||||
):
|
||||
self.add(loc, DIGEST_A, ["older bytes again"])
|
||||
self.assertEqual(
|
||||
self.db.current_image_tags(stats), {loc: ["old bytes", "older bytes again"]}
|
||||
)
|
||||
self.assertEqual(self.db.autotag_state(stats), {loc: False})
|
||||
|
||||
def test_marks_never_touch_prompt_rows(self):
|
||||
conn = self.db.model.get_connection()
|
||||
prompt_id = self.db.save_prompt("a beach", tags=["beach"])
|
||||
before = [
|
||||
[tuple(row) for row in conn.execute(f"SELECT * FROM {table} ORDER BY 1")]
|
||||
for table in ("prompts", "prompt_tags")
|
||||
]
|
||||
self.add(self.locator("a.png"), tags=["bird"], mark_autotagged=True)
|
||||
self.replace(self.locator("b.png"), DIGEST_B, ["waves"], mark_autotagged=True)
|
||||
after = [
|
||||
[tuple(row) for row in conn.execute(f"SELECT * FROM {table} ORDER BY 1")]
|
||||
for table in ("prompts", "prompt_tags")
|
||||
]
|
||||
self.assertIsNotNone(prompt_id)
|
||||
self.assertEqual(after, before)
|
||||
|
||||
|
||||
class AnnotationIsolationTests(AnnotationTestCase):
|
||||
def test_cascade_from_vocabulary_delete_keeps_byte_records(self):
|
||||
loc = self.locator("a.png")
|
||||
|
||||
+9
-163
@@ -1893,13 +1893,15 @@ class TestTagOutputImagesPreview(BatchJobTestCase):
|
||||
self.assertEqual(self.service.generate_calls, [])
|
||||
self.assertEqual(self.service.load_calls, [])
|
||||
|
||||
async def test_preview_counts_skip_and_merge(self):
|
||||
async def test_preview_counts_listed_still_images_not_prompts(self):
|
||||
self._seed()
|
||||
skip = await (await self._preview(existing="skip")).json()
|
||||
# t, f1, f2 and m are listed; gone.png is missing, o.png is outside.
|
||||
self.assertEqual(
|
||||
{k: skip[k] for k in ("images", "prompts", "already_tagged", "to_tag")},
|
||||
{"images": 6, "prompts": 5, "already_tagged": 1, "to_tag": 4},
|
||||
{k: skip[k] for k in ("images", "already_tagged", "to_tag")},
|
||||
{"images": 4, "already_tagged": 0, "to_tag": 4},
|
||||
)
|
||||
self.assertNotIn("prompts", skip)
|
||||
self.assertEqual(
|
||||
skip["model"],
|
||||
{
|
||||
@@ -1916,7 +1918,7 @@ class TestTagOutputImagesPreview(BatchJobTestCase):
|
||||
set(skip["disk"]), {"free_bytes", "required_bytes", "sufficient"}
|
||||
)
|
||||
merge = await (await self._preview(existing="merge")).json()
|
||||
self.assertEqual(merge["to_tag"], 5)
|
||||
self.assertEqual(merge["to_tag"], 4)
|
||||
leaked = [s for s in _strings(skip) if _absolute(s)]
|
||||
self.assertEqual(leaked, [])
|
||||
|
||||
@@ -1924,7 +1926,7 @@ class TestTagOutputImagesPreview(BatchJobTestCase):
|
||||
self._seed()
|
||||
data = await (await self._preview(existing="replace_after_review")).json()
|
||||
self.assertTrue(data["review_required"])
|
||||
self.assertEqual(data["prompts"], 5)
|
||||
self.assertEqual(data["images"], 4)
|
||||
|
||||
async def test_preview_refuses_bad_bodies(self):
|
||||
for body in (
|
||||
@@ -1979,119 +1981,6 @@ class TestTagOutputImagesJob(BatchJobTestCase):
|
||||
"WD14 ViT is not downloaded. Download it in System › Auto-tagging.",
|
||||
)
|
||||
|
||||
async def test_job_writes_what_legacy_writes(self):
|
||||
ids = self._seed()
|
||||
await self._run_job(existing="skip")
|
||||
native = {name: sorted(self._tags(pid)) for name, pid in ids.items()}
|
||||
# The same library through the legacy batch, in a fresh database.
|
||||
for name, pid in ids.items():
|
||||
self.api.db.update_prompt_metadata(
|
||||
pid,
|
||||
tags=(
|
||||
["existing"]
|
||||
if name == "tagged"
|
||||
else (["auto-scanned"] if name == "marked" else [])
|
||||
),
|
||||
)
|
||||
resp = await self.client.post(
|
||||
"/prompt_manager/autotag/start?model_type=wd14-vit&skip_tagged=true"
|
||||
)
|
||||
self.assertEqual(self._sse_events(await resp.text())[-1]["type"], "complete")
|
||||
legacy = {name: sorted(self._tags(pid)) for name, pid in ids.items()}
|
||||
self.assertEqual(native, legacy)
|
||||
self.assertEqual(native["tagged"], ["existing"])
|
||||
self.assertEqual(native["fresh"], ["1girl", "smile"])
|
||||
|
||||
async def test_job_outcomes_follow_the_mapping(self):
|
||||
ids = self._seed()
|
||||
self.api.db.update_prompt_metadata(
|
||||
ids["marked"], tags=["auto-scanned", "1girl", "smile"]
|
||||
)
|
||||
|
||||
def details(job):
|
||||
return {
|
||||
group: sorted(row["detail"] for row in rows)
|
||||
for group, rows in job.outcomes.items()
|
||||
}
|
||||
|
||||
merged = await self._run_job(existing="merge")
|
||||
self.assertEqual(merged.state, "finished")
|
||||
self.assertEqual(
|
||||
details(merged),
|
||||
{
|
||||
"tagged": ["Added 2 tags", "Added 2 tags"],
|
||||
"unchanged": ["No new tags"],
|
||||
"skipped": [
|
||||
"File not found",
|
||||
"Outside the output folders",
|
||||
"Prompt handled by another image",
|
||||
],
|
||||
"errors": [],
|
||||
},
|
||||
)
|
||||
self.assertEqual(merged.status_line, "Tagged 2 prompts")
|
||||
|
||||
skipped = await self._run_job(existing="skip")
|
||||
self.assertEqual(details(skipped)["tagged"], [])
|
||||
self.assertEqual(details(skipped)["skipped"].count("Prompt already tagged"), 3)
|
||||
self.assertEqual(skipped.status_line, "Tagged 0 prompts")
|
||||
|
||||
async def test_tagging_errors_are_fixed_strings(self):
|
||||
self._seed()
|
||||
self.service.generate_error = RuntimeError("/srv/secret/model exploded")
|
||||
job = await self._run_job(existing="merge")
|
||||
self.assertTrue(job.outcomes["errors"])
|
||||
self.assertEqual(
|
||||
{r["detail"] for r in job.outcomes["errors"]}, {"Tagging failed"}
|
||||
)
|
||||
self.assertNotIn("/srv/secret", json.dumps(job.report()))
|
||||
|
||||
async def test_job_progress_is_determinate(self):
|
||||
self._seed()
|
||||
seen = []
|
||||
original = self.service.generate_tags
|
||||
|
||||
def generate(*args, **kwargs):
|
||||
job = self.api._jobs.active("autotag")
|
||||
seen.append((job.processed, job.total))
|
||||
return original(*args, **kwargs)
|
||||
|
||||
self.service.generate_tags = generate
|
||||
job = await self._run_job(existing="merge")
|
||||
self.assertEqual((job.processed, job.total, job.progress), (6, 6, 100))
|
||||
self.assertTrue(all(total == 6 for _p, total in seen[1:]), seen)
|
||||
self.assertTrue(job.cancellable)
|
||||
|
||||
async def test_job_cancel_stops_before_the_next_image_and_keeps_writes(self):
|
||||
ids = self._seed()
|
||||
started = threading.Event()
|
||||
proceed = threading.Event()
|
||||
calls = []
|
||||
|
||||
def slow(image_path, **kwargs):
|
||||
calls.append(image_path)
|
||||
started.set()
|
||||
proceed.wait(30)
|
||||
return ["slow"]
|
||||
|
||||
self.service.generate_tags = slow
|
||||
resp = await self._start_job(existing="merge")
|
||||
job = self.api._jobs.get((await resp.json())["job"]["id"])
|
||||
try:
|
||||
await asyncio.get_running_loop().run_in_executor(None, started.wait, 30)
|
||||
cancel = await self.client.post(f"/prompt_manager/jobs/{job.id}/cancel")
|
||||
self.assertTrue((await cancel.json())["cancellation_requested"])
|
||||
finally:
|
||||
proceed.set()
|
||||
await asyncio.wait_for(asyncio.shield(job.task), 30)
|
||||
self.assertEqual(job.state, "cancelled")
|
||||
self.assertEqual(job.status_line, "Cancelled. Tags already added were kept.")
|
||||
self.assertEqual(len(calls), 1)
|
||||
tagged = [n for n, pid in ids.items() if "slow" in self._tags(pid)]
|
||||
self.assertEqual(len(tagged), 1)
|
||||
self.assertIsNone(model_gate.holder())
|
||||
self.assertEqual(self.service.unload_calls, 0)
|
||||
|
||||
async def test_job_refused_while_legacy_batch_holds(self):
|
||||
async with self.api._jobs.hold(
|
||||
"autotag", "Tag output images (legacy page)", admission="records"
|
||||
@@ -2103,41 +1992,6 @@ class TestTagOutputImagesJob(BatchJobTestCase):
|
||||
self.assertEqual(data["running"]["title"], "Tag output images (legacy page)")
|
||||
self.assertIsNone(model_gate.holder())
|
||||
|
||||
async def test_legacy_refused_while_job_runs(self):
|
||||
self._seed()
|
||||
started, proceed = threading.Event(), threading.Event()
|
||||
|
||||
def slow(image_path, **kwargs):
|
||||
started.set()
|
||||
proceed.wait(30)
|
||||
return ["slow"]
|
||||
|
||||
self.service.generate_tags = slow
|
||||
resp = await self._start_job(existing="merge")
|
||||
job = self.api._jobs.get((await resp.json())["job"]["id"])
|
||||
try:
|
||||
await asyncio.get_running_loop().run_in_executor(None, started.wait, 30)
|
||||
self.assertTrue(model_gate.holder().startswith("autotag-job:"))
|
||||
legacy = await self.client.post("/prompt_manager/autotag/start")
|
||||
# Both legacy pages read a stream: the refusal is one error frame
|
||||
# that names the running job.
|
||||
self.assertEqual(legacy.status, 200)
|
||||
self.assertEqual(
|
||||
self._sse_events(await legacy.text()),
|
||||
[
|
||||
{
|
||||
"type": "error",
|
||||
"status": "error",
|
||||
"message": "Tag output images already running",
|
||||
}
|
||||
],
|
||||
)
|
||||
unload = await self.client.post("/prompt_manager/autotag/unload")
|
||||
self.assertEqual((await unload.json())["operation"], "Tag output images")
|
||||
finally:
|
||||
proceed.set()
|
||||
await asyncio.wait_for(asyncio.shield(job.task), 30)
|
||||
|
||||
async def test_job_releases_gate_when_start_is_refused(self):
|
||||
class Running:
|
||||
id = "other"
|
||||
@@ -2185,7 +2039,7 @@ class TestTagOutputImagesJob(BatchJobTestCase):
|
||||
("database folder", "the safety backup", 10, 1),
|
||||
10,
|
||||
)
|
||||
with patch("py.api.autotag_routes.disk_plan", return_value=short):
|
||||
with patch("py.api.image_tag_jobs.disk_plan", return_value=short):
|
||||
resp = await self._start_job()
|
||||
data = await resp.json()
|
||||
self.assertEqual(resp.status, 507)
|
||||
@@ -2203,14 +2057,6 @@ class TestTagOutputImagesJob(BatchJobTestCase):
|
||||
inside = [row for row in rows if row["root_index"] == 0]
|
||||
self.assertTrue(inside)
|
||||
|
||||
async def test_native_batch_leaves_custom_prompt_unchanged(self):
|
||||
self._seed()
|
||||
self.service.custom_prompt = "legacy page prompt"
|
||||
await self._run_job(existing="merge", model_type="gguf")
|
||||
self.assertEqual(self.service.custom_prompt, "legacy page prompt")
|
||||
prompts = {call[1] for call in self.service.generate_calls}
|
||||
self.assertEqual(prompts, {"Tags please."})
|
||||
|
||||
|
||||
# The legacy batch's per-image frames over BatchJobTestCase's library, newest
|
||||
# first, with every throttle check passing (pinned from main's generator).
|
||||
@@ -2342,7 +2188,7 @@ class TestTagOutputImagesEdges(BatchJobTestCase):
|
||||
async def test_unexpected_error_fails_the_job_with_a_reason(self):
|
||||
self._seed()
|
||||
with patch.object(
|
||||
self.api.db, "autotag_batch_candidates", side_effect=[[], RuntimeError("x")]
|
||||
self.api, "_autotag_prepass", side_effect=RuntimeError("/srv/x")
|
||||
):
|
||||
job = await self._run_job(existing="merge")
|
||||
self.assertEqual(job.state, "failed")
|
||||
|
||||
@@ -0,0 +1,696 @@
|
||||
"""Tag output images tags each image, never its prompt (#379, PR 1).
|
||||
|
||||
``POST /prompt_manager/jobs/autotag`` runs the ``image-tags`` loop over every
|
||||
still image the output folders list, linked to a prompt or not: each image
|
||||
gets its own model tags in its image tag set and is marked auto-tagged.
|
||||
"Skip already tagged" skips images whose current bytes are marked, counted
|
||||
before the loop without opening them. Prompt tags and prompt text never
|
||||
change. No real model is loaded: the engine is a fake that answers per image.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import ntpath
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
try:
|
||||
from test_api_images import make_png
|
||||
from test_image_tag_jobs import HookedTagger, ImageTagJobTestCase
|
||||
except ImportError:
|
||||
from tests.test_api_images import make_png
|
||||
from tests.test_image_tag_jobs import HookedTagger, ImageTagJobTestCase
|
||||
|
||||
from database.execution_history_schema import rotate_history_epoch
|
||||
from py import model_gate
|
||||
from py.api import admin as admin_module
|
||||
from py.api import image_tag_jobs as jobs_module
|
||||
from py.config import PromptManagerConfig
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
JOB = "/prompt_manager/jobs/autotag"
|
||||
PREVIEW = "/prompt_manager/autotag/batch/preview"
|
||||
WAIT = 30.0
|
||||
WD14 = {"general_threshold": 0.35, "character_threshold": 0.85}
|
||||
CANCELLED = "Cancelled. Tags already added were kept."
|
||||
_DRIVE = re.compile(r"^[A-Za-z]:[\\/]")
|
||||
|
||||
|
||||
class PerImageTagger(HookedTagger):
|
||||
"""Answers each image from its own bytes: ``by_bytes`` maps exact file
|
||||
contents to the names the "model" sees; any other image gets SCORED."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.by_bytes = {}
|
||||
|
||||
def generate_tag_suggestions(self, image_path, **kwargs):
|
||||
result = super().generate_tag_suggestions(image_path, **kwargs)
|
||||
names = self.by_bytes.get(self.read_bytes[-1])
|
||||
if names is None:
|
||||
return result
|
||||
return [(name, 0.9) for name in names][: kwargs["limit"]]
|
||||
|
||||
|
||||
def _strings(value):
|
||||
if isinstance(value, str):
|
||||
yield value
|
||||
elif isinstance(value, dict):
|
||||
for item in value.values():
|
||||
yield from _strings(item)
|
||||
elif isinstance(value, (list, tuple)):
|
||||
for item in value:
|
||||
yield from _strings(item)
|
||||
|
||||
|
||||
def _absolute(text):
|
||||
return posixpath.isabs(text) or ntpath.isabs(text) or bool(_DRIVE.match(text))
|
||||
|
||||
|
||||
class BatchTestCase(ImageTagJobTestCase):
|
||||
async def get_application(self):
|
||||
app = await super().get_application()
|
||||
self.tagger = PerImageTagger()
|
||||
patcher = patch("py.autotag.get_autotag_service", return_value=self.tagger)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
backup = PromptManagerConfig.SAFETY_BACKUP
|
||||
self.addCleanup(setattr, PromptManagerConfig, "SAFETY_BACKUP", backup)
|
||||
PromptManagerConfig.SAFETY_BACKUP = False
|
||||
return app
|
||||
|
||||
# -- helpers ---------------------------------------------------------
|
||||
|
||||
def image(self, name, color, sees=None):
|
||||
path = make_png(self.output_dir / name, size=(8, 6), color=color)
|
||||
if sees is not None:
|
||||
self.tagger.by_bytes[path.read_bytes()] = list(sees)
|
||||
return path
|
||||
|
||||
async def relist(self):
|
||||
"""Walk the output folder again (a cached listing is used as it is)."""
|
||||
await self.api._listing(self.output_dir, fresh=True)
|
||||
|
||||
def prompt(self, text, tags=()):
|
||||
return self.api.db.save_prompt(
|
||||
text=text,
|
||||
category=None,
|
||||
tags=list(tags),
|
||||
rating=None,
|
||||
prompt_hash=generate_prompt_hash(text),
|
||||
)
|
||||
|
||||
def link(self, prompt_id, path):
|
||||
return self.api.db.link_image_to_prompt(prompt_id, str(path))
|
||||
|
||||
def prompt_rows(self):
|
||||
conn = sqlite3.connect(self._temp_db.name)
|
||||
try:
|
||||
return {
|
||||
table: conn.execute(f"SELECT * FROM {table} ORDER BY 1,2").fetchall()
|
||||
for table in ("prompts", "prompt_tags")
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def marked(self):
|
||||
conn = sqlite3.connect(self._temp_db.name)
|
||||
try:
|
||||
return {
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
"SELECT f.locator FROM media_image_autotags m JOIN "
|
||||
"media_annotation_bytes b ON b.id=m.byte_id JOIN "
|
||||
"media_annotation_files f ON f.id=b.file_id"
|
||||
)
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
async def preview(self, existing="skip", model_type="wd14-vit"):
|
||||
response = await self.client.post(
|
||||
PREVIEW, json={"model_type": model_type, "existing": existing}
|
||||
)
|
||||
return response.status, await self.json_of(response)
|
||||
|
||||
async def start_batch(self, existing="skip", model_type="wd14-vit", options=None):
|
||||
if options is None:
|
||||
options = (
|
||||
dict(WD14)
|
||||
if model_type.startswith("wd14")
|
||||
else {"instructions": "Tags please."}
|
||||
)
|
||||
response = await self.client.post(
|
||||
JOB,
|
||||
json={"model_type": model_type, "existing": existing, "options": options},
|
||||
)
|
||||
return response.status, await self.json_of(response)
|
||||
|
||||
async def run_batch(self, **kwargs):
|
||||
status, data = await self.start_batch(**kwargs)
|
||||
self.assertEqual(status, 202, data)
|
||||
job = self.api._jobs.get(data["job"]["id"])
|
||||
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
|
||||
return job
|
||||
|
||||
|
||||
class TestPerImageWrites(BatchTestCase):
|
||||
async def test_two_images_of_one_prompt_get_their_own_tags_and_no_prompt_changes(
|
||||
self,
|
||||
):
|
||||
prompt_id = self.prompt("a girl at the beach", tags=["beach"])
|
||||
a = self.image("a.png", (10, 20, 30), sees=["1girl", "bird"])
|
||||
b = self.image("b.png", (40, 50, 60), sees=["1girl", "waves"])
|
||||
self.link(prompt_id, a)
|
||||
self.link(prompt_id, b)
|
||||
before = self.prompt_rows()
|
||||
|
||||
job = await self.run_batch(existing="skip")
|
||||
|
||||
self.assertEqual(job.state, "finished", job.status_line)
|
||||
self.assertEqual(self.stored("a.png")["tags"], ["1girl", "bird"])
|
||||
self.assertEqual(self.stored("b.png")["tags"], ["1girl", "waves"])
|
||||
self.assertEqual(self.prompt_rows(), before)
|
||||
self.assertEqual(self.api.db.get_prompt_by_id(prompt_id)["tags"], ["beach"])
|
||||
self.assertEqual(self.marked(), {self.locator("a.png"), self.locator("b.png")})
|
||||
self.assertEqual(
|
||||
job.counters, {"tagged": 2, "unchanged": 0, "skipped": 0, "errors": 0}
|
||||
)
|
||||
self.assertEqual(job.status_line, "Tagged 2 images")
|
||||
self.assertEqual((job.processed, job.total, job.progress), (2, 2, 100))
|
||||
self.assertEqual(job.scope["images"], 2)
|
||||
# Inference read the private captures, one per image.
|
||||
self.assertEqual(len(self.tagger.calls), 2)
|
||||
|
||||
async def test_unlinked_images_are_tagged_and_videos_are_not_targets(self):
|
||||
self.image("free.png", (1, 2, 3), sees=["tree"])
|
||||
(self.output_dir / "clip.mp4").write_bytes(b"\x00\x00\x00\x18ftypmp42" * 8)
|
||||
|
||||
job = await self.run_batch()
|
||||
|
||||
self.assertEqual(self.stored("free.png")["tags"], ["tree"])
|
||||
self.assertEqual(job.total, 1)
|
||||
self.assertEqual(job.status_line, "Tagged 1 image")
|
||||
rows = [row["path"] for rows in job.outcomes.values() for row in rows]
|
||||
self.assertNotIn("clip.mp4", rows)
|
||||
|
||||
async def test_hand_tags_do_not_block_a_skip_run(self):
|
||||
path = self.image("c.png", (7, 7, 7), sees=["bird"])
|
||||
self.saved_set("c.png", path.read_bytes(), ["keeper"])
|
||||
|
||||
job = await self.run_batch(existing="skip")
|
||||
|
||||
self.assertEqual(self.stored("c.png")["tags"], ["keeper", "bird"])
|
||||
self.assertEqual(job.counters["tagged"], 1)
|
||||
|
||||
async def test_skip_counts_marked_images_without_opening_them_and_merge_retags(
|
||||
self,
|
||||
):
|
||||
self.image("a.png", (1, 1, 1), sees=["bird"])
|
||||
self.image("b.png", (2, 2, 2), sees=["cat"])
|
||||
first = await self.run_batch()
|
||||
self.assertEqual(first.counters["tagged"], 2)
|
||||
self.tagger.calls.clear()
|
||||
|
||||
captures = []
|
||||
store = self.api._capture_store()
|
||||
real_capture = store.capture
|
||||
|
||||
def spy(*args, **kwargs):
|
||||
captures.append(args)
|
||||
return real_capture(*args, **kwargs)
|
||||
|
||||
with patch.object(store, "capture", side_effect=spy):
|
||||
skipped = await self.run_batch(existing="skip")
|
||||
self.assertEqual(captures, [])
|
||||
self.assertEqual(self.tagger.calls, [])
|
||||
self.assertEqual(
|
||||
skipped.counters, {"tagged": 0, "unchanged": 0, "skipped": 2, "errors": 0}
|
||||
)
|
||||
self.assertEqual(
|
||||
sorted(self.rows(skipped, "skipped")),
|
||||
[("a.png", "Already auto-tagged"), ("b.png", "Already auto-tagged")],
|
||||
)
|
||||
self.assertFalse(any("Already auto-tagged" in line for line in skipped.log))
|
||||
self.assertEqual((skipped.processed, skipped.total), (2, 2))
|
||||
self.assertEqual(skipped.status_line, "Tagged 0 images")
|
||||
|
||||
self.tagger.by_bytes[(self.output_dir / "a.png").read_bytes()] = ["bird", "sky"]
|
||||
merged = await self.run_batch(existing="merge")
|
||||
self.assertEqual(len(self.tagger.calls), 2)
|
||||
self.assertEqual(self.stored("a.png")["tags"], ["bird", "sky"])
|
||||
self.assertEqual(self.rows(merged, "tagged"), [("a.png", "Added 1 tag")])
|
||||
self.assertEqual(self.rows(merged, "unchanged"), [("b.png", "No new tags")])
|
||||
|
||||
async def test_changed_bytes_are_tagged_again(self):
|
||||
self.image("a.png", (1, 1, 1), sees=["bird"])
|
||||
await self.run_batch()
|
||||
self.image("a.png", (9, 9, 9), sees=["cat"])
|
||||
job = await self.run_batch(existing="skip")
|
||||
self.assertEqual(self.rows(job, "tagged"), [("a.png", "Added 1 tag")])
|
||||
self.assertEqual(self.stored("a.png")["tags"], ["cat"])
|
||||
|
||||
async def test_an_image_with_no_model_tags_is_marked_and_skipped_next_time(self):
|
||||
self.image("plain.png", (3, 3, 3), sees=[])
|
||||
job = await self.run_batch()
|
||||
self.assertEqual(self.rows(job, "unchanged"), [("plain.png", "No new tags")])
|
||||
self.assertEqual(self.marked(), {self.locator("plain.png")})
|
||||
again = await self.run_batch()
|
||||
self.assertEqual(
|
||||
self.rows(again, "skipped"), [("plain.png", "Already auto-tagged")]
|
||||
)
|
||||
|
||||
async def test_unsupported_still_is_marked_and_skipped_by_the_next_run(self):
|
||||
(self.output_dir / "broken.png").write_bytes(b"not an image")
|
||||
job = await self.run_batch()
|
||||
self.assertEqual(
|
||||
self.rows(job, "skipped"), [("broken.png", "Not a supported still image")]
|
||||
)
|
||||
self.assertEqual(self.marked(), {self.locator("broken.png")})
|
||||
again = await self.run_batch()
|
||||
self.assertEqual(
|
||||
self.rows(again, "skipped"), [("broken.png", "Already auto-tagged")]
|
||||
)
|
||||
self.assertEqual(self.tagger.calls, [])
|
||||
|
||||
async def test_too_large_is_skipped_before_any_copy(self):
|
||||
self.image("big.png", (5, 5, 5))
|
||||
self.api._capture_store().max_file = 16
|
||||
store = self.api._capture_store()
|
||||
with patch.object(store, "capture", side_effect=AssertionError("copied")):
|
||||
job = await self.run_batch()
|
||||
self.assertEqual(
|
||||
self.rows(job, "skipped"), [("big.png", "Too large to verify")]
|
||||
)
|
||||
self.assertEqual(self.marked(), set())
|
||||
|
||||
async def test_overlapping_roots_list_a_file_once(self):
|
||||
self.image("sub/a.png", (4, 4, 4), sees=["bird"])
|
||||
sub = self.output_dir / "sub"
|
||||
self.api._get_all_output_dirs = lambda: [self.output_dir, sub]
|
||||
job = await self.run_batch()
|
||||
self.assertEqual(job.total, 1)
|
||||
self.assertEqual(len(self.tagger.calls), 1)
|
||||
self.assertEqual(self.rows(job, "tagged"), [("sub/a.png", "Added 1 tag")])
|
||||
status, data = await self.preview()
|
||||
self.assertEqual((status, data["images"]), (200, 1))
|
||||
|
||||
async def test_rows_log_and_status_carry_no_absolute_paths(self):
|
||||
self.image("a.png", (1, 1, 1))
|
||||
(self.output_dir / "broken.png").write_bytes(b"not an image")
|
||||
job = await self.run_batch()
|
||||
leaked = [s for s in _strings(job.report()) if _absolute(s)]
|
||||
self.assertEqual(leaked, [])
|
||||
self.assertNotIn(str(self.output_dir), json.dumps(job.report()))
|
||||
|
||||
|
||||
class TestBatchLifecycle(BatchTestCase):
|
||||
async def test_cancel_after_one_image_keeps_it_and_the_rerun_skips_it(self):
|
||||
for n in range(3):
|
||||
self.image(f"{n}.png", (n, n, n), sees=[f"t{n}"])
|
||||
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)
|
||||
cancel = await self.client.post(f"/prompt_manager/jobs/{job.id}/cancel")
|
||||
self.assertEqual(cancel.status, 200)
|
||||
finally:
|
||||
self.tagger.block = None
|
||||
proceed.set()
|
||||
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
|
||||
self.assertEqual(job.state, "cancelled")
|
||||
self.assertEqual(job.status_line, CANCELLED)
|
||||
self.assertEqual(job.counters["tagged"], 1)
|
||||
self.assertEqual(len(self.marked()), 1)
|
||||
self.assertIsNone(model_gate.holder())
|
||||
|
||||
lines = []
|
||||
real_update = type(job).update
|
||||
|
||||
def spy(self_job, **kwargs):
|
||||
if kwargs.get("status_line"):
|
||||
lines.append(kwargs["status_line"])
|
||||
return real_update(self_job, **kwargs)
|
||||
|
||||
with patch.object(type(job), "update", spy):
|
||||
rerun = await self.run_batch()
|
||||
self.assertEqual(rerun.counters["skipped"], 1)
|
||||
self.assertEqual(rerun.counters["tagged"], 2)
|
||||
self.assertIn("Processing file 2 of 3", lines)
|
||||
self.assertIn("Processing file 3 of 3", lines)
|
||||
self.assertNotIn("Processing file 1 of 3", lines)
|
||||
self.assertEqual((rerun.processed, rerun.total), (3, 3))
|
||||
|
||||
async def test_cancel_in_each_phase_writes_nothing(self):
|
||||
self.image("a.png", (1, 1, 1))
|
||||
for phase in (
|
||||
"_safety_backup_step",
|
||||
"_prepare_tagging_model",
|
||||
"_autotag_targets",
|
||||
):
|
||||
with self.subTest(phase):
|
||||
real = getattr(self.api, phase)
|
||||
|
||||
async def cancelling(*args, _real=real, **kwargs):
|
||||
result = await _real(*args, **kwargs)
|
||||
self.api._jobs.active("autotag").cancelled = True
|
||||
return result
|
||||
|
||||
with patch.object(self.api, phase, side_effect=cancelling):
|
||||
job = await self.run_batch()
|
||||
self.assertEqual(job.state, "cancelled")
|
||||
self.assertEqual(job.status_line, CANCELLED)
|
||||
self.assertEqual(self.marked(), set())
|
||||
self.assertEqual(self.tagger.calls, [])
|
||||
self.assertIsNone(model_gate.holder())
|
||||
|
||||
async def test_cancel_between_prepass_chunks_keeps_counts(self):
|
||||
for n in range(3):
|
||||
self.image(f"{n}.png", (n, n, n))
|
||||
await self.run_batch()
|
||||
seen = []
|
||||
real = jobs_module._prepass_chunk
|
||||
|
||||
def chunk(db, items, skip_marked, max_file):
|
||||
result = real(db, items, skip_marked, max_file)
|
||||
job = self.api._jobs.active("autotag")
|
||||
seen.append(job.processed)
|
||||
job.cancelled = True # cancel after the first chunk
|
||||
return result
|
||||
|
||||
with (
|
||||
patch.object(jobs_module, "PREPASS_CHUNK", 1),
|
||||
patch.object(jobs_module, "_prepass_chunk", side_effect=chunk),
|
||||
):
|
||||
job = await self.run_batch()
|
||||
self.assertEqual(job.state, "cancelled")
|
||||
self.assertEqual(job.counters["skipped"], 1)
|
||||
self.assertEqual((job.processed, job.total), (1, 3))
|
||||
self.assertEqual(seen, [0])
|
||||
|
||||
async def test_the_job_ends_only_after_its_cleanup(self):
|
||||
self.image("a.png", (1, 1, 1))
|
||||
PromptManagerConfig.SAFETY_BACKUP = True
|
||||
states = []
|
||||
|
||||
async def prune(job, _db):
|
||||
states.append((job.state, model_gate.holder() is not None))
|
||||
|
||||
with (
|
||||
patch("py.api.maintenance.take_safety_backup", return_value="db.backup_x"),
|
||||
patch.object(self.api, "_prune_backup_pool", side_effect=prune),
|
||||
):
|
||||
job = await self.run_batch()
|
||||
self.assertEqual(job.state, "finished")
|
||||
self.assertEqual(states, [("running", True)])
|
||||
self.assertIsNone(model_gate.holder())
|
||||
|
||||
async def test_prepass_publishes_its_progress(self):
|
||||
for n in range(2):
|
||||
self.image(f"{n}.png", (n, n, n))
|
||||
await self.run_batch()
|
||||
published = []
|
||||
real = type(self.api._jobs).start
|
||||
|
||||
def start(registry, *args, **kwargs):
|
||||
job = real(registry, *args, **kwargs)
|
||||
original = job.publish
|
||||
job.publish = lambda payload: (
|
||||
published.append(dict(payload)),
|
||||
original(payload),
|
||||
)
|
||||
return job
|
||||
|
||||
with patch.object(type(self.api._jobs), "start", start):
|
||||
await self.run_batch()
|
||||
self.assertIn({"type": "progress", "progress": 100}, published)
|
||||
self.assertEqual(published[-1], {"type": "finished", "progress": 100})
|
||||
|
||||
async def test_restore_mid_run_fails_the_job(self):
|
||||
self.image("a.png", (1, 1, 1))
|
||||
self.image("b.png", (2, 2, 2))
|
||||
|
||||
def restore(_path):
|
||||
conn = sqlite3.connect(self._temp_db.name)
|
||||
try:
|
||||
rotate_history_epoch(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
self.tagger.on_generate = restore
|
||||
job = await self.run_batch()
|
||||
self.assertEqual(job.state, "failed")
|
||||
self.assertEqual(
|
||||
job.status_line, "Prompt storage was restored; start the job again."
|
||||
)
|
||||
|
||||
async def test_listing_error_fails_the_job(self):
|
||||
self.image("a.png", (1, 1, 1))
|
||||
with patch.object(
|
||||
self.api, "_get_gallery_files", side_effect=OSError("/secret/out")
|
||||
):
|
||||
job = await self.run_batch()
|
||||
self.assertEqual(job.state, "failed")
|
||||
self.assertEqual(
|
||||
job.status_line, "The output folders could not be listed. Try again."
|
||||
)
|
||||
self.assertNotIn("/secret", json.dumps(job.report()))
|
||||
|
||||
async def test_model_that_cannot_load_fails_the_job(self):
|
||||
self.image("a.png", (1, 1, 1))
|
||||
self.tagger.load_error = RuntimeError("no CUDA")
|
||||
job = await self.run_batch()
|
||||
self.assertEqual(job.state, "failed")
|
||||
self.assertEqual(
|
||||
job.status_line, "Auto-tagging failed. Check server logs for details."
|
||||
)
|
||||
self.assertIsNone(model_gate.holder())
|
||||
|
||||
async def test_zero_targets_finish_with_tagged_zero(self):
|
||||
job = await self.run_batch()
|
||||
self.assertEqual(job.state, "finished")
|
||||
self.assertEqual(job.status_line, "Tagged 0 images")
|
||||
self.assertEqual((job.processed, job.total), (0, 0))
|
||||
|
||||
async def test_runner_unloads_after_a_cancel_when_not_kept_in_memory(self):
|
||||
self.image("a.png", (1, 1, 1))
|
||||
owner = model_gate.new_owner("legacy-batch")
|
||||
self.assertTrue(model_gate.try_acquire(owner))
|
||||
run = jobs_module.AutotagRun(
|
||||
model_type="wd14-vit",
|
||||
existing="skip",
|
||||
options={**WD14, "use_gpu": True, "suggestion_limit": 10},
|
||||
keep_in_memory=False,
|
||||
backup_on=False,
|
||||
owner=owner,
|
||||
)
|
||||
|
||||
from py.api.jobs import AUTOTAG_GROUP_NAMES, Job as RealJob
|
||||
|
||||
job = RealJob(
|
||||
"autotag",
|
||||
"Tag output images",
|
||||
admission="records",
|
||||
groups=AUTOTAG_GROUP_NAMES,
|
||||
)
|
||||
real_targets = self.api._autotag_targets
|
||||
|
||||
async def cancel_after_listing():
|
||||
targets = await real_targets()
|
||||
job.cancelled = True
|
||||
return targets
|
||||
|
||||
with patch.object(
|
||||
self.api, "_autotag_targets", side_effect=cancel_after_listing
|
||||
):
|
||||
await self.api._run_autotag_batch(run, job)
|
||||
self.assertEqual(job.state, "cancelled")
|
||||
self.assertEqual(self.tagger.unload_calls, 1)
|
||||
self.assertIsNone(model_gate.holder())
|
||||
|
||||
async def test_time_left_uses_the_models_rate_only(self):
|
||||
for n in range(4):
|
||||
self.image(f"{n}.png", (n, n, n))
|
||||
await self.run_batch()
|
||||
for n in range(4, 8):
|
||||
self.image(f"{n}.png", (n, n, n))
|
||||
await self.relist()
|
||||
calls = []
|
||||
real = jobs_module._time_left
|
||||
|
||||
def spy(*args):
|
||||
calls.append(args)
|
||||
return real(*args)
|
||||
|
||||
with patch.object(jobs_module, "_time_left", side_effect=spy):
|
||||
job = await self.run_batch()
|
||||
self.assertEqual(job.counters["skipped"], 4)
|
||||
# (done, total, batches, elapsed): over the 4 images the model runs on.
|
||||
self.assertEqual(
|
||||
[(c[0], c[1], c[2]) for c in calls], [(n, 4, n) for n in range(4)]
|
||||
)
|
||||
# The shared D56 text is unchanged.
|
||||
self.assertEqual(admin_module._time_left(5, 10, 5, 300), " · about 5 min left")
|
||||
|
||||
|
||||
class TestStartRefusals(BatchTestCase):
|
||||
async def test_unavailable_model_and_bad_bodies(self):
|
||||
self.tagger.downloaded["wd14-vit"] = False
|
||||
status, data = await self.start_batch()
|
||||
self.assertEqual((status, data["code"]), (409, "model-unavailable"))
|
||||
status, data = await self.start_batch(existing="replace_after_review")
|
||||
self.assertEqual((status, data["code"]), (400, "review-required"))
|
||||
status, _data = await self.start_batch(options={**WD14, "suggestion_limit": 3})
|
||||
self.assertEqual(status, 400)
|
||||
self.assertEqual(self.api._jobs.list(), [])
|
||||
self.assertIsNone(model_gate.holder())
|
||||
|
||||
async def test_model_busy_when_the_gate_is_held(self):
|
||||
owner = model_gate.new_owner("image-annotations")
|
||||
self.assertTrue(model_gate.try_acquire(owner))
|
||||
try:
|
||||
status, data = await self.start_batch()
|
||||
finally:
|
||||
model_gate.release(owner)
|
||||
self.assertEqual((status, data["code"]), (409, "model-busy"))
|
||||
|
||||
|
||||
class TestBatchBeside(BatchTestCase):
|
||||
async def test_legacy_batch_and_unload_are_refused_while_the_job_runs(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)
|
||||
self.assertTrue(model_gate.holder().startswith("autotag-job:"))
|
||||
legacy = await self.client.post("/prompt_manager/autotag/start")
|
||||
self.assertEqual(legacy.status, 200)
|
||||
frames = [
|
||||
json.loads(line[len("data: ") :])
|
||||
for line in (await legacy.text()).splitlines()
|
||||
if line.startswith("data: ")
|
||||
]
|
||||
self.assertEqual(
|
||||
frames,
|
||||
[
|
||||
{
|
||||
"type": "error",
|
||||
"status": "error",
|
||||
"message": "Tag output images already running",
|
||||
}
|
||||
],
|
||||
)
|
||||
unload = await self.client.post("/prompt_manager/autotag/unload")
|
||||
self.assertEqual((await unload.json())["operation"], "Tag output images")
|
||||
finally:
|
||||
self.tagger.block = None
|
||||
proceed.set()
|
||||
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
|
||||
self.assertEqual(job.state, "finished")
|
||||
|
||||
async def test_language_model_batch_passes_instructions_per_call(self):
|
||||
self.image("a.png", (1, 1, 1))
|
||||
job = await self.run_batch(model_type="gguf")
|
||||
self.assertEqual(job.state, "finished", job.status_line)
|
||||
self.assertEqual(self.tagger.calls[0]["prompt"], "Tags please.")
|
||||
self.assertEqual(self.tagger.custom_prompt, "legacy prompt")
|
||||
|
||||
async def test_inference_errors_are_fixed_strings_and_not_marked(self):
|
||||
self.image("a.png", (1, 1, 1))
|
||||
|
||||
def explode(_path):
|
||||
raise RuntimeError("/srv/secret/model exploded")
|
||||
|
||||
self.tagger.on_generate = explode
|
||||
job = await self.run_batch()
|
||||
self.assertEqual(
|
||||
self.rows(job, "errors"),
|
||||
[("a.png", "Auto-tagging failed. Check server logs for details.")],
|
||||
)
|
||||
self.assertNotIn("/srv/secret", json.dumps(job.report()))
|
||||
self.assertEqual(self.marked(), set())
|
||||
|
||||
|
||||
class TestPreview(BatchTestCase):
|
||||
async def test_preview_counts_images_and_estimates_time(self):
|
||||
prompt_id = self.prompt("p")
|
||||
a = self.image("a.png", (1, 1, 1))
|
||||
self.image("b.png", (2, 2, 2))
|
||||
self.image("c.png", (3, 3, 3))
|
||||
self.link(prompt_id, a)
|
||||
(self.output_dir / "clip.mp4").write_bytes(b"\x00\x00\x00\x18ftypmp42" * 8)
|
||||
status, fresh = await self.preview()
|
||||
self.assertEqual(status, 200, fresh)
|
||||
self.assertEqual(
|
||||
{k: fresh[k] for k in ("images", "already_tagged", "to_tag")},
|
||||
{"images": 3, "already_tagged": 0, "to_tag": 3},
|
||||
)
|
||||
self.assertNotIn("prompts", fresh)
|
||||
self.assertEqual(fresh["suggestion_limit"], 10)
|
||||
self.assertEqual(fresh["estimate_device"], "cpu")
|
||||
self.assertAlmostEqual(fresh["estimate_seconds"], 3 * 1.6)
|
||||
|
||||
self.tagger.block = None
|
||||
await self.run_batch()
|
||||
self.image("d.png", (4, 4, 4))
|
||||
await self.relist()
|
||||
skip = (await self.preview("skip"))[1]
|
||||
self.assertEqual(
|
||||
{k: skip[k] for k in ("images", "already_tagged", "to_tag")},
|
||||
{"images": 4, "already_tagged": 3, "to_tag": 1},
|
||||
)
|
||||
merge = (await self.preview("merge"))[1]
|
||||
self.assertEqual(merge["to_tag"], 4)
|
||||
for model_type in ("gguf", "hf"):
|
||||
llm = (await self.preview(model_type=model_type))[1]
|
||||
self.assertIsNone(llm["estimate_seconds"])
|
||||
self.assertIsNone(llm["estimate_device"])
|
||||
self.assertEqual([s for s in _strings(skip) if _absolute(s)], [])
|
||||
|
||||
async def test_preview_uses_the_graphics_card_rate_when_wd14_runs_on_cuda(self):
|
||||
self.image("a.png", (1, 1, 1))
|
||||
self.tagger.wd14_device = lambda: "gpu"
|
||||
data = (await self.preview())[1]
|
||||
self.assertEqual(data["estimate_device"], "gpu")
|
||||
from py.tagging_defaults import WD14_GPU_SECONDS_PER_IMAGE
|
||||
|
||||
self.assertAlmostEqual(data["estimate_seconds"], WD14_GPU_SECONDS_PER_IMAGE)
|
||||
|
||||
async def test_preview_writes_nothing_and_opens_no_media_file(self):
|
||||
self.image("a.png", (1, 1, 1))
|
||||
await self.run_batch()
|
||||
self.image("b.png", (2, 2, 2))
|
||||
await self.relist() # the listing, primed
|
||||
files = [self._temp_db.name + suffix for suffix in ("", "-wal")]
|
||||
before = [Path(f).read_bytes() if os.path.exists(f) else None for f in files]
|
||||
media = str(self.output_dir)
|
||||
real_open, real_stat = open, os.stat
|
||||
|
||||
def guarded_open(file, *args, **kwargs):
|
||||
if str(file).startswith(media):
|
||||
raise AssertionError(f"opened {file}")
|
||||
return real_open(file, *args, **kwargs)
|
||||
|
||||
def guarded_stat(path, *args, **kwargs):
|
||||
if str(path).startswith(media) and str(path) != media:
|
||||
raise AssertionError(f"stat {path}")
|
||||
return real_stat(path, *args, **kwargs)
|
||||
|
||||
with patch("builtins.open", guarded_open), patch("os.stat", guarded_stat):
|
||||
status, data = await self.preview()
|
||||
self.assertEqual(status, 200, data)
|
||||
after = [Path(f).read_bytes() if os.path.exists(f) else None for f in files]
|
||||
self.assertEqual(after, before)
|
||||
self.assertEqual(data["to_tag"], 1)
|
||||
self.assertEqual(self.tagger.calls[1:], [])
|
||||
@@ -884,3 +884,29 @@ class TestImageConversion(EngineTestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestWd14Device(EngineTestCase):
|
||||
"""Where WD14 runs, for the Tag output images estimate (#379)."""
|
||||
|
||||
def test_loaded_session_answers_from_its_first_provider(self):
|
||||
cpu = FakeOnnxSession([0.5] * len(WD14_TAGS))
|
||||
service = self._service(("wd14", (cpu, WD14_TAGS)))
|
||||
_touch_model_files(self.models_dir, "wd14-vit")
|
||||
service.load_model("wd14-vit")
|
||||
self.assertEqual(service.wd14_device(), "cpu")
|
||||
cpu.get_providers = lambda: ["CUDAExecutionProvider", "CPUExecutionProvider"]
|
||||
self.assertEqual(service.wd14_device(), "gpu")
|
||||
|
||||
def test_without_a_session_the_available_providers_answer(self):
|
||||
service = self._service(None)
|
||||
fake = types.SimpleNamespace(
|
||||
get_available_providers=lambda: ["CUDAExecutionProvider"]
|
||||
)
|
||||
with patch.dict(sys.modules, {"onnxruntime": fake}):
|
||||
self.assertEqual(service.wd14_device(), "gpu")
|
||||
fake.get_available_providers = lambda: ["CPUExecutionProvider"]
|
||||
with patch.dict(sys.modules, {"onnxruntime": fake}):
|
||||
self.assertEqual(service.wd14_device(), "cpu")
|
||||
with patch.dict(sys.modules, {"onnxruntime": None}):
|
||||
self.assertEqual(service.wd14_device(), "cpu")
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
"""Auto-tagged marks schema group (#379): install, validation and restore."""
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from database.models import PromptModel
|
||||
|
||||
try: # guarded so a missing module fails assertions, not test loading
|
||||
from database.autotag_schema import (
|
||||
AUTOTAG_TABLE,
|
||||
autotag_schema_error,
|
||||
autotag_without_annotations_error,
|
||||
migrate_autotag_state,
|
||||
)
|
||||
except ImportError:
|
||||
AUTOTAG_TABLE = None
|
||||
autotag_schema_error = None
|
||||
autotag_without_annotations_error = None
|
||||
migrate_autotag_state = None
|
||||
|
||||
TABLE = "media_image_autotags"
|
||||
DDL = (
|
||||
f"CREATE TABLE {TABLE} (byte_id TEXT PRIMARY KEY "
|
||||
"REFERENCES media_annotation_bytes(id) ON DELETE CASCADE) WITHOUT ROWID"
|
||||
)
|
||||
ANNOTATION_TABLES = (
|
||||
"media_annotation_metadata",
|
||||
"media_annotation_files",
|
||||
"media_annotation_bytes",
|
||||
"media_image_tags",
|
||||
)
|
||||
REVIEW_TABLES = (
|
||||
"media_review_metadata",
|
||||
"media_review_items",
|
||||
"media_review_decisions",
|
||||
)
|
||||
FILE_ID = "0f8e4b2a-1c3d-4e5f-8a9b-0c1d2e3f4a5b"
|
||||
BYTE_ID = "6a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d"
|
||||
OTHER_ID = "7b2c3d4e-5f6a-4b7c-9d0e-1f2a3b4c5d6e"
|
||||
DIGEST = hashlib.sha256(b"img").hexdigest()
|
||||
NOW = "2026-10-27T00:00:00.000000+00:00"
|
||||
NEEDS_ANNOTATIONS = "Image auto-tag marks need the image annotation tables"
|
||||
INVALID_VALUES = "Image auto-tag marks contain invalid values"
|
||||
INVALID_SCHEMA = "Image auto-tag marks have an invalid schema"
|
||||
|
||||
|
||||
def _compact(sql):
|
||||
return re.sub(r"\s+", "", sql or "").lower()
|
||||
|
||||
|
||||
def catalog(conn):
|
||||
return {
|
||||
(kind, name, _compact(sql))
|
||||
for kind, name, sql in conn.execute(
|
||||
"SELECT type,name,sql FROM sqlite_master WHERE name=? OR tbl_name=?",
|
||||
(TABLE, TABLE),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def marks(conn):
|
||||
return [tuple(row) for row in conn.execute(f"SELECT byte_id FROM {TABLE}")]
|
||||
|
||||
|
||||
def insert_byte(conn, file_id=FILE_ID, byte_id=BYTE_ID, locator="/out/img.png"):
|
||||
conn.execute(
|
||||
"INSERT INTO media_annotation_files VALUES(?,?,?)", (file_id, locator, NOW)
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO media_annotation_bytes VALUES(?,?,?,?,?,?,?,0)",
|
||||
(byte_id, file_id, DIGEST, 3, 1_700_000_000_000_000_000, NOW, NOW),
|
||||
)
|
||||
|
||||
|
||||
class AutotagSchemaTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.addCleanup(assert_closed, self, self.temp.name)
|
||||
self.addCleanup(PromptModel.reset_schema_cache)
|
||||
self.models = []
|
||||
self.addCleanup(self.close_models)
|
||||
self.path = str(Path(self.temp.name) / "live.db")
|
||||
|
||||
def close_models(self):
|
||||
for model in self.models:
|
||||
model.close_all()
|
||||
|
||||
def model(self, path=None):
|
||||
model = PromptModel(path or self.path)
|
||||
self.models.append(model)
|
||||
return model
|
||||
|
||||
def require_module(self):
|
||||
self.assertIsNotNone(
|
||||
autotag_schema_error, "database.autotag_schema is not implemented"
|
||||
)
|
||||
|
||||
def raw(self, path, *statements, foreign_keys=False):
|
||||
with closing(sqlite3.connect(path)) as raw, raw:
|
||||
raw.execute(f"PRAGMA foreign_keys={'ON' if foreign_keys else 'OFF'}")
|
||||
for statement in statements:
|
||||
if isinstance(statement, tuple):
|
||||
raw.execute(*statement)
|
||||
else:
|
||||
raw.execute(statement)
|
||||
|
||||
def closed_current(self, name="current.db", *, with_mark=True):
|
||||
"""A closed, current database with one byte record (and its mark)."""
|
||||
path = str(Path(self.temp.name) / name)
|
||||
model = self.model(path)
|
||||
with model.get_connection() as conn:
|
||||
insert_byte(conn)
|
||||
if with_mark:
|
||||
conn.execute(f"INSERT INTO {TABLE} VALUES(?)", (BYTE_ID,))
|
||||
model.close_all()
|
||||
return path
|
||||
|
||||
def test_fresh_install_has_exactly_the_pinned_table(self):
|
||||
self.require_module()
|
||||
self.assertEqual(AUTOTAG_TABLE, TABLE)
|
||||
model = self.model()
|
||||
with model.get_connection() as conn:
|
||||
# WITHOUT ROWID with a primary key: no sqlite_autoindex row.
|
||||
self.assertEqual(catalog(conn), {("table", TABLE, _compact(DDL))})
|
||||
self.assertEqual(
|
||||
[tuple(row) for row in conn.execute(f"PRAGMA table_info({TABLE})")],
|
||||
[(0, "byte_id", "TEXT", 1, None, 1)],
|
||||
)
|
||||
self.assertIsNone(autotag_schema_error(conn, allow_missing=False))
|
||||
self.assertEqual(marks(conn), [])
|
||||
self.assertEqual(
|
||||
PromptModel.verify_database_file(self.path, require_library_state=True),
|
||||
(True, "ok"),
|
||||
)
|
||||
|
||||
def test_reopen_and_rerun_are_idempotent(self):
|
||||
self.require_module()
|
||||
path = self.closed_current()
|
||||
for _ in range(2):
|
||||
PromptModel.reset_schema_cache()
|
||||
model = self.model(path)
|
||||
with model.get_connection() as conn:
|
||||
migrate_autotag_state(conn)
|
||||
self.assertEqual(marks(conn), [(BYTE_ID,)])
|
||||
self.assertEqual(catalog(conn), {("table", TABLE, _compact(DDL))})
|
||||
model.close_all()
|
||||
|
||||
def test_old_database_without_the_table_gains_it_and_keeps_its_rows(self):
|
||||
self.require_module()
|
||||
path = self.closed_current(with_mark=False)
|
||||
self.raw(path, f"DROP TABLE {TABLE}")
|
||||
with closing(sqlite3.connect(path)) as raw:
|
||||
before = raw.execute("SELECT * FROM media_annotation_bytes").fetchall()
|
||||
self.assertIsNone(autotag_schema_error(raw))
|
||||
self.assertEqual(
|
||||
autotag_schema_error(raw, allow_missing=False),
|
||||
"Missing image auto-tag marks schema",
|
||||
)
|
||||
self.assertTrue(PromptModel.verify_database_file(path)[0])
|
||||
self.assertEqual(
|
||||
PromptModel.verify_database_file(path, require_library_state=True),
|
||||
(False, "Missing image auto-tag marks schema"),
|
||||
)
|
||||
PromptModel.reset_schema_cache()
|
||||
model = self.model(path)
|
||||
with model.get_connection() as conn:
|
||||
self.assertEqual(catalog(conn), {("table", TABLE, _compact(DDL))})
|
||||
self.assertEqual(
|
||||
[
|
||||
tuple(row)
|
||||
for row in conn.execute("SELECT * FROM media_annotation_bytes")
|
||||
],
|
||||
before,
|
||||
)
|
||||
model.close_all()
|
||||
|
||||
def test_table_without_annotation_group_is_refused(self):
|
||||
self.require_module()
|
||||
path = self.closed_current(with_mark=False)
|
||||
self.raw(
|
||||
path,
|
||||
*(
|
||||
f"DROP TABLE {t}"
|
||||
for t in REVIEW_TABLES + tuple(reversed(ANNOTATION_TABLES))
|
||||
),
|
||||
)
|
||||
with closing(sqlite3.connect(path)) as raw:
|
||||
self.assertEqual(autotag_without_annotations_error(raw), NEEDS_ANNOTATIONS)
|
||||
for allow_missing in (True, False):
|
||||
self.assertEqual(
|
||||
autotag_schema_error(raw, allow_missing=allow_missing),
|
||||
NEEDS_ANNOTATIONS,
|
||||
)
|
||||
self.assertEqual(
|
||||
PromptModel.verify_database_file(path), (False, NEEDS_ANNOTATIONS)
|
||||
)
|
||||
PromptModel.reset_schema_cache()
|
||||
with self.assertRaises(sqlite3.DatabaseError) as raised:
|
||||
self.model(path)
|
||||
self.assertIn(NEEDS_ANNOTATIONS, str(raised.exception))
|
||||
|
||||
def test_invalid_values_are_refused_without_repair(self):
|
||||
self.require_module()
|
||||
cases = {
|
||||
"not-a-uuid": (f"INSERT INTO {TABLE} VALUES('not-a-uuid')",),
|
||||
"upper-uuid": ((f"INSERT INTO {TABLE} VALUES(?)", (OTHER_ID.upper(),)),),
|
||||
"dangling": ((f"INSERT INTO {TABLE} VALUES(?)", (OTHER_ID,)),),
|
||||
}
|
||||
for name, statements in cases.items():
|
||||
with self.subTest(name):
|
||||
path = self.closed_current(f"{name}.db", with_mark=False)
|
||||
self.raw(path, *statements)
|
||||
with closing(sqlite3.connect(path)) as raw:
|
||||
self.assertEqual(autotag_schema_error(raw), INVALID_VALUES)
|
||||
before = marks(raw)
|
||||
self.assertEqual(
|
||||
PromptModel.verify_database_file(path), (False, INVALID_VALUES)
|
||||
)
|
||||
PromptModel.reset_schema_cache()
|
||||
with self.assertRaises(sqlite3.DatabaseError):
|
||||
self.model(path)
|
||||
with closing(sqlite3.connect(path)) as raw:
|
||||
self.assertEqual(marks(raw), before)
|
||||
|
||||
def test_altered_definitions_are_refused(self):
|
||||
self.require_module()
|
||||
cases = {
|
||||
"rowid": f"CREATE TABLE {TABLE} (byte_id TEXT PRIMARY KEY "
|
||||
"REFERENCES media_annotation_bytes(id) ON DELETE CASCADE)",
|
||||
"restrict": f"CREATE TABLE {TABLE} (byte_id TEXT PRIMARY KEY "
|
||||
"REFERENCES media_annotation_bytes(id) ON DELETE RESTRICT) WITHOUT ROWID",
|
||||
"extra column": f"CREATE TABLE {TABLE} (byte_id TEXT PRIMARY KEY "
|
||||
"REFERENCES media_annotation_bytes(id) ON DELETE CASCADE, engine TEXT) "
|
||||
"WITHOUT ROWID",
|
||||
}
|
||||
for name, ddl in cases.items():
|
||||
with self.subTest(name):
|
||||
path = self.closed_current(f"{name}.db", with_mark=False)
|
||||
self.raw(path, f"DROP TABLE {TABLE}", ddl)
|
||||
with closing(sqlite3.connect(path)) as raw:
|
||||
self.assertEqual(autotag_schema_error(raw), INVALID_SCHEMA)
|
||||
self.assertFalse(PromptModel.verify_database_file(path)[0])
|
||||
path = self.closed_current("index.db", with_mark=False)
|
||||
self.raw(path, f"CREATE INDEX idx_extra ON {TABLE}(byte_id)")
|
||||
with closing(sqlite3.connect(path)) as raw:
|
||||
self.assertEqual(autotag_schema_error(raw), INVALID_SCHEMA)
|
||||
|
||||
def test_deleting_the_byte_record_cascades_to_its_mark(self):
|
||||
self.require_module()
|
||||
model = self.model()
|
||||
with model.get_connection() as conn:
|
||||
insert_byte(conn)
|
||||
conn.execute(f"INSERT INTO {TABLE} VALUES(?)", (BYTE_ID,))
|
||||
conn.execute(f"INSERT OR IGNORE INTO {TABLE} VALUES(?)", (BYTE_ID,))
|
||||
self.assertEqual(marks(conn), [(BYTE_ID,)])
|
||||
conn.execute("DELETE FROM media_annotation_files")
|
||||
self.assertEqual(marks(conn), [])
|
||||
|
||||
def test_restore_of_a_pre_change_backup_migrates(self):
|
||||
self.require_module()
|
||||
src = self.closed_current("backup.db", with_mark=False)
|
||||
self.raw(src, f"DROP TABLE {TABLE}")
|
||||
live = self.model()
|
||||
live.restore_from_file(src)
|
||||
with closing(sqlite3.connect(self.path)) as raw:
|
||||
self.assertEqual(catalog(raw), {("table", TABLE, _compact(DDL))})
|
||||
self.assertEqual(
|
||||
raw.execute("SELECT id FROM media_annotation_bytes").fetchall(),
|
||||
[(BYTE_ID,)],
|
||||
)
|
||||
self.assertEqual(
|
||||
PromptModel.verify_database_file(self.path, require_library_state=True),
|
||||
(True, "ok"),
|
||||
)
|
||||
with closing(sqlite3.connect(src)) as raw: # the source is untouched
|
||||
self.assertEqual(catalog(raw), set())
|
||||
|
||||
def test_install_failure_rolls_back(self):
|
||||
self.require_module()
|
||||
src = self.closed_current("copy.db", with_mark=False)
|
||||
self.raw(src, f"DROP TABLE {TABLE}")
|
||||
path = str(Path(self.temp.name) / "rollback.db")
|
||||
shutil.copyfile(src, path)
|
||||
with closing(sqlite3.connect(path)) as raw:
|
||||
raw.execute("BEGIN")
|
||||
raw.execute("CREATE TABLE marker(x)")
|
||||
# An invalid group already present: nothing created, outer work kept.
|
||||
raw.execute(f"CREATE TABLE {TABLE}(byte_id TEXT)")
|
||||
with self.assertRaises(sqlite3.DatabaseError):
|
||||
migrate_autotag_state(raw)
|
||||
self.assertEqual(
|
||||
raw.execute(
|
||||
f"SELECT sql FROM sqlite_master WHERE name='{TABLE}'"
|
||||
).fetchone()[0],
|
||||
f"CREATE TABLE {TABLE}(byte_id TEXT)",
|
||||
)
|
||||
self.assertIsNotNone(
|
||||
raw.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE name='marker'"
|
||||
).fetchone()
|
||||
)
|
||||
raw.rollback()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -468,6 +468,7 @@ class TestRestoreFromFile(BackupTestCase):
|
||||
|
||||
def test_restore_old_backup_without_annotations(self):
|
||||
tables = (
|
||||
"media_image_autotags",
|
||||
"media_review_decisions",
|
||||
"media_review_items",
|
||||
"media_review_metadata",
|
||||
|
||||
@@ -690,6 +690,32 @@ class TestFixRound(ImageTagJobTestCase):
|
||||
],
|
||||
)
|
||||
|
||||
async def test_an_ended_job_shows_no_time_left(self):
|
||||
"""#379 live check: a finished or cancelled job never says "min left"."""
|
||||
for index in range(3):
|
||||
self.png(f"f{index}.png", color=(index, 7, 7))
|
||||
clock = iter([0.0, 0.0, 120.0, 240.0])
|
||||
with patch(
|
||||
"py.api.image_tag_jobs._monotonic", side_effect=lambda: next(clock, 240.0)
|
||||
):
|
||||
job = await self.run_job(self.auto_body([f"f{i}.png" for i in range(3)]))
|
||||
self.assertEqual(job.state, "finished")
|
||||
self.assertEqual(job.status_line, "Auto-tag finished · 3 files processed")
|
||||
|
||||
self.png("g0.png", color=(1, 1, 1))
|
||||
self.png("g1.png", color=(2, 2, 2))
|
||||
entered, proceed = threading.Event(), threading.Event()
|
||||
self.tagger.block = (entered, proceed)
|
||||
job = await self.start_ok(self.auto_body(["g0.png", "g1.png"]))
|
||||
await self.wait_for(entered)
|
||||
await self.client.post(f"/prompt_manager/jobs/{job.id}/cancel")
|
||||
self.tagger.block = None
|
||||
proceed.set()
|
||||
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
|
||||
self.assertEqual(job.state, "cancelled")
|
||||
self.assertEqual(job.status_line, "Auto-tag cancelled · 1 file processed")
|
||||
self.assertNotIn("left", job.status_line)
|
||||
|
||||
def maximal_target(self, number):
|
||||
"""A distinct target whose path is 1024 non-ASCII code points long."""
|
||||
head = f"{number:03d}/" + ("界" * 200 + "/") * 5
|
||||
|
||||
@@ -371,7 +371,10 @@ class ReviewSchemaTests(unittest.TestCase):
|
||||
self.require_module()
|
||||
for label, dropped in (
|
||||
("annotations only", TABLES),
|
||||
("predates annotations", ANNOTATION_TABLES + TABLES),
|
||||
(
|
||||
"predates annotations",
|
||||
("media_image_autotags",) + ANNOTATION_TABLES + TABLES,
|
||||
),
|
||||
):
|
||||
with self.subTest(label):
|
||||
path = str(Path(self.temp.name) / f"{label.replace(' ', '-')}.db")
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { COLUMNS, CONTROL_EXEMPTIONS, STATUSES, declaredTitles, parseControl, parseCoverageTable, readCoverageTable,
|
||||
rowProblems } from '../../tools/vendor/legacy-coverage.mjs';
|
||||
|
||||
// TESTING.md "Legacy System capability coverage" (#249 System S12, #329): every legacy System capability names an
|
||||
// owner, a known status, a well-formed native control and a test its file really declares.
|
||||
|
||||
const ROW = Object.freeze({ Capability: 'Clear/truncate logs', 'Legacy anchor': '`admin.html:915`', Owner: 'S12 Logs',
|
||||
Status: 'covered', 'Native control': '`diagnostics` · button · "Clear all logs…"', Test: 'clears the logs (clear.spec.mjs)' });
|
||||
const declaring = source => ({ locate: file => `tests/browser/${file}`, read: () => source });
|
||||
|
||||
test('every coverage row has an owner, a known status and a declared test', () => {
|
||||
const rows = readCoverageTable();
|
||||
assert.ok(rows.length >= 54, `the table lists ${rows.length} capabilities`);
|
||||
for (const row of rows) {
|
||||
assert.deepEqual(rowProblems(row), [], `TESTING.md:${row.line} ${row.Capability}`);
|
||||
assert.ok(row.Owner.trim());
|
||||
assert.ok(STATUSES.includes(row.Status));
|
||||
}
|
||||
assert.ok(rows.some(row => row.Status === 'kept in legacy') && rows.some(row => row.Status === 'approved removal'));
|
||||
// A broken row fails: an empty owner, an unknown status, a test its file does not declare.
|
||||
const [first] = rows;
|
||||
assert.deepEqual(rowProblems({ ...first, Owner: '' }), ['no owner']);
|
||||
assert.ok(rowProblems({ ...first, Status: 'done' }).includes('unknown status "done"'));
|
||||
assert.match(rowProblems({ ...first, Test: first.Test.replace(/^(.+) \(/, '$1 again (') }).join(), /declares no test/);
|
||||
assert.match(rowProblems({ ...first, Test: 'some title (missing.spec.mjs)' }).join(), /no test file missing\.spec\.mjs/);
|
||||
});
|
||||
|
||||
test('a title that appears only in a comment does not count', () => {
|
||||
const commented = [
|
||||
"// test('clears the logs', async () => {});",
|
||||
"/* test('clears the logs', async () => {}); */",
|
||||
'/**',
|
||||
" * it('clears the logs')",
|
||||
' */',
|
||||
"const title = 'clears the logs';",
|
||||
"test.describe('clears the logs', () => {});",
|
||||
].join('\n');
|
||||
assert.equal(declaredTitles(commented, 'clear.spec.mjs').has('clears the logs'), false);
|
||||
assert.deepEqual(rowProblems(ROW, declaring(commented)), ['clear.spec.mjs declares no test "clears the logs"']);
|
||||
const declared = `${commented}\nfor (const theme of THEMES) {\n test('clears the logs', async ({ page }) => {});\n}\n`;
|
||||
assert.deepEqual(rowProblems(ROW, declaring(declared)), []);
|
||||
// Escaped quotes, template literals, modifiers and a title on the next line are declarations too.
|
||||
const titles = declaredTitles("test('a gate\\'s refusal', () => {});\ntest.only(`b`, () => {});\nit(\n 'c', () => {});",
|
||||
'x.test.js');
|
||||
assert.deepEqual([...titles], ["a gate's refusal", 'b', 'c']);
|
||||
// A block comment that starts after code on a line, and a `//` or `/*` inside a string or a regular expression.
|
||||
const trailing = [
|
||||
"const url = 'http://host/*'; /* test('clears the logs', () => {});",
|
||||
"test('clears the logs', () => {}); */",
|
||||
"const pattern = /['\"`]\\/\\*/;",
|
||||
"test('after the pattern', () => {});",
|
||||
].join('\n');
|
||||
assert.deepEqual([...declaredTitles(trailing, 'x.spec.mjs')], ['after the pattern']);
|
||||
// Python: only a real `def test_…` counts, never a commented one.
|
||||
const python = ['# def test_report_lists_config_only_keys(self):', 'class T:', ' \"\"\"Example:', '',
|
||||
' def test_in_a_docstring(self):', ' \"\"\"', ' def test_other(self):', ' def test_nested():',
|
||||
' pass', 'def test_module():', ' pass', ''].join('\n');
|
||||
assert.deepEqual([...declaredTitles(python, 'test_x.py')], ['test_other', 'test_module']);
|
||||
});
|
||||
|
||||
test('a malformed native control cell fails', () => {
|
||||
assert.deepEqual(parseControl('`diagnostics` · button · "Clear all logs…"'),
|
||||
{ section: 'diagnostics', role: 'button', name: 'Clear all logs…', row: null });
|
||||
assert.deepEqual(parseControl('`maintenance` · button · "Review plan" · in "Duplicate prompt text"'),
|
||||
{ section: 'maintenance', role: 'button', name: 'Review plan', row: 'Duplicate prompt text' });
|
||||
assert.equal(parseControl('—'), null);
|
||||
for (const [cell, message] of [
|
||||
['diagnostics · button · "Clear all logs…"', /malformed/],
|
||||
['`diagnostics` · button · Clear all logs…', /malformed/],
|
||||
['`diagnostics` - button - "Clear all logs…"', /malformed/],
|
||||
['', /malformed/],
|
||||
['`logs` · button · "Clear all logs…"', /unknown System section "logs"/],
|
||||
['`diagnostics` · link · "Clear all logs…"', /unknown role "link"/],
|
||||
]) {
|
||||
assert.throws(() => parseControl(cell), message, cell);
|
||||
assert.match(rowProblems({ ...ROW, 'Native control': cell }, declaring("test('clears the logs', () => {});")).join(), message);
|
||||
}
|
||||
// Only a covered row names a control.
|
||||
assert.deepEqual(rowProblems({ ...ROW, Status: 'kept in legacy' }, declaring("test('clears the logs', () => {});")),
|
||||
['a kept in legacy row names a native control']);
|
||||
// The table itself must keep its six columns.
|
||||
assert.throws(() => parseCoverageTable(`### Legacy System capability coverage\n\n| Capability | Owner |\n| --- | --- |\n`), /columns/);
|
||||
assert.deepEqual(COLUMNS, ['Capability', 'Legacy anchor', 'Owner', 'Status', 'Native control', 'Test']);
|
||||
});
|
||||
|
||||
test('a skipped test does not prove a capability', () => {
|
||||
for (const modifier of ['skip', 'fixme', 'fail']) {
|
||||
assert.deepEqual(rowProblems(ROW, declaring(`test.${modifier}('clears the logs', async () => {});`)),
|
||||
['clear.spec.mjs only skips "clears the logs"'], modifier);
|
||||
}
|
||||
for (const modifier of ['only', 'slow']) {
|
||||
assert.deepEqual(rowProblems(ROW, declaring(`test.${modifier}('clears the logs', async () => {});`)), [], modifier);
|
||||
}
|
||||
// A running declaration beside a skipped one counts.
|
||||
assert.deepEqual(rowProblems(ROW, declaring("test.skip('clears the logs', () => {});\ntest('clears the logs', () => {});")), []);
|
||||
});
|
||||
|
||||
test('a covered row names its native control unless it is exempt', () => {
|
||||
const source = declaring("test('clears the logs', () => {});");
|
||||
assert.deepEqual(rowProblems({ ...ROW, 'Native control': '—' }, source), ['a covered row names no native control']);
|
||||
for (const status of ['kept in legacy', 'approved removal']) {
|
||||
assert.deepEqual(rowProblems({ ...ROW, Status: status, 'Native control': '—' }, source), [], status);
|
||||
}
|
||||
const [exempt] = Object.keys(CONTROL_EXEMPTIONS);
|
||||
assert.deepEqual(rowProblems({ ...ROW, Capability: exempt, 'Native control': '—' }, source), []);
|
||||
// Every exemption has a reason and names a covered row of the table that has no control.
|
||||
const rows = readCoverageTable();
|
||||
for (const [capability, reason] of Object.entries(CONTROL_EXEMPTIONS)) {
|
||||
assert.ok(reason.length > 20, capability);
|
||||
const row = rows.find(item => item.Capability === capability);
|
||||
assert.ok(row, `${capability} is in the table`);
|
||||
assert.deepEqual([row.Status, row['Native control']], ['covered', '—']);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user