Merge remote-tracking branch 'origin/main' into fix/501-removal-mark-gaps
This commit is contained in:
@@ -44,7 +44,12 @@ export function mapLibraryConcept(reference) {
|
||||
// "4 runs" and "Used today". The two-line clamp is
|
||||
// applied below through `clampedCards`, measured on the concept with `CLAMP_CSS` (its samples wrap at 390px).
|
||||
footer:card.footer}));
|
||||
return {...reference,library:{...library,toolbar,
|
||||
// D152 (#443): Show archived prompts (N) comes first in the heading actions, a plain button like Export (the concept
|
||||
// fixture has nothing archived, so N is 0).
|
||||
const actions=reference.heading.actions;
|
||||
const plain=actions.buttons.find(button=>!button.primary);
|
||||
const heading={...reference.heading,actions:{...actions,buttons:[{...plain,text:'Show archived prompts (0)'},...actions.buttons]}};
|
||||
return {...reference,heading,library:{...library,toolbar,
|
||||
// D35: the caption uses the singular for one.
|
||||
caption:{...library.caption,top:library.caption.top-rise,spans:library.caption.spans.map(span=>({...span,text:countCopy(span.text)}))},
|
||||
...(({grid,cards:clamped})=>({grid:{...grid,top:grid.top-rise},cards:clamped}))(clampedCards({grid:library.grid,cards})),
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
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';
|
||||
|
||||
// #443 D152: the Library's Delete… review against the concept's own reviewed archive: the recipe editor's "Archive…"
|
||||
// (app.js 976), which opens `maintenancePlan("archive <title>")` (953–958, 1309–1310). The concept's shell, grid tokens,
|
||||
// `p.small` and actions are kept; its title, demo note, rows, note text and `ca-primary` start are replaced by D152's,
|
||||
// and the start takes the concept's `danger` class, measured on the editor's own "Archive…". The bar's Delete… is
|
||||
// compared with the concept Images bar's danger action, "Remove links…" (app.js 432).
|
||||
test.use({ locale: 'en-US', timezoneId: 'UTC' });
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
await seedConcept(request);
|
||||
page.conceptErrors = [];
|
||||
page.on('pageerror', error => page.conceptErrors.push(error.message));
|
||||
});
|
||||
test.afterEach(async ({ page, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.conceptErrors).toEqual([]);
|
||||
});
|
||||
|
||||
/** Runs in the page: the open review dialog's title, body tree, grid, notes and actions with 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 notes = [...body.querySelectorAll(':scope > p.small')];
|
||||
const actions = body.querySelector(':scope > .dialog-actions');
|
||||
return {
|
||||
title: text(dialog.querySelector('#modal-title')),
|
||||
tree: [...body.children].map(name).filter(item => item !== 'p.demo-note' && item !== 'h3'),
|
||||
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') },
|
||||
notes: notes.map(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') })) },
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs in the page: a button's class and tokens (the concept's danger button, or production's). */
|
||||
function measureButton(selector) {
|
||||
const node = document.querySelector(selector);
|
||||
const style = getComputedStyle(node);
|
||||
return { className: node.className, ...Object.fromEntries(['backgroundColor', 'color', 'fontSize', 'fontWeight', 'paddingTop',
|
||||
'paddingLeft', 'borderTopColor', 'borderTopLeftRadius', 'height'].map(key => [key, key === 'height'
|
||||
? `${Math.round(node.getBoundingClientRect().height * 100) / 100}px` : style[key]])) };
|
||||
}
|
||||
|
||||
async function conceptReview(page, theme) {
|
||||
await openConcept(page, 'prompt/alpine', theme);
|
||||
await page.locator('#main .heading-actions').getByRole('button', { name: 'Edit metadata', exact: true }).click();
|
||||
const archive = page.locator('dialog#modal[open] .dialog-actions button', { hasText: 'Archive…' });
|
||||
await expect(archive).toBeVisible();await page.mouse.move(0, 0);
|
||||
const danger = await page.evaluate(measureButton, 'dialog#modal[open] .dialog-actions button.danger');
|
||||
await archive.click();
|
||||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Review maintenance plan');
|
||||
await page.mouse.move(0, 0);
|
||||
return { danger, review: await page.evaluate(measureReview) };
|
||||
}
|
||||
|
||||
async function seedMixed(request) {
|
||||
// One unpaired prompt beside the concept's paired ones, so the review deletes one and archives one.
|
||||
await expect(await request.post('/prompt_manager/save', { data: { text: 'Unpaired study', category: 'Studies' } })).toBeOK();
|
||||
const items = (await (await request.get('/prompt_manager/library?limit=100')).json()).items;
|
||||
return { unpaired: items.find(item => item.text === 'Unpaired study'), paired: items.find(item => item.variant_count > 0) };
|
||||
}
|
||||
|
||||
async function productionReview(page, request, theme) {
|
||||
const { unpaired, paired } = await seedMixed(request);
|
||||
await openProduction(page, 'library', theme);
|
||||
for (const item of [unpaired, paired]) await page.locator(`article[data-prompt-id="${item.id}"] .image-overlays input`).check();
|
||||
await page.locator('#library-bulkbar .bulkbar').getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Delete or archive selected prompts');
|
||||
await page.mouse.move(0, 0);
|
||||
return page.evaluate(measureReview);
|
||||
}
|
||||
|
||||
/** D152's changes to the concept's review; everything else must equal the concept. */
|
||||
function mapDeleteConcept({ danger, review }) {
|
||||
const [cancel] = review.actions.buttons;
|
||||
const note = review.notes.at(-1);
|
||||
return {
|
||||
...review,
|
||||
title: 'Delete or archive selected prompts',
|
||||
tree: ['dl.meta-grid', 'p.small', 'p.small', 'div.dialog-actions'],
|
||||
grid: { ...review.grid, rows: [['Delete permanently', '1 prompt not in a saved pair'],
|
||||
['Archive', '1 prompt in a saved pair: hidden, restorable'],
|
||||
['Image links removed', '0 images will no longer show these prompts (files stay on disk)'],
|
||||
['Backup', 'Create and verify before deleting']] },
|
||||
notes: [
|
||||
{ ...note, text: 'Prompts in a saved pair, as its positive or its negative, are archived instead of deleted, so the '
|
||||
+ 'pair and its runs stay intact. Archived prompts cannot be deleted. To bring them back, press Show archived prompts, '
|
||||
+ 'select them and press Restore.' },
|
||||
// A second paragraph takes the concept's own `p + p` margin (style.css 93-95).
|
||||
{ ...note, marginTop: '10px', text: "Deleting removes each prompt's text, title, category, tags, rating, notes, run count, last use and "
|
||||
+ 'image links. Deleted prompts cannot be brought back one by one: the only way back is to restore the safety '
|
||||
+ 'backup taken first (System › Backup & export), which returns the whole library to that moment. It is kept '
|
||||
+ 'among the last 10 safety backups. Image files, favorites, collections and recorded runs stay. A later scan, a '
|
||||
+ 'new image or a node run with the same text adds it again as a new prompt.' },
|
||||
],
|
||||
actions: { ...review.actions, tree: [review.actions.tree[0], 'button.danger'], buttons: [cancel, { text: 'Delete 1 and archive 1 prompt', className: danger.className,
|
||||
backgroundColor: danger.backgroundColor, color: danger.color, fontSize: danger.fontSize, paddingTop: danger.paddingTop,
|
||||
paddingLeft: danger.paddingLeft, borderTopColor: danger.borderTopColor }] },
|
||||
};
|
||||
}
|
||||
|
||||
for (const theme of THEMES) for (const [width, height] of SIZES) {
|
||||
test(`the Delete… review matches the concept's reviewed archive ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
|
||||
await page.setViewportSize({ width, height });
|
||||
const concept = await conceptReview(page, theme);
|
||||
expect(concept.review.tree).toEqual(['dl.meta-grid', 'p.small', 'div.dialog-actions']);
|
||||
await page.screenshot({ path: testInfo.outputPath(`concept-archive-review-${theme}-${width}.png`) });
|
||||
const production = await productionReview(page, request, theme);
|
||||
await page.screenshot({ path: testInfo.outputPath(`production-delete-review-${theme}-${width}.png`) });
|
||||
expect(diffInventory(mapDeleteConcept(concept), production)).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test('the Delete… review comparison detects mutations', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const concept = await conceptReview(page, 'dark');
|
||||
await productionReview(page, request, 'dark');
|
||||
const expected = mapDeleteConcept(concept);
|
||||
const paths = async () => diffInventory(expected, await page.evaluate(measureReview)).map(item => item.path);
|
||||
expect(await paths()).toEqual([]);
|
||||
await mutate(page, { css: '#modal-body .dialog-actions .danger { padding-left: 2px }' });
|
||||
expect(await paths()).toContain('actions.buttons.1.paddingLeft');
|
||||
});
|
||||
|
||||
test('the bar\'s Delete… matches the concept Images bar\'s danger action', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await openConcept(page, 'images', 'dark');
|
||||
await page.locator('#image-grid .media-card').first().locator('input[data-select]').check();
|
||||
await expect(page.locator('#bulkbar .bulkbar')).toBeVisible();await page.mouse.move(0, 0);
|
||||
const reference = await page.evaluate(measureButton, '#bulkbar .bulkbar button.danger');
|
||||
const { paired } = await seedMixed(request);
|
||||
await openProduction(page, 'library', 'dark');
|
||||
await page.locator(`article[data-prompt-id="${paired.id}"] .image-overlays input`).check();
|
||||
await expect(page.locator('#library-bulkbar .bulkbar')).toBeVisible();await page.mouse.move(0, 0);
|
||||
expect(await page.evaluate(measureButton, '#library-bulkbar .bulkbar button.danger')).toEqual(reference);
|
||||
});
|
||||
@@ -87,8 +87,8 @@ function measureBackups(mainSelector) {
|
||||
}
|
||||
|
||||
const SCHEDULE_REASON = 'Daily and Weekly are not available yet: back up with Back up whenever you choose.';
|
||||
const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups and LoRA imports change ' +
|
||||
'records. A restore always keeps one.';
|
||||
const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups, LoRA imports and ' +
|
||||
'prompt deletes change records. A restore always keeps one.';
|
||||
|
||||
/**
|
||||
* D103: "Back up" (the concept's "Simulate backup"), the status line from /backup/status, "Export JSON" (its "Export
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {armUnseenRestores,unseenRestore} from './unseen-restore.mjs';
|
||||
|
||||
// #443 D152: Delete… on the Library bar deletes unpaired prompts, archives paired ones (restorable), keeps protected ones.
|
||||
const route='/prompt_manager/workspace#/library';
|
||||
const PREVIEW='/prompt_manager/library/delete/preview';
|
||||
const DELETE='/prompt_manager/library/delete';
|
||||
const cards=page=>page.locator('article[data-prompt-id]');
|
||||
const card=(page,id)=>page.locator(`article[data-prompt-id="${id}"]`);
|
||||
const box=(page,id)=>card(page,id).locator('.image-overlays input[type="checkbox"]');
|
||||
const bar=page=>page.locator('#library-bulkbar > .bulkbar');
|
||||
const toast=page=>page.locator('.toast');
|
||||
const review=page=>page.getByRole('dialog');
|
||||
const showArchived=page=>page.locator('#workspace-main .heading-actions').getByRole('button',{name:/^Show archived prompts/});
|
||||
|
||||
async function items(request,shelf='active') {
|
||||
const query=shelf==='archived'?'&shelf=archived':'';
|
||||
return (await(await request.get(`/prompt_manager/library?limit=100${query}`)).json()).items;
|
||||
}
|
||||
const byText=(list,text)=>list.find(item=>item.text===text);
|
||||
function recordPosts(page,path) {
|
||||
const posts=[];
|
||||
page.on('request',item=>{if(item.method()==='POST'&&new URL(item.url()).pathname===path)posts.push(item.postDataJSON());});
|
||||
return posts;
|
||||
}
|
||||
async function openReview(page) {
|
||||
await bar(page).getByRole('button',{name:'Delete…',exact:true}).click();
|
||||
await expect(review(page)).toBeVisible();
|
||||
}
|
||||
|
||||
test.beforeEach(async({page,request,browserName})=>{
|
||||
await startCoverage(page,browserName);await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:2}})).toBeOK();
|
||||
page.libraryErrors=[];page.on('pageerror',error=>page.libraryErrors.push(error.message));
|
||||
});
|
||||
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.libraryErrors).toEqual([]);});
|
||||
|
||||
test('Delete… reviews the selection, deletes the unpaired, archives the paired and keeps the protected',async({page,request})=>{
|
||||
const before=await items(request);
|
||||
const dog=byText(before,'Dog landscape'),cat=byText(before,'Cat portrait'),guarded=byText(before,'Library extra 0000');
|
||||
await expect(await request.post('/prompt_manager/bulk/tags',{data:{prompt_ids:[guarded.id],tags:['__protected__']}})).toBeOK();
|
||||
const previews=recordPosts(page,PREVIEW),writes=recordPosts(page,DELETE);
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(before.length);
|
||||
for(const item of [dog,cat,guarded]) await box(page,item.id).check();
|
||||
await expect(bar(page).getByRole('button')).toHaveText([`Select all ${before.length} loaded`,'Add tags','Set category','Delete…','Clear']);
|
||||
await expect(bar(page).getByRole('button',{name:'Delete…',exact:true})).toHaveClass(/danger/);
|
||||
await openReview(page);
|
||||
await expect(review(page)).toHaveAccessibleName('Delete or archive selected prompts');
|
||||
const grid=review(page).locator('dl.meta-grid > div');
|
||||
await expect(grid.locator('dt')).toHaveText(['Delete permanently','Archive','Protected (kept)','Image links removed','Backup']);
|
||||
await expect(grid.locator('dd')).toHaveText(['1 prompt not in a saved pair','1 prompt in a saved pair: hidden, restorable',
|
||||
'1: Library extra 0000','0 images will no longer show these prompts (files stay on disk)','Create and verify before deleting']);
|
||||
await expect(review(page).locator('p.small')).toHaveCount(3);
|
||||
await expect(review(page).getByRole('button',{name:'Delete 1 and archive 1 prompt',exact:true})).toHaveClass(/danger/);
|
||||
// Cancel writes nothing and releases the bar.
|
||||
await review(page).getByRole('button',{name:'Cancel',exact:true}).click();
|
||||
await expect(review(page)).toHaveCount(0);expect(writes).toEqual([]);
|
||||
await expect(bar(page).getByRole('button',{name:'Delete…',exact:true})).not.toHaveAttribute('aria-disabled','true');
|
||||
await openReview(page);
|
||||
await review(page).getByRole('button',{name:'Delete 1 and archive 1 prompt',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText(/^Deleted 1 prompt\. Archived 1 prompt\. 1 protected prompt was kept\. Safety backup saved: \S+\.backup_\S+ \(System › Backup & export\)\.$/);
|
||||
await expect(page.locator('#library-bulkbar')).toBeEmpty();
|
||||
await expect(card(page,dog.id)).toHaveCount(0);await expect(card(page,cat.id)).toHaveCount(0);await expect(card(page,guarded.id)).toHaveCount(1);
|
||||
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
|
||||
// One preview per review opened (Cancel, then Delete…).
|
||||
const reviewed={prompt_ids:[dog.id,cat.id,guarded.id],expected_epoch:epoch};
|
||||
expect(previews).toEqual([reviewed,reviewed]);
|
||||
expect(writes).toEqual([{archive_ids:[cat.id],delete_ids:[dog.id],expected_epoch:epoch,backup:'on'}]);
|
||||
const after=await items(request);
|
||||
expect(byText(after,'Dog landscape')).toBeUndefined();expect(byText(after,'Cat portrait')).toBeUndefined();
|
||||
expect((await items(request,'archived')).map(item=>item.id)).toEqual([cat.id]);
|
||||
await expect(showArchived(page)).toHaveText('Show archived prompts (1)');
|
||||
});
|
||||
|
||||
test('Show archived prompts lists archived prompts, and Restore on its bar returns them',async({page,request})=>{
|
||||
const before=await items(request);const cat=byText(before,'Cat portrait'),dual=byText(before,'Dual role portrait');
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(before.length);
|
||||
await box(page,cat.id).check();await box(page,dual.id).check();
|
||||
await openReview(page);
|
||||
await expect(review(page)).toHaveAccessibleName('Archive selected prompts');
|
||||
// The archive reason is the saved pair, for one or many prompts (live note on #470): never negative variants.
|
||||
await expect(review(page).locator('dl.meta-grid > div').filter({hasText:'Archive'}).locator('dd')).toHaveText('2 prompts in a saved pair: hidden, restorable');
|
||||
await review(page).getByRole('button',{name:'Archive 2 prompts',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText('Archived 2 prompts.');
|
||||
await expect(cards(page)).toHaveCount(before.length-2);
|
||||
await expect(showArchived(page)).toHaveAttribute('aria-pressed','false');
|
||||
await showArchived(page).click();
|
||||
await expect(showArchived(page)).toHaveAttribute('aria-pressed','true');
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await expect(page.locator('.section-caption > span').first()).toHaveText(/^2 archived prompts · \d+ negative variants? hidden with them$/);
|
||||
await expect(card(page,cat.id).locator('.card-title .chip',{hasText:'Archived'})).toHaveCount(1);
|
||||
await box(page,cat.id).check();
|
||||
await expect(bar(page).getByRole('button')).toHaveText(['Select all 2 loaded','Restore','Clear']);
|
||||
await bar(page).getByRole('button',{name:'Restore',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText('Restored 1 prompt to the Prompt library.');
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
await showArchived(page).click();
|
||||
await expect(cards(page)).toHaveCount(before.length-1);
|
||||
await expect(card(page,cat.id)).toHaveCount(1);
|
||||
expect((await items(request,'archived')).map(item=>item.id)).toEqual([dual.id]);
|
||||
// An empty archived view says so.
|
||||
await showArchived(page).click();await box(page,dual.id).check();
|
||||
await bar(page).getByRole('button',{name:'Restore',exact:true}).click();
|
||||
await expect(page.locator('.library-status')).toHaveText('No archived prompts.');
|
||||
});
|
||||
|
||||
test('an archived prompt\'s page says so and restores it from its notice',async({page,request})=>{
|
||||
const cat=byText(await items(request),'Cat portrait');
|
||||
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
|
||||
await expect(await request.post(DELETE,{data:{archive_ids:[cat.id],delete_ids:[],expected_epoch:epoch,backup:'off'}})).toBeOK();
|
||||
await page.goto(`/prompt_manager/workspace#/library/${cat.id}`);
|
||||
const notice=page.locator('.library-archived-notice');
|
||||
await expect(notice.locator('h2')).toHaveText('Archived');
|
||||
// The notice says why (the saved pair) and counts the prompt's negative variants (live note on #470).
|
||||
expect(cat.variant_count).toBeGreaterThan(1);
|
||||
await expect(notice.locator('p').first()).toHaveText(`This prompt is archived because it is in a saved pair. It and its ${cat.variant_count} negative variants are hidden from the Prompt library; nothing was removed.`);
|
||||
await notice.getByRole('button',{name:'Restore this prompt to the Prompt library'}).click();
|
||||
await expect(toast(page)).toHaveText('Restored 1 prompt to the Prompt library.');
|
||||
await expect(notice).toHaveCount(0);
|
||||
expect(byText(await items(request),'Cat portrait')?.id).toBe(cat.id);
|
||||
});
|
||||
|
||||
// #443: a starved renderer (headless WebKit under CPU load, a background tab) can hold animation frames for
|
||||
// seconds; a loaded page of prompts is shown without waiting for one, on first load and after a delete.
|
||||
async function holdAnimationFrames(page) {
|
||||
await page.addInitScript(()=>{
|
||||
const request=window.requestAnimationFrame.bind(window),cancel=window.cancelAnimationFrame.bind(window);
|
||||
const held=new Map(),real=new Map();let holding=true,next=0;
|
||||
window.requestAnimationFrame=callback=>{const id=--next;if(holding)held.set(id,callback);else real.set(id,request(callback));return id;};
|
||||
window.cancelAnimationFrame=id=>{held.delete(id);if(real.has(id))cancel(real.get(id));real.delete(id);};
|
||||
window.__releaseAnimationFrames=()=>{holding=false;for(const [id,callback] of held)real.set(id,request(callback));held.clear();};
|
||||
});
|
||||
}
|
||||
test('a held animation frame does not hide the loaded prompts, on first load or after a delete',async({page,request})=>{
|
||||
const before=await items(request);const dog=byText(before,'Dog landscape');
|
||||
await holdAnimationFrames(page);
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(before.length);
|
||||
await box(page,dog.id).check();await openReview(page);
|
||||
await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText(/^Deleted 1 prompt\./);
|
||||
await expect(cards(page)).toHaveCount(before.length-1);await expect(card(page,dog.id)).toHaveCount(0);
|
||||
await page.evaluate(()=>window.__releaseAnimationFrames());
|
||||
await expect(cards(page)).toHaveCount(before.length-1);
|
||||
});
|
||||
|
||||
test('a double press sends one preview, and the bar holds while it is reviewed',async({page,request})=>{
|
||||
const dog=byText(await items(request),'Dog landscape');
|
||||
const previews=recordPosts(page,PREVIEW);
|
||||
let release;const held=new Promise(resolve=>{release=resolve;});
|
||||
await page.route(`**${PREVIEW}`,async item=>{await held;await item.fallback();});
|
||||
await page.goto(route);await box(page,dog.id).check();
|
||||
const remove=bar(page).getByRole('button',{name:/^(Delete…|Reviewing…)$/});
|
||||
await remove.click();
|
||||
await expect(remove).toHaveText('Reviewing…');await expect(remove).toHaveAttribute('aria-disabled','true');
|
||||
await expect(bar(page).getByRole('button',{name:'Clear',exact:true})).toHaveAttribute('aria-disabled','true');
|
||||
await remove.click({force:true});
|
||||
release();
|
||||
await expect(review(page)).toHaveAccessibleName('Delete selected prompts');
|
||||
expect(previews).toHaveLength(1);
|
||||
await review(page).getByRole('button',{name:'Cancel',exact:true}).click();
|
||||
await expect(bar(page).getByRole('button',{name:'Delete…',exact:true})).not.toHaveAttribute('aria-disabled','true');
|
||||
});
|
||||
|
||||
test('a restore since the page loaded, a failed backup, a busy job and an unconfirmed answer each say what happened',async({page,request})=>{
|
||||
const before=await items(request);const dog=byText(before,'Dog landscape');
|
||||
await armUnseenRestores(page);
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(before.length);
|
||||
// Failed backup: nothing changed, the selection stays.
|
||||
await page.route(`**${DELETE}`,item=>item.fulfill({status:500,contentType:'application/json',
|
||||
body:JSON.stringify({success:false,code:'backup-failed',error:'The safety backup could not be taken and verified; nothing was changed.'})}));
|
||||
await box(page,dog.id).check();await openReview(page);
|
||||
await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText('The safety backup failed. The safety backup could not be taken and verified; nothing was changed.');
|
||||
await expect(box(page,dog.id)).toBeChecked();
|
||||
// Busy: names the job, nothing changed.
|
||||
await page.unroute(`**${DELETE}`);
|
||||
await page.route(`**${DELETE}`,item=>item.fulfill({status:409,contentType:'application/json',
|
||||
body:JSON.stringify({success:false,code:'busy',error:'busy',running:{id:'abc',title:'Scan'}})}));
|
||||
await openReview(page);await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText('Scan is running. Nothing was changed; try again when it finishes.');
|
||||
// Unconfirmed: the shelf reloads.
|
||||
await page.unroute(`**${DELETE}`);
|
||||
await page.route(`**${DELETE}`,item=>item.abort());
|
||||
await openReview(page);await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText('The server did not confirm the change. The Library was reloaded: check the shelf and Show archived prompts.');
|
||||
await expect(page.locator('#library-bulkbar')).toBeEmpty();
|
||||
await page.unroute(`**${DELETE}`);
|
||||
// A restore the page has not seen: the review opened before it is refused on the server.
|
||||
await box(page,dog.id).check();await openReview(page);
|
||||
const backup=await request.get('/prompt_manager/backup');await expect(backup).toBeOK();const buffer=await backup.body();
|
||||
const restore=await unseenRestore(page,()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'library.db',mimeType:'application/octet-stream',buffer}}}));
|
||||
await expect(restore).toBeOK();
|
||||
await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText('The library was restored or moved since this page loaded. Nothing was deleted or archived.');
|
||||
expect(byText(await items(request),'Dog landscape')).toBeDefined();
|
||||
});
|
||||
|
||||
test('a failed review keeps the selection, and a selection nothing can change opens the close-only review',async({page,request})=>{
|
||||
const before=await items(request);const guarded=byText(before,'Library extra 0000');
|
||||
await expect(await request.post('/prompt_manager/bulk/tags',{data:{prompt_ids:[guarded.id],tags:['__protected__']}})).toBeOK();
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(before.length);
|
||||
await box(page,guarded.id).check();
|
||||
await page.route(`**${PREVIEW}`,item=>item.fulfill({status:500,contentType:'application/json',body:JSON.stringify({success:false,code:'review-failed',error:'x'})}),{times:1});
|
||||
await bar(page).getByRole('button',{name:'Delete…',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText('The selected prompts could not be reviewed. Try again.');
|
||||
await expect(box(page,guarded.id)).toBeChecked();
|
||||
await expect(bar(page).getByRole('button',{name:'Delete…',exact:true})).not.toHaveAttribute('aria-disabled','true');
|
||||
await openReview(page);
|
||||
await expect(review(page)).toHaveAccessibleName('Delete selected prompts');
|
||||
await expect(review(page).locator('p.small').last()).toHaveText('None of the selected prompts can be deleted or archived.');
|
||||
await expect(review(page).locator('.dialog-actions > button')).toHaveText(['Close']);
|
||||
await review(page).getByRole('button',{name:'Close',exact:true}).click();
|
||||
await expect(bar(page).getByRole('button',{name:'Delete…',exact:true})).not.toHaveAttribute('aria-disabled','true');
|
||||
expect(byText(await items(request),'Library extra 0000')).toBeDefined();
|
||||
});
|
||||
@@ -493,13 +493,15 @@ test('a restore seen finishing keeps New prompt open and reloads the Library aft
|
||||
test('library heading offers Export then + New prompt, and New opens the saved prompt history',async({page,request})=>{
|
||||
await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]').first()).toBeVisible();
|
||||
const actions=headingActions(page).locator('button');
|
||||
expect(await visibleNames(actions)).toEqual(['Export','+ New prompt']);
|
||||
// D152 (#443): Show archived prompts (N) comes first, a plain toggle.
|
||||
expect(await visibleNames(actions)).toEqual(['Show archived prompts (0)','Export','+ New prompt']);
|
||||
await expect(actions.nth(0)).toHaveAttribute('aria-pressed','false');await expect(actions.nth(0)).not.toHaveClass(/ca-primary/);
|
||||
// One image is counted in the singular.
|
||||
const f=page.fixture;await expect(page.locator(`article[data-prompt-id="${f.positive_id}"]`).getByText('1 image',{exact:true})).toHaveCount(1);
|
||||
await expect(actions.nth(0)).toHaveAccessibleName('Export full Library JSON');await expect(actions.nth(0)).not.toHaveClass(/ca-primary/);
|
||||
await expect(actions.nth(1)).toHaveClass(/\bca-primary\b/);
|
||||
for(const index of [0,1])expect((await actions.nth(index).boundingBox()).height).toBeGreaterThanOrEqual(32);
|
||||
await actions.nth(1).click();const dialog=editorDialog(page);await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog);
|
||||
await expect(actions.nth(1)).toHaveAccessibleName('Export full Library JSON');await expect(actions.nth(1)).not.toHaveClass(/ca-primary/);
|
||||
await expect(actions.nth(2)).toHaveClass(/\bca-primary\b/);
|
||||
for(const index of [0,1,2])expect((await actions.nth(index).boundingBox()).height).toBeGreaterThanOrEqual(32);
|
||||
await actions.nth(2).click();const dialog=editorDialog(page);await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog);
|
||||
await field(dialog,'Title').fill('Harbor at dawn');await field(dialog,'Positive prompt').fill('A quiet harbor at dawn');await field(dialog,'Negative prompt (optional)').fill('fog, noise');
|
||||
await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
|
||||
const created=(await(await request.get('/prompt_manager/library?text=quiet%20harbor')).json()).items[0];
|
||||
|
||||
@@ -62,7 +62,8 @@ test('Set category writes the selected prompts on the server, reloads the shelf
|
||||
await box(page,first.id).check();await expect(bar(page).locator('strong')).toHaveText('1 selected');
|
||||
await box(page,second.id).check();await expect(bar(page).locator('strong')).toHaveText('2 selected');
|
||||
// Two of three loaded are checked, so Select all N loaded follows the count (D151, #444 ruling).
|
||||
await expect(bar(page).getByRole('button')).toHaveText([`Select all ${items.length} loaded`,'Add tags','Set category','Clear']);
|
||||
// D152 (#443) adds Delete… before Clear.
|
||||
await expect(bar(page).getByRole('button')).toHaveText([`Select all ${items.length} loaded`,'Add tags','Set category','Delete…','Clear']);
|
||||
await setCategory(page,' Studio set ');
|
||||
await expect(toast(page)).toHaveText('Category “Studio set” set on 2 prompts.');
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
@@ -287,7 +288,7 @@ test('Ctrl/Cmd+A and Select all N loaded select every loaded prompt, windowed on
|
||||
let release;const held=new Promise(resolve=>{release=resolve;});
|
||||
await page.route('**/prompt_manager/bulk/tags',async route=>{await held;await route.continue();},{times:1});
|
||||
await addTags(page,'everyone');
|
||||
for(const name of ['Add tags','Set category',`Select all ${all} loaded`]) await expect(bar(page).getByRole('button',{name,exact:true})).toHaveAttribute('aria-disabled','true');
|
||||
for(const name of ['Add tags','Set category','Delete…','Clear',`Select all ${all} loaded`]) await expect(bar(page).getByRole('button',{name,exact:true})).toHaveAttribute('aria-disabled','true');
|
||||
release();
|
||||
await expect(toast(page)).toHaveText(`Added “everyone” to ${all-1} prompts.`);
|
||||
expect(writes).toHaveLength(1);expect(new Set(writes[0].prompt_ids).size).toBe(all-1);
|
||||
|
||||
@@ -11,8 +11,8 @@ const STATUS_URL = '/prompt_manager/backup/status';
|
||||
const PREVIEW_URL = '/prompt_manager/restore/preview';
|
||||
const APPLY_URL = '/prompt_manager/restore/apply';
|
||||
const WHEN = { dateStyle: 'medium', timeStyle: 'short' };
|
||||
const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups and LoRA imports change ' +
|
||||
'records. A restore always keeps one.';
|
||||
const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups, LoRA imports and ' +
|
||||
'prompt deletes change records. A restore always keeps one.';
|
||||
|
||||
test.use({ locale: 'en-US', timezoneId: 'UTC' });
|
||||
|
||||
|
||||
@@ -132,6 +132,9 @@
|
||||
"POST /prompt_manager/jobs/thumbnails",
|
||||
"POST /prompt_manager/jobs/{job_id}/cancel",
|
||||
"POST /prompt_manager/library",
|
||||
"POST /prompt_manager/library/delete",
|
||||
"POST /prompt_manager/library/delete/preview",
|
||||
"POST /prompt_manager/library/restore",
|
||||
"POST /prompt_manager/library/{positive_id}/variants",
|
||||
"POST /prompt_manager/logs/config",
|
||||
"POST /prompt_manager/logs/truncate",
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
// #443 D152: the Prompt library's Delete… review (delete unpaired, archive paired, keep protected) and Restore.
|
||||
const source = import('../../web/js/workspace/library-delete.mjs');
|
||||
|
||||
const preview = (patch = {}) => ({success: true, library_epoch: 'e', delete: [], archive: [], protected: [],
|
||||
already_archived: 0, not_found: 0, image_links: 0, backup: 'on', disk: null, ...patch});
|
||||
|
||||
test('the routes are the workspace ones, never the legacy bulk delete', async () => {
|
||||
const {PREVIEW_URL, DELETE_URL, RESTORE_URL} = await source;
|
||||
assert.equal(PREVIEW_URL, '/prompt_manager/library/delete/preview');
|
||||
assert.equal(DELETE_URL, '/prompt_manager/library/delete');
|
||||
assert.equal(RESTORE_URL, '/prompt_manager/library/restore');
|
||||
});
|
||||
|
||||
test('the title, primary and body follow what the review will do', async () => {
|
||||
const {reviewOf, reviewTitle, primaryLabel, deleteBody} = await source;
|
||||
const both = reviewOf(preview({delete: [1, 2], archive: [3]}));
|
||||
assert.equal(reviewTitle(both), 'Delete or archive selected prompts');
|
||||
assert.equal(primaryLabel(both), 'Delete 2 and archive 1 prompt');
|
||||
assert.deepEqual(deleteBody(both), {archive_ids: [3], delete_ids: [1, 2], expected_epoch: 'e', backup: 'on'});
|
||||
const onlyDelete = reviewOf(preview({delete: [1]}));
|
||||
assert.equal(reviewTitle(onlyDelete), 'Delete selected prompts');
|
||||
assert.equal(primaryLabel(onlyDelete), 'Delete 1 prompt');
|
||||
const onlyArchive = reviewOf(preview({archive: [4, 5]}));
|
||||
assert.equal(reviewTitle(onlyArchive), 'Archive selected prompts');
|
||||
assert.equal(primaryLabel(onlyArchive), 'Archive 2 prompts');
|
||||
});
|
||||
|
||||
test('the grid shows each row only when it applies, with protected titles in selection order', async () => {
|
||||
const {reviewOf, reviewGrid} = await source;
|
||||
const names = {7: 'Seven', 8: 'Eight', 9: 'Nine', 10: 'Ten'};
|
||||
const review = reviewOf(preview({delete: [1], archive: [2], protected: [7, 8, 9, 10], already_archived: 1,
|
||||
not_found: 2, image_links: 3}));
|
||||
assert.deepEqual(reviewGrid(review, id => names[id]), [
|
||||
['Delete permanently', '1 prompt not in a saved pair'],
|
||||
['Archive', '1 prompt in a saved pair: hidden, restorable'],
|
||||
['Protected (kept)', '4: Seven, Eight, Nine and 1 more'],
|
||||
['Already archived', '1'],
|
||||
['No longer exist', '2'],
|
||||
['Image links removed', '3 images will no longer show these prompts (files stay on disk)'],
|
||||
['Backup', 'Create and verify before deleting'],
|
||||
]);
|
||||
const archiveOnly = reviewOf(preview({archive: [2]}));
|
||||
assert.deepEqual(reviewGrid(archiveOnly).map(([term]) => term), ['Delete permanently', 'Archive']);
|
||||
const off = reviewOf(preview({delete: [1], backup: 'off'}));
|
||||
assert.equal(reviewGrid(off).at(-1)[1], 'Off: no safety backup (System › Backup & export › Recovery policy)');
|
||||
});
|
||||
|
||||
test('the Archive row names the saved pair, never negative variants, for one or many prompts', async () => {
|
||||
const {reviewOf, reviewGrid} = await source;
|
||||
// A saved-pair prompt with 0 negative variants (live prompt 7901) is archive-only for its pair, not for variants.
|
||||
const one = reviewGrid(reviewOf(preview({archive: [2]}))).find(([term]) => term === 'Archive');
|
||||
assert.deepEqual(one, ['Archive', '1 prompt in a saved pair: hidden, restorable']);
|
||||
const many = reviewGrid(reviewOf(preview({archive: [2, 3, 4]}))).find(([term]) => term === 'Archive');
|
||||
assert.deepEqual(many, ['Archive', '3 prompts in a saved pair: hidden, restorable']);
|
||||
for (const [, value] of [one, many]) assert.doesNotMatch(value, /variant/);
|
||||
});
|
||||
|
||||
test('the sentences explain archive, irreversibility per backup policy, re-creation and protection', async () => {
|
||||
const {reviewOf, reviewSentences} = await source;
|
||||
const [archive, remove, guarded] = reviewSentences(reviewOf(preview({delete: [1], archive: [2], protected: [3]})));
|
||||
// Live test (#470): the archive reason is the saved pair, which a prompt with 0 negative variants has too.
|
||||
assert.match(archive, /^Prompts in a saved pair, as its positive or its negative, are archived instead of deleted, so the pair and its runs stay intact\./);
|
||||
assert.doesNotMatch(archive, /variant/);
|
||||
assert.match(archive, /Archived prompts cannot be deleted\. To bring them back, press Show archived prompts/);
|
||||
assert.match(remove, /the only way back is to restore the safety backup taken first \(System › Backup & export\), which returns the whole library to that moment\. It is kept among the last 10 safety backups\./);
|
||||
assert.match(remove, /A later scan, a new image or a node run with the same text adds it again as a new prompt\.$/);
|
||||
assert.equal(guarded, 'Protected prompts are tagged __protected__; remove the tag to delete them.');
|
||||
const [off] = reviewSentences(reviewOf(preview({delete: [1], backup: 'off'})));
|
||||
assert.match(off, /Deleted prompts cannot be brought back\. Image files/);
|
||||
assert.doesNotMatch(off, /safety backup/);
|
||||
});
|
||||
|
||||
test('a review that cannot start says why: nothing to change, or no room for the backup', async () => {
|
||||
const {reviewOf, blockedReason, COPY} = await source;
|
||||
assert.equal(blockedReason(reviewOf(preview({protected: [1]}))), COPY.none);
|
||||
assert.equal(blockedReason(reviewOf(preview({delete: [1], disk: {sufficient: false, message: 'Not enough. Nothing was changed.'}}))),
|
||||
'Not enough. Nothing was changed.');
|
||||
assert.match(blockedReason(reviewOf(preview({delete: [1], disk: {sufficient: false}}))), /not enough free space/);
|
||||
assert.equal(blockedReason(reviewOf(preview({delete: [1], disk: {sufficient: true}}))), null);
|
||||
assert.equal(blockedReason(reviewOf(preview({archive: [1]}))), null);
|
||||
});
|
||||
|
||||
test('a delete outcome counts each part, singular and plural, and names the backup', async () => {
|
||||
const {reviewOf, deleteOutcome} = await source;
|
||||
const review = reviewOf(preview({delete: [1, 2], archive: [3], protected: [9]}));
|
||||
assert.deepEqual(deleteOutcome({success: true, deleted: 2, archived: 3, already_archived: 0, not_found: 0, changed: 0,
|
||||
backup: 'prompts.db.backup_x'}, null, review), {committed: true, reload: true,
|
||||
toast: 'Deleted 2 prompts. Archived 3 prompts. 1 protected prompt was kept. Safety backup saved: prompts.db.backup_x (System › Backup & export).'});
|
||||
assert.equal(deleteOutcome({success: true, deleted: 1, archived: 0, already_archived: 1, not_found: 1, changed: 1,
|
||||
backup: null}, null, reviewOf(preview())).toast,
|
||||
'Deleted 1 prompt. 1 was already archived. 1 no longer exists. 1 changed since the review and was kept.');
|
||||
assert.equal(deleteOutcome({success: true, deleted: 0, archived: 0, already_archived: 2, not_found: 3, changed: 2,
|
||||
backup: null}, null, reviewOf(preview({protected: [1, 2]}))).toast,
|
||||
'Nothing was deleted or archived. 2 were already archived. 3 no longer exist. 2 changed since the review and were kept. 2 protected prompts were kept. The Library was reloaded.');
|
||||
assert.equal(deleteOutcome({success: true, deleted: 0, archived: 0}, null, reviewOf(preview())).committed, false);
|
||||
});
|
||||
|
||||
test('a failed delete says nothing changed, or that it may have, and keeps or reloads the selection', async () => {
|
||||
const {deleteOutcome, COPY} = await source;
|
||||
assert.deepEqual(deleteOutcome(null, {status: 409, code: 'storage-changed'}), {toast: COPY.storage, committed: false, reload: true});
|
||||
assert.equal(COPY.storage, 'The library was restored or moved since this page loaded. Nothing was deleted or archived.');
|
||||
assert.deepEqual(deleteOutcome(null, {status: 409, code: 'busy', running: {title: 'Scan'}}),
|
||||
{toast: 'Scan is running. Nothing was changed; try again when it finishes.', committed: false, reload: false});
|
||||
assert.deepEqual(deleteOutcome(null, {status: 500, code: 'backup-failed', message: 'The safety backup could not be taken and verified; nothing was changed.'}),
|
||||
{toast: 'The safety backup failed. The safety backup could not be taken and verified; nothing was changed.', committed: false, reload: false});
|
||||
assert.deepEqual(deleteOutcome(null, {status: 507, code: 'insufficient-space', message: 'Not enough free disk space. Nothing was changed.'}),
|
||||
{toast: 'Not enough free disk space. Nothing was changed.', committed: false, reload: false});
|
||||
for (const failure of [new TypeError('Failed to fetch'), {status: 502}, {status: 503}, {status: 504}]) {
|
||||
assert.deepEqual(deleteOutcome(null, failure), {toast: COPY.unconfirmed, committed: true, reload: true});
|
||||
}
|
||||
assert.deepEqual(deleteOutcome(null, {status: 400}), {toast: COPY.failed, committed: false, reload: false});
|
||||
});
|
||||
|
||||
test('a failed preview reloads only after a restore; Restore outcomes have their own words', async () => {
|
||||
const {previewOutcome, restoreOutcome, COPY} = await source;
|
||||
assert.deepEqual(previewOutcome({status: 409, code: 'storage-changed'}), {toast: COPY.storage, committed: false, reload: true});
|
||||
assert.deepEqual(previewOutcome({status: 500}), {toast: COPY.reviewFailed, committed: false, reload: false});
|
||||
assert.equal(previewOutcome({status: 409, code: 'busy', running: {title: 'Optimize'}}).toast,
|
||||
'Optimize is running. Nothing was changed; try again when it finishes.');
|
||||
assert.deepEqual(restoreOutcome({success: true, restored: 2, unchanged: 1, not_found: 1}, null),
|
||||
{toast: 'Restored 2 prompts to the Prompt library. 1 was not archived. 1 no longer exists.', committed: true, reload: true});
|
||||
assert.deepEqual(restoreOutcome({success: true, restored: 0, unchanged: 2, not_found: 0}, null),
|
||||
{toast: 'No prompt was restored. 2 were not archived.', committed: false, reload: true});
|
||||
assert.deepEqual(restoreOutcome(null, {status: 409, code: 'storage-changed'}), {toast: COPY.restoreStorage, committed: false, reload: true});
|
||||
assert.deepEqual(restoreOutcome(null, {status: 503}), {toast: COPY.restoreUnconfirmed, committed: true, reload: true});
|
||||
assert.deepEqual(restoreOutcome(null, {status: 400}), {toast: COPY.restoreFailed, committed: false, reload: false});
|
||||
});
|
||||
|
||||
test('reviewOf keeps only well-formed ids and counts from an answer', async () => {
|
||||
const {reviewOf} = await source;
|
||||
const review = reviewOf({library_epoch: 3, delete: [1, 'x', -2, 2], archive: null, protected: [1.5], already_archived: -1,
|
||||
not_found: 'x', image_links: 2, backup: 'maybe'});
|
||||
assert.deepEqual(review, {epoch: null, delete: [1, 2], archive: [], protected: [], alreadyArchived: 0, notFound: 0,
|
||||
imageLinks: 2, backup: 'on', disk: null});
|
||||
});
|
||||
|
||||
test('the archived notice says why (the saved pair) and names negative variants only when there are any', async () => {
|
||||
const {archivedNoticeText} = await source;
|
||||
assert.equal(archivedNoticeText(0), 'This prompt is archived because it is in a saved pair. It is hidden from the Prompt ' +
|
||||
'library; nothing was removed.');
|
||||
assert.equal(archivedNoticeText(1), 'This prompt is archived because it is in a saved pair. It and its 1 negative variant ' +
|
||||
'are hidden from the Prompt library; nothing was removed.');
|
||||
assert.equal(archivedNoticeText(3), 'This prompt is archived because it is in a saved pair. It and its 3 negative variants ' +
|
||||
'are hidden from the Prompt library; nothing was removed.');
|
||||
// An unknown count (an older server) never claims variants.
|
||||
assert.equal(archivedNoticeText(undefined), archivedNoticeText(0));
|
||||
});
|
||||
@@ -0,0 +1,525 @@
|
||||
"""#443 (D152): delete selected Library prompts; paired ones are archived.
|
||||
|
||||
The DB commands, and what every reader does with an archived parent.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from database.execution_history_schema import rotate_history_epoch
|
||||
from database.library_commands import LibraryCommandError
|
||||
from database.library_folders import folder_prefixes
|
||||
from database.operations import PromptDatabase
|
||||
from database.prompt_archive import TABLE as ARCHIVE
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
|
||||
def save(db, text, **metadata):
|
||||
return db.save_prompt(text, prompt_hash=generate_prompt_hash(text), **metadata)
|
||||
|
||||
|
||||
def rows(db, sql, params=()):
|
||||
with db.model.get_connection() as conn:
|
||||
return [tuple(row) for row in conn.execute(sql, params).fetchall()]
|
||||
|
||||
|
||||
def archived_ids(db):
|
||||
return sorted(row[0] for row in rows(db, f"SELECT prompt_id FROM {ARCHIVE}"))
|
||||
|
||||
|
||||
def prompt_ids(db):
|
||||
return sorted(row[0] for row in rows(db, "SELECT id FROM prompts"))
|
||||
|
||||
|
||||
_STAMP = re.compile(r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}")
|
||||
|
||||
|
||||
def _stable(value):
|
||||
return "<time>" if isinstance(value, str) and _STAMP.match(value) else value
|
||||
|
||||
|
||||
def table_dump(db):
|
||||
"""Every row of every table (times masked), to compare two databases."""
|
||||
tables = [
|
||||
row[0]
|
||||
for row in rows(
|
||||
db,
|
||||
"SELECT name FROM sqlite_master WHERE type='table' "
|
||||
"AND name NOT LIKE 'sqlite_%' AND name!='execution_history_metadata' "
|
||||
"ORDER BY name",
|
||||
)
|
||||
]
|
||||
dump = {}
|
||||
for table in tables:
|
||||
dump[table] = sorted(
|
||||
(tuple(map(_stable, row)) for row in rows(db, f"SELECT * FROM {table}")),
|
||||
key=repr,
|
||||
)
|
||||
return dump
|
||||
|
||||
|
||||
class LibraryDeleteCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp.name)
|
||||
self.db = PromptDatabase(str(self.root / "delete.db"))
|
||||
self.epoch = self.db.get_library_options()["library_epoch"]
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close_all()
|
||||
self.temp.cleanup()
|
||||
|
||||
def paired(self, positive, negative="blur"):
|
||||
pair = self.db.save_prompt_pair(positive, negative)
|
||||
found = self.db.get_prompt_pair(pair)
|
||||
return found["positive_prompt_id"], found["negative_prompt_id"], pair
|
||||
|
||||
def image(self, prompt_id, name):
|
||||
path = self.root / name
|
||||
path.write_bytes(b"png")
|
||||
return self.db.link_image_to_prompt(prompt_id, str(path))
|
||||
|
||||
def apply(self, ids, epoch=None, review=None):
|
||||
"""Review ``ids`` (or use ``review``), then write what was reviewed."""
|
||||
review = review or self.classify(ids)
|
||||
return self.db.archive_or_delete_library_prompts(
|
||||
review["archive"],
|
||||
review["delete"],
|
||||
expected_epoch=epoch or self.epoch,
|
||||
candidates=review["fingerprints"],
|
||||
)
|
||||
|
||||
def classify(self, ids, epoch=None):
|
||||
return self.db.classify_library_delete(ids, expected_epoch=epoch or self.epoch)
|
||||
|
||||
def restore(self, ids, epoch=None):
|
||||
return self.db.restore_library_prompts(ids, expected_epoch=epoch or self.epoch)
|
||||
|
||||
|
||||
class TestClassifyAndApply(LibraryDeleteCase):
|
||||
def test_each_class_in_order(self):
|
||||
plain = save(self.db, "Plain")
|
||||
protected = save(self.db, "Guarded", tags=["__protected__"])
|
||||
protected_paired, _neg, _pair = self.paired("Guarded pair")
|
||||
self.db.bulk_add_tags([protected_paired], ["__protected__"])
|
||||
positive, negative_only, _pair = self.paired("Cat", "only negative")
|
||||
both = save(self.db, "Both roles")
|
||||
self.image(both, "both.png") # a parent through its image
|
||||
self.db.save_prompt_pair("Other", "Both roles")
|
||||
archived_pair_positive, _n, old_pair = self.paired("Old")
|
||||
self.db.archive_prompt_pair(old_pair, True)
|
||||
already, _n2, _p2 = self.paired("Already")
|
||||
self.apply([already])
|
||||
self.image(plain, "plain.png")
|
||||
ids = [
|
||||
plain,
|
||||
protected,
|
||||
protected_paired,
|
||||
positive,
|
||||
negative_only,
|
||||
both,
|
||||
archived_pair_positive,
|
||||
already,
|
||||
99999,
|
||||
]
|
||||
result = self.classify(ids)
|
||||
self.assertEqual(
|
||||
result,
|
||||
{
|
||||
"library_epoch": self.epoch,
|
||||
"delete": [plain],
|
||||
"archive": [positive, both, archived_pair_positive],
|
||||
"protected": [protected, protected_paired],
|
||||
"already_archived": [already],
|
||||
"not_found": [negative_only, 99999],
|
||||
"image_links": 1,
|
||||
"fingerprints": result["fingerprints"],
|
||||
},
|
||||
)
|
||||
self.assertEqual(list(result["fingerprints"]), [plain])
|
||||
|
||||
def test_apply_archives_paired_deletes_unpaired_and_skips_protected(self):
|
||||
plain = save(self.db, "Plain", tags=["red"])
|
||||
protected = save(self.db, "Guarded", tags=["__protected__"])
|
||||
positive, negative, pair = self.paired("Cat")
|
||||
self.image(plain, "a.png")
|
||||
self.image(positive, "b.png")
|
||||
before = rows(self.db, "SELECT updated_at FROM prompts WHERE id=?", (positive,))
|
||||
result = self.apply([plain, protected, positive, 99999])
|
||||
self.assertEqual(
|
||||
result,
|
||||
{
|
||||
"library_epoch": self.epoch,
|
||||
"deleted": 1,
|
||||
"archived": 1,
|
||||
"already_archived": 0,
|
||||
"not_found": 0,
|
||||
"changed": 0,
|
||||
"image_links": 1,
|
||||
},
|
||||
)
|
||||
self.assertEqual(prompt_ids(self.db), sorted([protected, positive, negative]))
|
||||
self.assertEqual(archived_ids(self.db), [positive])
|
||||
# Archive keeps everything: the pair, its image link and the prompt row.
|
||||
self.assertEqual(
|
||||
len(rows(self.db, "SELECT 1 FROM prompt_pairs WHERE id=?", (pair,))), 1
|
||||
)
|
||||
self.assertEqual(
|
||||
len(
|
||||
rows(
|
||||
self.db,
|
||||
"SELECT 1 FROM generated_images WHERE prompt_id=?",
|
||||
(positive,),
|
||||
)
|
||||
),
|
||||
1,
|
||||
)
|
||||
after = rows(self.db, "SELECT updated_at FROM prompts WHERE id=?", (positive,))
|
||||
self.assertNotEqual(before, after)
|
||||
# The image file stays on disk.
|
||||
self.assertTrue((self.root / "a.png").exists())
|
||||
|
||||
def test_delete_has_exactly_the_legacy_row_effects(self):
|
||||
twin = PromptDatabase(str(self.root / "twin.db"))
|
||||
self.addCleanup(twin.close_all)
|
||||
twin_epoch = twin.get_library_options()["library_epoch"]
|
||||
for db in (self.db, twin):
|
||||
a = save(db, "Alpha", tags=["x", "y"], category="c")
|
||||
save(db, "Beta")
|
||||
path = self.root / "shared.png"
|
||||
path.write_bytes(b"png")
|
||||
db.link_image_to_prompt(a, str(path), {"workflow": {"nodes": []}})
|
||||
self.db.bulk_delete_prompts([a])
|
||||
twin.archive_or_delete_library_prompts(
|
||||
[], [a], expected_epoch=twin_epoch, candidates=None
|
||||
)
|
||||
self.assertEqual(table_dump(self.db), table_dump(twin))
|
||||
|
||||
def test_already_archived_and_restore(self):
|
||||
positive, _neg, _pair = self.paired("Cat")
|
||||
self.apply([positive])
|
||||
again = self.classify([positive])
|
||||
self.assertEqual(
|
||||
(again["archive"], again["already_archived"]), ([], [positive])
|
||||
)
|
||||
# Archived between the review and the write: counted, not archived twice.
|
||||
other, _n, _p = self.paired("Dog")
|
||||
review = self.classify([other])
|
||||
self.apply([other])
|
||||
late = self.apply([], review=review)
|
||||
self.assertEqual((late["archived"], late["already_archived"]), (0, 1))
|
||||
plain = save(self.db, "Plain")
|
||||
result = self.restore([positive, plain, 99999])
|
||||
self.assertEqual(
|
||||
result,
|
||||
{
|
||||
"library_epoch": self.epoch,
|
||||
"restored": 1,
|
||||
"unchanged": 1,
|
||||
"not_found": 1,
|
||||
},
|
||||
)
|
||||
self.assertEqual(archived_ids(self.db), [other])
|
||||
|
||||
def test_the_write_executes_only_what_was_reviewed(self):
|
||||
plain = save(self.db, "Plain")
|
||||
guarded = save(self.db, "Guarded later")
|
||||
touched = save(self.db, "Touched later")
|
||||
review = self.classify([plain, guarded, touched])
|
||||
# Between the review and the write: one becomes protected, one is used.
|
||||
self.db.bulk_add_tags([guarded], ["__protected__"])
|
||||
self.db.record_prompt_use(touched)
|
||||
result = self.apply([], review=review)
|
||||
self.assertEqual((result["deleted"], result["changed"]), (1, 2))
|
||||
self.assertEqual(prompt_ids(self.db), sorted([guarded, touched]))
|
||||
self.assertEqual(archived_ids(self.db), [])
|
||||
|
||||
def test_a_new_image_link_after_the_review_keeps_the_prompt(self):
|
||||
plain = save(self.db, "Plain")
|
||||
review = self.classify([plain])
|
||||
self.image(plain, "late.png")
|
||||
self.assertEqual(self.apply([], review=review)["changed"], 1)
|
||||
self.assertIn(plain, prompt_ids(self.db))
|
||||
|
||||
def test_reviewed_deletes_without_a_backup_are_kept(self):
|
||||
plain = save(self.db, "Plain")
|
||||
review = self.classify([plain])
|
||||
result = self.db.archive_or_delete_library_prompts(
|
||||
[], [plain], expected_epoch=self.epoch, candidates={}
|
||||
)
|
||||
self.assertEqual((result["deleted"], result["changed"]), (0, 1))
|
||||
self.assertEqual(review["delete"], [plain])
|
||||
|
||||
def test_a_reviewed_delete_that_became_paired_is_archived(self):
|
||||
plain = save(self.db, "Plain")
|
||||
review = self.classify([plain])
|
||||
self.db.save_prompt_pair("Plain", "late negative")
|
||||
result = self.apply([], review=review)
|
||||
self.assertEqual((result["deleted"], result["archived"]), (0, 1))
|
||||
|
||||
def test_a_reviewed_archive_that_lost_its_pair_is_archived_not_deleted(self):
|
||||
positive, _negative, pair = self.paired("Cat")
|
||||
review = self.classify([positive])
|
||||
self.db.delete_prompt_pair(pair)
|
||||
result = self.apply([], review=review)
|
||||
self.assertEqual((result["deleted"], result["archived"]), (0, 1))
|
||||
self.assertEqual(archived_ids(self.db), [positive])
|
||||
|
||||
def test_one_id_in_both_lists_or_no_ids_is_refused(self):
|
||||
plain = save(self.db, "Plain")
|
||||
for archive, delete in (([plain], [plain]), ([], [])):
|
||||
with self.subTest(archive=archive, delete=delete):
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.archive_or_delete_library_prompts(
|
||||
archive, delete, expected_epoch=self.epoch, candidates=None
|
||||
)
|
||||
|
||||
def test_archiving_repairs_a_missing_or_stale_hash(self):
|
||||
missing, _n1, _p1 = self.paired("Hashless")
|
||||
stale, _n2, _p2 = self.paired("Stale hash")
|
||||
with self.db.model.get_connection() as conn:
|
||||
conn.execute("UPDATE prompts SET hash=NULL WHERE id=?", (missing,))
|
||||
conn.execute("UPDATE prompts SET hash='stale' WHERE id=?", (stale,))
|
||||
self.apply([missing, stale])
|
||||
for pid, text in ((missing, "Hashless"), (stale, "Stale hash")):
|
||||
self.assertEqual(
|
||||
rows(self.db, "SELECT hash FROM prompts WHERE id=?", (pid,)),
|
||||
[(generate_prompt_hash(text),)],
|
||||
)
|
||||
# So every ingestion lookup finds the archived row.
|
||||
self.assertEqual(save(self.db, text), pid)
|
||||
|
||||
def test_a_canonical_hash_another_row_holds_is_left_alone(self):
|
||||
stale, _n, _p = self.paired("Twin text")
|
||||
with self.db.model.get_connection() as conn:
|
||||
conn.execute("UPDATE prompts SET hash='stale' WHERE id=?", (stale,))
|
||||
twin = save(self.db, "twin text")
|
||||
self.apply([stale])
|
||||
self.assertEqual(
|
||||
rows(self.db, "SELECT hash FROM prompts WHERE id=?", (stale,)), [("stale",)]
|
||||
)
|
||||
self.assertEqual(save(self.db, "Twin text"), twin)
|
||||
|
||||
def test_restore_unhides_protected_and_non_parent_archived_prompts(self):
|
||||
guarded, _n, _p = self.paired("Guarded")
|
||||
self.apply([guarded])
|
||||
self.db.bulk_add_tags([guarded], ["__protected__"])
|
||||
negative_only = save(self.db, "Only a negative")
|
||||
self.db.save_prompt_pair("Owner", "Only a negative")
|
||||
with self.db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
f"INSERT INTO {ARCHIVE} VALUES (?, '2026-10-28T00:00:00+00:00')",
|
||||
(negative_only,),
|
||||
)
|
||||
result = self.restore([guarded, negative_only])
|
||||
self.assertEqual(result["restored"], 2)
|
||||
self.assertEqual(archived_ids(self.db), [])
|
||||
|
||||
def test_restored_storage_changes_nothing(self):
|
||||
plain = save(self.db, "Plain")
|
||||
positive, _neg, _pair = self.paired("Cat")
|
||||
rotate_history_epoch(self.db.model.get_connection())
|
||||
for call in (
|
||||
lambda: self.apply([plain, positive]),
|
||||
lambda: self.classify([plain]),
|
||||
lambda: self.restore([positive]),
|
||||
):
|
||||
with self.assertRaises(LibraryCommandError) as caught:
|
||||
call()
|
||||
self.assertEqual(caught.exception.code, "storage-changed")
|
||||
self.assertIn(plain, prompt_ids(self.db))
|
||||
self.assertEqual(archived_ids(self.db), [])
|
||||
|
||||
def test_a_failure_mid_write_rolls_everything_back(self):
|
||||
plain = save(self.db, "Plain")
|
||||
positive, _neg, _pair = self.paired("Cat")
|
||||
with self.db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"CREATE TRIGGER fail_delete BEFORE DELETE ON prompts "
|
||||
"BEGIN SELECT RAISE(ABORT, 'boom'); END"
|
||||
)
|
||||
with self.assertRaises(sqlite3.DatabaseError):
|
||||
self.apply([positive, plain])
|
||||
self.assertIn(plain, prompt_ids(self.db))
|
||||
self.assertEqual(archived_ids(self.db), [])
|
||||
|
||||
def test_many_ids_are_handled_in_batches(self):
|
||||
ids = [save(self.db, f"Many {i}") for i in range(1000)]
|
||||
result = self.apply(ids)
|
||||
self.assertEqual(result["deleted"], 1000)
|
||||
|
||||
def test_rejects_invalid_arguments(self):
|
||||
plain = save(self.db, "Plain")
|
||||
for ids, epoch in (([], self.epoch), ([True], self.epoch), ([plain], "x")):
|
||||
for call in (self.apply, self.classify, self.restore):
|
||||
with self.subTest(ids=ids, epoch=epoch, call=call.__name__):
|
||||
with self.assertRaises(ValueError):
|
||||
call(ids, epoch)
|
||||
|
||||
|
||||
class TestArchivedReaders(LibraryDeleteCase):
|
||||
"""What each reader does with an archived parent (design §4.4)."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.kept, _kn, _kp = self.paired("Kept cat", "kept blur")
|
||||
self.gone, _gn, _gp = self.paired("Gone cat", "gone blur")
|
||||
for pid, category, tag in ((self.kept, "keep", "k"), (self.gone, "gone", "g")):
|
||||
self.db.update_prompt_metadata(pid, category=category, tags=[tag])
|
||||
self.apply([self.gone])
|
||||
|
||||
def ids_of(self, page):
|
||||
return [item["id"] for item in page["items"]]
|
||||
|
||||
def test_listing_search_and_totals(self):
|
||||
active = self.db.list_library()
|
||||
self.assertEqual(self.ids_of(active), [self.kept])
|
||||
self.assertEqual(
|
||||
(active["total"], active["variant_total"], active["archived_total"]),
|
||||
(1, 1, 1),
|
||||
)
|
||||
archived = self.db.list_library(shelf="archived")
|
||||
self.assertEqual(self.ids_of(archived), [self.gone])
|
||||
self.assertEqual((archived["total"], archived["variant_total"]), (1, 1))
|
||||
self.assertEqual(self.ids_of(self.db.list_library(text="Gone")), [])
|
||||
self.assertEqual(
|
||||
self.ids_of(self.db.list_library(text="Gone", shelf="archived")),
|
||||
[self.gone],
|
||||
)
|
||||
self.assertEqual(self.ids_of(self.db.list_library(tags=["g"])), [])
|
||||
self.assertEqual(self.db.list_library(category="gone")["archived_total"], 1)
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.list_library(shelf="all")
|
||||
|
||||
def test_the_444_filters_and_tag_counts_hide_it(self):
|
||||
# #444's folder filter, tag match and tag ranking keep #443's shelves.
|
||||
bare, _bn, _bp = self.paired("Bare cat", "bare blur")
|
||||
self.apply([bare])
|
||||
folder = self.root / "out" / "cats"
|
||||
folder.mkdir(parents=True)
|
||||
for pid, name in ((self.kept, "kept.png"), (self.gone, "gone.png")):
|
||||
self.image(pid, str(Path("out") / "cats" / name))
|
||||
prefixes = folder_prefixes([str(self.root / "out")], "cats")
|
||||
self.assertEqual(
|
||||
self.ids_of(self.db.list_library(folder_prefixes=prefixes)), [self.kept]
|
||||
)
|
||||
self.assertEqual(
|
||||
self.ids_of(
|
||||
self.db.list_library(folder_prefixes=prefixes, shelf="archived")
|
||||
),
|
||||
[self.gone],
|
||||
)
|
||||
self.assertEqual(
|
||||
self.ids_of(self.db.list_library(tags=["k", "g"], tag_mode="any")),
|
||||
[self.kept],
|
||||
)
|
||||
self.assertEqual(self.ids_of(self.db.list_library(tag_mode="untagged")), [])
|
||||
self.assertEqual(
|
||||
self.ids_of(self.db.list_library(tag_mode="untagged", shelf="archived")),
|
||||
[bare],
|
||||
)
|
||||
tags = self.db.list_vocabulary_tags(sort="prompts")
|
||||
self.assertEqual(
|
||||
{row["name"]: row["prompt_count"] for row in tags["tags"]},
|
||||
{"k": 1, "g": 0},
|
||||
)
|
||||
self.assertEqual(tags["untagged_count"], 0)
|
||||
self.assertEqual(
|
||||
self.db.list_vocabulary_tags(search="g", sort="prompts")["tags"][0][
|
||||
"prompt_count"
|
||||
],
|
||||
0,
|
||||
)
|
||||
|
||||
def test_detail_and_options(self):
|
||||
self.assertTrue(self.db.get_library_positive(self.gone)["positive"]["archived"])
|
||||
self.assertFalse(
|
||||
self.db.get_library_positive(self.kept)["positive"]["archived"]
|
||||
)
|
||||
# Suggestions keep archived parents' categories: the archived view
|
||||
# filters by them.
|
||||
self.assertEqual(self.db.get_library_options()["categories"], ["gone", "keep"])
|
||||
|
||||
def test_counts(self):
|
||||
self.assertEqual(self.db.library_counts()["positive_prompts"], 1)
|
||||
self.assertEqual(self.db.library_counts()["negative_variants"], 1)
|
||||
tags = {
|
||||
row["name"]: row["prompt_count"]
|
||||
for row in self.db.list_vocabulary_tags()["tags"]
|
||||
}
|
||||
self.assertEqual((tags["k"], tags["g"]), (1, 0))
|
||||
categories = {
|
||||
row["value"]: row["prompt_count"]
|
||||
for row in self.db.list_vocabulary_categories()["categories"]
|
||||
}
|
||||
# A value only archived parents hold stays listed with 0, as a tag does.
|
||||
self.assertEqual(categories, {"keep": 1, "gone": 0})
|
||||
|
||||
def test_restore_summary_and_export(self):
|
||||
from database.restore_stage import library_summary
|
||||
|
||||
summary = library_summary(self.db.model.get_connection())
|
||||
self.assertEqual(summary["positives"], 1)
|
||||
exported = {
|
||||
item["positive_text"]: item["archived"]
|
||||
for item in self.db.export_library()["positives"]
|
||||
}
|
||||
self.assertEqual(exported, {"Kept cat": False, "Gone cat": True})
|
||||
|
||||
def test_usage_statistics_keep_archived_runs(self):
|
||||
self.db.record_prompt_use(self.gone, 3)
|
||||
runs = {row["category"]: row["runs"] for row in self.db.usage_by_category()}
|
||||
self.assertEqual(runs["gone"], 3)
|
||||
|
||||
def test_runs_node_saves_and_new_prompt_find_the_archived_row(self):
|
||||
count = len(prompt_ids(self.db))
|
||||
self.assertTrue(self.db.record_prompt_use(self.gone, 2))
|
||||
existing = self.db.get_prompt_by_hash(generate_prompt_hash("Gone cat"))
|
||||
self.assertEqual(existing["id"], self.gone)
|
||||
self.assertEqual(save(self.db, "Gone cat"), self.gone)
|
||||
self.db.save_prompt_pair("Gone cat", "new blur")
|
||||
self.assertEqual(len(prompt_ids(self.db)), count + 1) # only the new negative
|
||||
self.assertEqual(archived_ids(self.db), [self.gone])
|
||||
|
||||
def test_legacy_reads_still_list_it_and_legacy_delete_still_refuses(self):
|
||||
from database.pairs import PairDependencyError
|
||||
|
||||
texts = [p["text"] for p in self.db.get_recent_prompts(limit=50)["prompts"]]
|
||||
self.assertIn("Gone cat", texts)
|
||||
with self.assertRaises(PairDependencyError):
|
||||
self.db.bulk_delete_prompts([self.gone])
|
||||
|
||||
def test_as_another_parents_negative_it_stays_a_visible_variant(self):
|
||||
owner, _neg, pair = self.paired("Owner cat", "Gone cat")
|
||||
detail = self.db.get_library_positive(owner)
|
||||
self.assertEqual([v["id"] for v in detail["variants"]], [pair])
|
||||
self.assertEqual(self.ids_of(self.db.list_library(text="Gone")), [owner])
|
||||
|
||||
def test_maintenance_never_prunes_an_archived_prompt(self):
|
||||
lonely = save(self.db, "Lonely")
|
||||
with self.db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
f"INSERT INTO {ARCHIVE} VALUES (?, '2026-10-28T00:00:00+00:00')",
|
||||
(lonely,),
|
||||
)
|
||||
orphaned = [row[0] for row in conn.execute(self.orphaned_sql()).fetchall()]
|
||||
self.assertNotIn(lonely, orphaned)
|
||||
|
||||
@staticmethod
|
||||
def orphaned_sql():
|
||||
from database.maintenance import ORPHANED_PROMPTS_SQL
|
||||
|
||||
return ORPHANED_PROMPTS_SQL
|
||||
|
||||
def test_bulk_metadata_writes_still_reach_an_archived_parent(self):
|
||||
result = self.db.bulk_set_library_category(
|
||||
[self.gone], "moved", expected_epoch=self.epoch
|
||||
)
|
||||
self.assertEqual(result["updated"], 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -400,6 +400,32 @@ class HistoryReadTests(unittest.TestCase):
|
||||
pass
|
||||
self.assertFalse(conn.in_transaction)
|
||||
|
||||
def test_a_deleted_negative_is_unavailable_and_a_negative_only_one_is_not(self):
|
||||
# #443: a Library delete can remove an unpaired prompt a run used as
|
||||
# its negative. The run's prompt ids are live hints, so a deleted one
|
||||
# reads unavailable; a negative-only prompt stays available.
|
||||
db = self.database()
|
||||
self.saved(db)
|
||||
self.saved(db, "negative", 2)
|
||||
start = self.capture(db)
|
||||
with db.recorded_history_snapshot():
|
||||
context = db.get_history_run(start.run_id)["contexts"][0]
|
||||
self.assertTrue(context["navigation"]["negative_available"])
|
||||
with db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO prompt_pairs(id,positive_prompt_id,negative_prompt_id) "
|
||||
"VALUES(9,1,2)"
|
||||
)
|
||||
with db.recorded_history_snapshot():
|
||||
context = db.get_history_run(start.run_id)["contexts"][0]
|
||||
self.assertTrue(context["navigation"]["negative_available"])
|
||||
with db.model.get_connection() as conn:
|
||||
conn.execute("DELETE FROM prompt_pairs WHERE id=9")
|
||||
conn.execute("DELETE FROM prompts WHERE id=2")
|
||||
with db.recorded_history_snapshot():
|
||||
context = db.get_history_run(start.run_id)["contexts"][0]
|
||||
self.assertFalse(context["navigation"]["negative_available"])
|
||||
|
||||
def test_detail_preserves_full_text_snapshots_hints_and_archive(self):
|
||||
db = self.database()
|
||||
self.saved(db)
|
||||
|
||||
@@ -345,7 +345,8 @@ class TestLibrary(unittest.TestCase):
|
||||
self.assertEqual(
|
||||
(parent["title"], parent["notes"]), ("Portrait", "keep\nnotes")
|
||||
)
|
||||
self.assertNotIn("archived", parent)
|
||||
# The parent's own archive state (#443), never a pair's.
|
||||
self.assertIs(parent["archived"], False)
|
||||
self.assertRegex(parent["edit_token"], r"^[0-9a-f]{64}$")
|
||||
self.assertEqual(page["items"][1]["title"], " \t\u00a0\u3000")
|
||||
self.assertEqual(self.db.list_library(text="Portrait")["total"], 1)
|
||||
@@ -1520,6 +1521,7 @@ class TestLibrary(unittest.TestCase):
|
||||
"tags",
|
||||
"rating",
|
||||
"notes",
|
||||
"archived",
|
||||
"variants",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
"""#443 (D152): the Library delete, archive and restore routes, and ingestion after."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from tests.test_api_admin import AdminAPITestCase # first: it stubs ComfyUI
|
||||
from database.execution_history_schema import rotate_history_epoch # noqa: E402
|
||||
from database.prompt_archive import TABLE as ARCHIVE # noqa: E402
|
||||
from database.safety_backup import SafetyBackupFailed # noqa: E402
|
||||
from py.config import PromptManagerConfig # noqa: E402
|
||||
from utils.hashing import generate_prompt_hash # noqa: E402
|
||||
|
||||
PREVIEW = "/prompt_manager/library/delete/preview"
|
||||
DELETE = "/prompt_manager/library/delete"
|
||||
RESTORE = "/prompt_manager/library/restore"
|
||||
|
||||
|
||||
def graph(positive, negative="blur"):
|
||||
return {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": positive}},
|
||||
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": negative}},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": ["1", 0], "negative": ["2", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class LibraryDeleteRouteCase(AdminAPITestCase):
|
||||
def epoch(self):
|
||||
return self.api.db.get_library_options()["library_epoch"]
|
||||
|
||||
def save(self, text, **metadata):
|
||||
return self.api.db.save_prompt(
|
||||
text, prompt_hash=generate_prompt_hash(text), **metadata
|
||||
)
|
||||
|
||||
def paired(self, positive, negative="blur"):
|
||||
pair = self.api.db.save_prompt_pair(positive, negative)
|
||||
return self.api.db.get_prompt_pair(pair)["positive_prompt_id"]
|
||||
|
||||
def rows(self, sql, params=()):
|
||||
with self.api.db.model.get_connection() as conn:
|
||||
return [tuple(row) for row in conn.execute(sql, params).fetchall()]
|
||||
|
||||
def ids(self):
|
||||
return sorted(row[0] for row in self.rows("SELECT id FROM prompts"))
|
||||
|
||||
def archived(self):
|
||||
return sorted(row[0] for row in self.rows(f"SELECT prompt_id FROM {ARCHIVE}"))
|
||||
|
||||
def backups(self):
|
||||
folder = os.path.dirname(self.api.db.model.db_path)
|
||||
return sorted(name for name in os.listdir(folder) if ".backup_" in name)
|
||||
|
||||
async def post(self, url, body):
|
||||
response = await self.client.request("POST", url, json=body)
|
||||
return response.status, await response.json()
|
||||
|
||||
async def review(self, ids):
|
||||
status, body = await self.post(
|
||||
PREVIEW, {"prompt_ids": ids, "expected_epoch": self.epoch()}
|
||||
)
|
||||
self.assertEqual(status, 200, body)
|
||||
return body
|
||||
|
||||
async def confirm(self, review, backup=None):
|
||||
return await self.post(
|
||||
DELETE,
|
||||
{
|
||||
"archive_ids": review["archive"],
|
||||
"delete_ids": review["delete"],
|
||||
"expected_epoch": review["library_epoch"],
|
||||
"backup": backup or review["backup"],
|
||||
},
|
||||
)
|
||||
|
||||
def policy(self, on):
|
||||
previous = PromptManagerConfig.SAFETY_BACKUP
|
||||
PromptManagerConfig.SAFETY_BACKUP = on
|
||||
self.addCleanup(setattr, PromptManagerConfig, "SAFETY_BACKUP", previous)
|
||||
|
||||
|
||||
class TestLibraryDeleteRoutes(LibraryDeleteRouteCase):
|
||||
async def test_preview_then_delete_archives_deletes_and_backs_up(self):
|
||||
self.policy(True)
|
||||
plain = self.save("Plain")
|
||||
guarded = self.save("Guarded", tags=["__protected__"])
|
||||
positive = self.paired("Cat")
|
||||
review = await self.review([plain, guarded, positive, 99999])
|
||||
self.assertEqual(
|
||||
{
|
||||
key: review[key]
|
||||
for key in ("delete", "archive", "protected", "not_found")
|
||||
},
|
||||
{
|
||||
"delete": [plain],
|
||||
"archive": [positive],
|
||||
"protected": [guarded],
|
||||
"not_found": 1,
|
||||
},
|
||||
)
|
||||
self.assertEqual(review["backup"], "on")
|
||||
self.assertTrue(review["disk"]["sufficient"])
|
||||
status, body = await self.confirm(review)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(
|
||||
(body["deleted"], body["archived"], body["changed"]), (1, 1, 0)
|
||||
)
|
||||
self.assertEqual(self.backups(), [body["backup"]])
|
||||
self.assertNotIn(os.sep, body["backup"])
|
||||
self.assertNotIn(plain, self.ids())
|
||||
self.assertEqual(self.archived(), [positive])
|
||||
# The backup still holds the deleted prompt.
|
||||
import sqlite3
|
||||
|
||||
path = os.path.join(os.path.dirname(self.api.db.model.db_path), body["backup"])
|
||||
with sqlite3.connect(path) as copy:
|
||||
self.assertEqual(
|
||||
copy.execute(
|
||||
"SELECT COUNT(*) FROM prompts WHERE id=?", (plain,)
|
||||
).fetchone()[0],
|
||||
1,
|
||||
)
|
||||
copy.close()
|
||||
# The hold ended: nothing is listed as running.
|
||||
self.assertEqual(self.api._jobs.running(), [])
|
||||
|
||||
async def test_archive_only_takes_no_backup(self):
|
||||
self.policy(True)
|
||||
positive = self.paired("Cat")
|
||||
review = await self.review([positive])
|
||||
self.assertIsNone(review["disk"])
|
||||
status, body = await self.confirm(review)
|
||||
self.assertEqual((status, body["archived"], body["backup"]), (200, 1, None))
|
||||
self.assertEqual(self.backups(), [])
|
||||
|
||||
async def test_policy_off_takes_none_unless_the_review_promised_one(self):
|
||||
plain = self.save("Plain")
|
||||
other = self.save("Other")
|
||||
self.policy(False)
|
||||
review = await self.review([plain])
|
||||
self.assertEqual((review["backup"], review["disk"]), ("off", None))
|
||||
status, body = await self.confirm(review)
|
||||
self.assertEqual((status, body["deleted"], body["backup"]), (200, 1, None))
|
||||
# Reviewed with the policy on, turned off before the confirm: kept.
|
||||
review = await self.review([other])
|
||||
status, body = await self.confirm(review, backup="on")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIsNotNone(body["backup"])
|
||||
|
||||
async def test_a_failed_backup_changes_nothing(self):
|
||||
self.policy(True)
|
||||
plain = self.save("Plain")
|
||||
positive = self.paired("Cat")
|
||||
review = await self.review([plain, positive])
|
||||
with patch(
|
||||
"py.api.maintenance.take_safety_backup",
|
||||
side_effect=SafetyBackupFailed("x"),
|
||||
):
|
||||
status, body = await self.confirm(review)
|
||||
self.assertEqual((status, body["code"]), (500, "backup-failed"))
|
||||
self.assertIn("nothing was changed", body["error"])
|
||||
self.assertIn(plain, self.ids())
|
||||
self.assertEqual(self.archived(), [])
|
||||
|
||||
async def test_a_short_disk_refuses_before_any_change(self):
|
||||
self.policy(True)
|
||||
plain = self.save("Plain")
|
||||
from py.api import maintenance
|
||||
|
||||
with patch.object(maintenance, "_free_bytes", return_value=0):
|
||||
review = await self.review([plain])
|
||||
self.assertFalse(review["disk"]["sufficient"])
|
||||
self.assertIn("Nothing was changed", review["disk"]["message"])
|
||||
status, body = await self.confirm(review)
|
||||
self.assertEqual((status, body["code"]), (507, "insufficient-space"))
|
||||
self.assertIn(plain, self.ids())
|
||||
|
||||
async def test_busy_beside_a_record_changing_job(self):
|
||||
plain = self.save("Plain")
|
||||
review = await self.review([plain])
|
||||
async with self.api._jobs.hold("scan", "Scan", admission="records"):
|
||||
status, body = await self.confirm(review)
|
||||
self.assertEqual((status, body["code"]), (409, "busy"))
|
||||
self.assertEqual(body["running"]["title"], "Scan")
|
||||
self.assertIn(plain, self.ids())
|
||||
|
||||
async def test_restored_storage_answers_409_on_every_route(self):
|
||||
plain = self.save("Plain")
|
||||
review = await self.review([plain])
|
||||
rotate_history_epoch(self.api.db.model.get_connection())
|
||||
status, body = await self.confirm(review)
|
||||
self.assertEqual((status, body["code"]), (409, "storage-changed"))
|
||||
for url in (PREVIEW, RESTORE):
|
||||
status, body = await self.post(
|
||||
url, {"prompt_ids": [plain], "expected_epoch": review["library_epoch"]}
|
||||
)
|
||||
self.assertEqual((status, body["code"]), (409, "storage-changed"))
|
||||
self.assertIn(plain, self.ids())
|
||||
|
||||
async def test_restore_route(self):
|
||||
positive = self.paired("Cat")
|
||||
await self.confirm(await self.review([positive]))
|
||||
status, body = await self.post(
|
||||
RESTORE, {"prompt_ids": [positive, 99999], "expected_epoch": self.epoch()}
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual((body["restored"], body["not_found"]), (1, 1))
|
||||
self.assertEqual(self.archived(), [])
|
||||
|
||||
async def test_bodies_are_validated(self):
|
||||
plain = self.save("Plain")
|
||||
epoch = self.epoch()
|
||||
selection = {"prompt_ids": [plain], "expected_epoch": epoch}
|
||||
for url, body in (
|
||||
(PREVIEW, {**selection, "extra": 1}),
|
||||
(PREVIEW, {"prompt_ids": [], "expected_epoch": epoch}),
|
||||
(PREVIEW, {"prompt_ids": [True], "expected_epoch": epoch}),
|
||||
(PREVIEW, {"prompt_ids": list(range(1, 1002)), "expected_epoch": epoch}),
|
||||
(PREVIEW, {"prompt_ids": [plain], "expected_epoch": "nope"}),
|
||||
(RESTORE, {"prompt_ids": [plain]}),
|
||||
(DELETE, {**selection}),
|
||||
(
|
||||
DELETE,
|
||||
{
|
||||
"archive_ids": [],
|
||||
"delete_ids": [],
|
||||
"expected_epoch": epoch,
|
||||
"backup": "on",
|
||||
},
|
||||
),
|
||||
(
|
||||
DELETE,
|
||||
{
|
||||
"archive_ids": [plain],
|
||||
"delete_ids": [plain],
|
||||
"expected_epoch": epoch,
|
||||
"backup": "on",
|
||||
},
|
||||
),
|
||||
(
|
||||
DELETE,
|
||||
{
|
||||
"archive_ids": [],
|
||||
"delete_ids": [plain],
|
||||
"expected_epoch": epoch,
|
||||
"backup": "maybe",
|
||||
},
|
||||
),
|
||||
(
|
||||
DELETE,
|
||||
{
|
||||
"archive_ids": list(range(1, 600)),
|
||||
"delete_ids": list(range(600, 1100)),
|
||||
"expected_epoch": epoch,
|
||||
"backup": "off",
|
||||
},
|
||||
),
|
||||
):
|
||||
with self.subTest(url=url, body=body):
|
||||
status, answer = await self.post(url, body)
|
||||
self.assertEqual(status, 400, answer)
|
||||
self.assertFalse(answer["success"])
|
||||
for url in (PREVIEW, DELETE, RESTORE):
|
||||
response = await self.client.request(
|
||||
"POST", url, data="[1]", headers={"Content-Type": "application/json"}
|
||||
)
|
||||
self.assertEqual(response.status, 400)
|
||||
self.assertIn(plain, self.ids())
|
||||
|
||||
async def test_the_legacy_bulk_delete_is_unchanged(self):
|
||||
plain = self.save("Plain")
|
||||
positive = self.paired("Cat")
|
||||
response = await self.client.request(
|
||||
"POST", "/prompt_manager/bulk/delete", json={"prompt_ids": [positive]}
|
||||
)
|
||||
self.assertEqual(response.status, 409)
|
||||
response = await self.client.request(
|
||||
"POST", "/prompt_manager/bulk/delete", json={"prompt_ids": [plain]}
|
||||
)
|
||||
self.assertEqual((await response.json())["deleted_count"], 1)
|
||||
self.assertEqual(self.archived(), [])
|
||||
|
||||
async def test_shelf_parameter_and_cursor_scope(self):
|
||||
kept = [self.paired(f"Kept {i}") for i in range(3)]
|
||||
gone = [self.paired(f"Gone {i}") for i in range(3)]
|
||||
await self.confirm(await self.review(gone))
|
||||
response = await self.client.request(
|
||||
"GET", "/prompt_manager/library?limit=2&shelf=active"
|
||||
)
|
||||
page = await response.json()
|
||||
self.assertEqual(page["archived_total"], 3)
|
||||
self.assertTrue(set(item["id"] for item in page["items"]) <= set(kept))
|
||||
response = await self.client.request(
|
||||
"GET",
|
||||
"/prompt_manager/library?limit=2&shelf=archived"
|
||||
f"&cursor={page['next_cursor']}",
|
||||
)
|
||||
self.assertEqual(response.status, 400)
|
||||
response = await self.client.request(
|
||||
"GET", "/prompt_manager/library?shelf=archived"
|
||||
)
|
||||
archived = await response.json()
|
||||
self.assertEqual(sorted(item["id"] for item in archived["items"]), gone)
|
||||
self.assertTrue(all(item["archived"] for item in archived["items"]))
|
||||
response = await self.client.request("GET", "/prompt_manager/library?shelf=all")
|
||||
self.assertEqual(response.status, 400)
|
||||
|
||||
|
||||
class TestIngestionAfterArchive(LibraryDeleteRouteCase):
|
||||
def scan(self, name, positive, negative="blur"):
|
||||
meta = {"prompt": json.dumps(graph(positive, negative))}
|
||||
path = self.output_dir / name
|
||||
path.write_bytes(b"png")
|
||||
return self.api._ingest_scanned_file(path, meta)
|
||||
|
||||
async def test_a_scan_after_archive_creates_no_duplicate_and_keeps_it_archived(
|
||||
self,
|
||||
):
|
||||
self.assertEqual(self.scan("first.png", "Sunset"), "added")
|
||||
positive = self.api.db.get_prompt_by_hash(generate_prompt_hash("Sunset"))["id"]
|
||||
await self.confirm(await self.review([positive]))
|
||||
before = self.ids()
|
||||
self.scan("second.png", "Sunset")
|
||||
self.scan("third.png", " sunset ", "other negative")
|
||||
self.assertEqual(
|
||||
[i for i in self.ids() if i not in before],
|
||||
[
|
||||
self.api.db.get_prompt_by_hash(generate_prompt_hash("other negative"))[
|
||||
"id"
|
||||
]
|
||||
],
|
||||
)
|
||||
self.assertEqual(self.archived(), [positive])
|
||||
linked = self.rows(
|
||||
"SELECT COUNT(*) FROM generated_images WHERE prompt_id=?", (positive,)
|
||||
)
|
||||
self.assertEqual(linked, [(3,)])
|
||||
response = await self.client.request("GET", "/prompt_manager/library")
|
||||
self.assertNotIn(
|
||||
positive, [item["id"] for item in (await response.json())["items"]]
|
||||
)
|
||||
|
||||
async def test_a_scan_after_delete_creates_the_prompt_again(self):
|
||||
plain = self.save("Lonely sunset")
|
||||
await self.confirm(await self.review([plain]))
|
||||
self.assertEqual(self.scan("again.png", "Lonely sunset"), "added")
|
||||
again = self.api.db.get_prompt_by_hash(generate_prompt_hash("Lonely sunset"))
|
||||
self.assertIsNotNone(again)
|
||||
self.assertNotEqual(again["id"], plain)
|
||||
self.assertEqual(self.archived(), [])
|
||||
@@ -0,0 +1,196 @@
|
||||
"""#443: the Library parent archive table, its migration and its validation."""
|
||||
|
||||
import shutil
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from database.models import PromptModel
|
||||
from database.operations import PromptDatabase
|
||||
from database.prompt_archive import (
|
||||
TABLE,
|
||||
migrate_prompt_archive,
|
||||
prompt_archive_schema_error,
|
||||
)
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
|
||||
def _tables(conn):
|
||||
return {
|
||||
row[0]
|
||||
for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
}
|
||||
|
||||
|
||||
class TestPromptArchiveSchema(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.path = str(Path(self.temp.name) / "archive.db")
|
||||
self.db = PromptDatabase(self.path)
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close_all()
|
||||
self.temp.cleanup()
|
||||
|
||||
def conn(self):
|
||||
return self.db.model.get_connection()
|
||||
|
||||
def save(self, text):
|
||||
return self.db.save_prompt(text, prompt_hash=generate_prompt_hash(text))
|
||||
|
||||
def test_startup_creates_the_table_and_it_validates(self):
|
||||
self.assertIn(TABLE, _tables(self.conn()))
|
||||
self.assertIsNone(prompt_archive_schema_error(self.conn(), allow_missing=False))
|
||||
|
||||
def test_migration_is_idempotent(self):
|
||||
conn = self.conn()
|
||||
migrate_prompt_archive(conn)
|
||||
migrate_prompt_archive(conn)
|
||||
self.assertIsNone(prompt_archive_schema_error(conn, allow_missing=False))
|
||||
|
||||
def test_a_missing_table_is_accepted_only_when_allowed(self):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
self.addCleanup(conn.close)
|
||||
self.assertIsNone(prompt_archive_schema_error(conn))
|
||||
self.assertEqual(
|
||||
prompt_archive_schema_error(conn, allow_missing=False),
|
||||
"Missing prompt archive schema",
|
||||
)
|
||||
|
||||
def test_deleting_a_prompt_drops_its_archive_row(self):
|
||||
pid = self.save("Archived then deleted")
|
||||
conn = self.conn()
|
||||
conn.execute(
|
||||
f"INSERT INTO {TABLE}(prompt_id, archived_at) VALUES (?, ?)",
|
||||
(pid, "2026-10-28T00:00:00+00:00"),
|
||||
)
|
||||
conn.commit()
|
||||
self.db.delete_prompt(pid)
|
||||
self.assertEqual(conn.execute(f"SELECT COUNT(*) FROM {TABLE}").fetchone()[0], 0)
|
||||
|
||||
def _broken(self, *statements):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
self.addCleanup(conn.close)
|
||||
conn.execute("CREATE TABLE prompts (id INTEGER PRIMARY KEY)")
|
||||
for statement in statements:
|
||||
conn.execute(statement)
|
||||
return conn
|
||||
|
||||
def test_unsupported_structures_are_refused(self):
|
||||
good = (
|
||||
f"CREATE TABLE {TABLE} (prompt_id INTEGER PRIMARY KEY, "
|
||||
"archived_at TEXT NOT NULL, "
|
||||
"FOREIGN KEY(prompt_id) REFERENCES prompts(id) ON DELETE CASCADE)"
|
||||
)
|
||||
cases = {
|
||||
"other definition": (
|
||||
f"CREATE TABLE {TABLE} (prompt_id INTEGER PRIMARY KEY, "
|
||||
"archived_at TEXT)",
|
||||
),
|
||||
"no cascade": (
|
||||
f"CREATE TABLE {TABLE} (prompt_id INTEGER PRIMARY KEY, "
|
||||
"archived_at TEXT NOT NULL, "
|
||||
"FOREIGN KEY(prompt_id) REFERENCES prompts(id))",
|
||||
),
|
||||
"extra index": (good, f"CREATE INDEX x ON {TABLE}(archived_at)"),
|
||||
"trigger": (
|
||||
good,
|
||||
f"CREATE TRIGGER t AFTER INSERT ON {TABLE} BEGIN SELECT 1; END",
|
||||
),
|
||||
"view name": (f"CREATE VIEW {TABLE} AS SELECT 1",),
|
||||
}
|
||||
for name, statements in cases.items():
|
||||
with self.subTest(name):
|
||||
self.assertIsNotNone(
|
||||
prompt_archive_schema_error(self._broken(*statements))
|
||||
)
|
||||
|
||||
def test_invalid_values_and_dangling_rows_are_refused(self):
|
||||
good = (
|
||||
f"CREATE TABLE {TABLE} (prompt_id INTEGER PRIMARY KEY, "
|
||||
"archived_at TEXT NOT NULL, "
|
||||
"FOREIGN KEY(prompt_id) REFERENCES prompts(id) ON DELETE CASCADE)"
|
||||
)
|
||||
for name, row in {
|
||||
"number": "(1, 5)",
|
||||
"blob": "(1, x'00')",
|
||||
"long": f"(1, '{'x' * 65}')",
|
||||
"dangling": "(99, '2026-10-28T00:00:00+00:00')",
|
||||
}.items():
|
||||
with self.subTest(name):
|
||||
conn = self._broken(good, "INSERT INTO prompts(id) VALUES (1)")
|
||||
conn.execute(f"INSERT INTO {TABLE} VALUES {row}")
|
||||
self.assertIsNotNone(prompt_archive_schema_error(conn))
|
||||
|
||||
def test_a_refused_structure_stops_the_migration_without_changes(self):
|
||||
conn = self._broken(
|
||||
f"CREATE TABLE {TABLE} (prompt_id INTEGER PRIMARY KEY, archived_at TEXT)"
|
||||
)
|
||||
with self.assertRaises(sqlite3.DatabaseError):
|
||||
migrate_prompt_archive(conn)
|
||||
sql = conn.execute(
|
||||
"SELECT sql FROM sqlite_master WHERE name=?", (TABLE,)
|
||||
).fetchone()[0]
|
||||
self.assertNotIn("NOT NULL", sql)
|
||||
|
||||
|
||||
class TestPromptArchiveBackups(unittest.TestCase):
|
||||
"""Restore validation accepts an older backup and refuses a damaged table."""
|
||||
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp.name)
|
||||
self.db = PromptDatabase(str(self.root / "live.db"))
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close_all()
|
||||
self.temp.cleanup()
|
||||
|
||||
def copy(self, name):
|
||||
target = self.root / name
|
||||
self.db.model.backup_database(str(target))
|
||||
return str(target)
|
||||
|
||||
def test_a_backup_without_the_table_verifies_and_migrates(self):
|
||||
path = self.copy("old.db")
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute(f"DROP TABLE {TABLE}")
|
||||
conn.close()
|
||||
ok, reason = self.db.model.verify_database_file(path)
|
||||
self.assertTrue(ok, reason)
|
||||
model = PromptModel(path)
|
||||
try:
|
||||
self.assertIn(TABLE, _tables(model.get_connection()))
|
||||
finally:
|
||||
model.close_all()
|
||||
|
||||
def test_a_backup_with_a_damaged_table_is_refused(self):
|
||||
path = self.copy("bad.db")
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute(f"DROP TABLE {TABLE}")
|
||||
conn.execute(f"CREATE TABLE {TABLE} (prompt_id INTEGER, archived_at TEXT)")
|
||||
conn.close()
|
||||
ok, reason = self.db.model.verify_database_file(path)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("archive", reason.lower())
|
||||
|
||||
def test_an_archived_prompt_survives_a_backup_copy(self):
|
||||
pid = self.db.save_prompt("Kept", prompt_hash=generate_prompt_hash("Kept"))
|
||||
conn = self.db.model.get_connection()
|
||||
conn.execute(
|
||||
f"INSERT INTO {TABLE}(prompt_id, archived_at) VALUES (?, ?)",
|
||||
(pid, "2026-10-28T00:00:00+00:00"),
|
||||
)
|
||||
conn.commit()
|
||||
path = self.copy("copy.db")
|
||||
shutil.copy(path, path + ".2")
|
||||
with sqlite3.connect(path + ".2") as other:
|
||||
self.assertEqual(
|
||||
other.execute(f"SELECT prompt_id FROM {TABLE}").fetchall(), [(pid,)]
|
||||
)
|
||||
other.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user