* feat(vocabulary): add workspace vocabulary state * feat(vocabulary): render the native Tags & categories screen * feat(vocabulary): add guarded vocabulary dialogs * fix(vocabulary): recover unconfirmed deletes and list every category in Manage * refactor(vocabulary): use the shared plural helper * test(vocabulary): compare the native screen with the approved concept * fix(vocabulary): wrap long tag names so row actions stay visible on narrow screens * docs(vocabulary): document the native vocabulary screen and gates Also aligns the shell link focus-ring offset with the concept (3px) and makes the chrome comparison poll the document title. * fix(vocabulary): refresh lists when a write lands after its dialog closes A rename, delete, create or category apply that commits after its dialog was disposed (Chromium force-closing the modal on a repeated Escape, or Back while the write is in flight) returned before onInvalidate, so the lists were never re-read and a cached Library shelf kept showing the old names. Like the recipe editor, a disposed command now still reports any outcome that may have changed storage (receipt, conflict, restore, unconfirmed) without rendering; only a failed write stays silent. * fix(vocabulary): give the Manage categories datalist its own id The proposed-name datalist reused the recipe editor's `categories` id. It was safe only because routes dispose each other's dialogs; it is now `vocabulary-category-options`. * docs(vocabulary): correct vocabulary testing notes Name the Tag column and the Create tag heading action, say that a failed Load more moves focus to Retry, place bulk actions on the admin page, describe late writes after a closed dialog, Browse's exact filter and the full deviation list, and use a test port that is not the live concept server's. * test(vocabulary): raise the vocabulary dialog branch floor coverage:update after the late-write and datalist fixes: workspace/vocabulary-dialog.mjs branches 87 -> 88. No floor is lowered. * fix(vocabulary): keep 390px row actions visible regardless of font metrics The one-column Tags table reserved a fixed 6em for the tag name. With CI's stock DejaVu Sans fallback (about 9% wider than the pinned image's) Tag + Prompts + Images + stacked actions needed 351px of a 336px wrap, so Browse and Rename / merge were pushed out of view on every row. At 700px and below the Tag column now has no fixed minimum and the actions ask for no more than their stacked width, so all remaining space goes to the tag name: short names stay on one line wherever the concept's own table fits, and a wider font wraps the name instead of clipping an action. A new test widens the metrics with letter spacing and fails without this change in every engine. * ci: run browser jobs in the pinned Playwright image Concept comparisons measure geometry, which follows the fonts a browser falls back to. The concept names Inter, which neither environment ships: the pinned image falls back to WenQuanYi Zen Hei, a stock ubuntu-latest runner to DejaVu Sans. Under DejaVu the concept's own Tags table needs more than a 390px viewport (its page scrolls sideways and, under touch emulation, the widened layout viewport resizes its dialogs), so the 390px comparisons failed in CI while passing locally. Both browser jobs now run in mcr.microsoft.com/playwright:v1.64.1-noble, the image local runs use, instead of installing browsers on the runner. A tooling test keeps the tag equal to @playwright/test in the lockfile. * fix(vocabulary): ignore a category preview edited while it is pending Editing the Category or proposed name while a slow Preview was in flight was a no-op in the saving phase, so the late response still became an applicable preview: the form showed the new values while Apply would submit the old source and target. An edit during a pending preview now marks it stale. Its late result returns the dialog to idle (Preview again) instead of showing an impact or offering Apply; a plain failure of a stale preview is not shown either, while a restore or conflict still is, since it describes storage. The inputs stay editable during the request, as in the recipe editor, which disables only its actions while saving. * ci: use the image's Python and Node in containerized browser jobs Inside mcr.microsoft.com/playwright:v1.64.1-noble, actions/setup-python failed before any test ran: `spawn /__t/Python/3.12.15/x64/bin/pip ENOENT`. The toolcache builds come from the runner, and their entry points name host interpreter paths that do not exist in the container. The jobs now use the image's Python 3.12 and Node 24. The image has no ensurepip and the job runs as uid 1001 without root, so a checksum-pinned pip wheel bootstraps a user-space venv for requirements-dev.txt; a step checks that the image Node is 24. The tooling test forbids setup-python and setup-node in these jobs and requires the pinned bootstrap.
794 lines
59 KiB
JavaScript
794 lines
59 KiB
JavaScript
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||
import {writeFile} from 'node:fs/promises';
|
||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||
import {pageInventoryOf,inventoryOf,diffInventory,mapVocabularyConcept,mapVocabularyDialog} from './vocabulary-canonical-comparison.mjs';
|
||
|
||
const route='/prompt_manager/workspace#/tags';
|
||
// Prompts per concept tag (app.js:71-165): first appearance order, Library Browse counts.
|
||
const CONCEPT_COUNTS=[['cinematic',2],['blue_hour',1],['architecture',1],['minimal',2],['warm_light',1],['editorial',1],['forest',1],['mist',1],
|
||
['peaceful',2],['neon',1],['night',1],['space',1],['concept_art',1],['coast',1],['soft_light',1]];
|
||
const CATEGORIES=[['Landscapes',3],['Architecture',1],['Environments',1],['Illustration',1]];
|
||
const main=page=>page.locator('#workspace-main');
|
||
const surfaces=page=>main(page).locator('.two-col > section.surface');
|
||
const table=page=>main(page).getByRole('table',{name:'Reusable tags',exact:true});
|
||
const rows=page=>table(page).locator('tbody tr');
|
||
const tagRow=(page,name)=>rows(page).filter({has:page.locator('td:first-child').getByText(name,{exact:true})});
|
||
const categoryRow=(page,name)=>surfaces(page).nth(1).locator('.operation').filter({has:page.getByRole('heading',{level:3,name,exact:true})});
|
||
const loadMore=page=>main(page).getByRole('button',{name:'Load more tags',exact:true});
|
||
|
||
async function seed(request,data={}) {
|
||
const response=await request.post('/__test__/vocabulary/reset',{data});await expect(response).toBeOK();return response.json();
|
||
}
|
||
async function expectLibrary(page,{tags='',category='',count}) {
|
||
await expect(page).toHaveURL(/#\/library$/);
|
||
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Prompt library');
|
||
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();
|
||
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('');
|
||
await expect(page.getByRole('textbox',{name:'Tags',exact:true})).toHaveValue(tags);
|
||
const select=page.getByRole('combobox',{name:'Category',exact:true});
|
||
await expect(select).toHaveValue(category);
|
||
if(!category) await expect(select.locator('option:checked')).toHaveText('All categories');
|
||
await expect(page.locator('.library-caption')).toHaveText(`${count} of ${count} positive prompts · Images are grouped by positive prompt`);
|
||
await expect(page.locator('article[data-prompt-id]')).toHaveCount(count);
|
||
}
|
||
|
||
let manifest;
|
||
test.beforeEach(async({page,request,browserName})=>{
|
||
await startCoverage(page,browserName);manifest=await seed(request);
|
||
page.vocabularyErrors=[];page.on('pageerror',error=>page.vocabularyErrors.push(error.message));
|
||
});
|
||
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.vocabularyErrors).toEqual([]);});
|
||
|
||
test('tags route renders the concept composition without a legacy handoff',async({page})=>{
|
||
await page.goto(route);
|
||
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Tags & categories');
|
||
await expect(main(page).locator('.workspace-heading-copy > p')).toHaveText('Organize once. Reuse everywhere.');
|
||
const actions=main(page).locator('.workspace-heading .heading-actions button');
|
||
await expect(actions).toHaveText(['+ Create tag']);
|
||
await expect(actions.first()).not.toHaveClass(/ca-primary/);
|
||
await expect(main(page).getByRole('button',{name:'Auto-tag images'})).toHaveCount(0);
|
||
await expect(surfaces(page)).toHaveCount(2);
|
||
await expect(surfaces(page).locator('h2')).toHaveText(['Tag vocabulary','Categories']);
|
||
await expect(surfaces(page).locator(':scope > p:visible')).toHaveText(['Category/tag autocomplete uses this same vocabulary across nodes and dialogs.',
|
||
'Categories provide a broad home. Tags describe content; proposed collections group projects without changing either.']);
|
||
await expect(table(page).locator('caption')).toHaveText('Reusable tags');
|
||
await expect(table(page).locator('thead th')).toHaveText(['Tag','Prompts','Images','Actions']);
|
||
expect(await table(page).locator('thead th').evaluateAll(cells=>cells.map(cell=>cell.getAttribute('scope')))).toEqual(['col','col','col','col']);
|
||
const expected=[...CONCEPT_COUNTS,[manifest.pair_only_tag,1],[manifest.archived_only_tag,0],[manifest.negative_only_tag,0],[manifest.unused_tag,0]];
|
||
await expect(rows(page)).toHaveCount(expected.length);
|
||
expect(manifest.tags).toEqual(expected.map(([name])=>name));
|
||
await expect(rows(page).locator('td:nth-child(1)')).toHaveText(expected.map(([name])=>name));
|
||
await expect(rows(page).locator('td:nth-child(2)')).toHaveText(expected.map(([,count])=>String(count)));
|
||
const images=await rows(page).locator('td:nth-child(3)').evaluateAll(cells=>cells.map(cell=>[cell.querySelector('[aria-hidden="true"]')?.textContent,cell.querySelector('.ca-visually-hidden')?.textContent]));
|
||
expect(images).toEqual(expected.map(()=>['—','Not available yet']));
|
||
await expect(rows(page).first().locator('td:nth-child(3)')).toHaveAccessibleName('Not available yet');
|
||
const labels=await rows(page).evaluateAll(nodes=>nodes.map(row=>[...row.querySelectorAll('td:nth-child(4) .row-actions button')].map(button=>button.textContent)));
|
||
expect(labels).toEqual(expected.map(()=>['Browse','Rename / merge']));
|
||
const operations=surfaces(page).nth(1).locator('.operation');
|
||
await expect(operations.locator('h3')).toHaveText([...CATEGORIES.map(([name])=>name),'Manage category']);
|
||
await expect(operations.locator('p')).toHaveText([...CATEGORIES.map(([,count])=>`${count} positive prompt${count===1?'':'s'}`),'Rename or consolidate a category with a preview of affected recipes.']);
|
||
await expect(operations.locator('.row-actions button')).toHaveText(['Browse','Browse','Browse','Browse','Review']);
|
||
await expect(main(page).locator('.handoff')).toHaveCount(0);
|
||
await expect(main(page).locator('a[href*="admin"]')).toHaveCount(0);
|
||
await expect(main(page).getByRole('button',{name:/^Retry/})).toHaveCount(0);
|
||
await expect(loadMore(page)).toBeHidden();
|
||
});
|
||
|
||
test('prompt counts equal Library Browse results',async({page})=>{
|
||
for(const name of ['cinematic',manifest.pair_only_tag,manifest.archived_only_tag]) {
|
||
await page.goto(route);
|
||
const row=tagRow(page,name);await expect(row).toHaveCount(1);
|
||
const count=Number(await row.locator('td:nth-child(2)').textContent());
|
||
await row.getByRole('button',{name:'Browse',exact:true}).click();
|
||
await expectLibrary(page,{tags:name,count});
|
||
}
|
||
});
|
||
|
||
test('category Browse sets only the category constraint',async({page})=>{
|
||
await page.goto('/prompt_manager/workspace#/library');
|
||
const search=page.getByRole('searchbox',{name:'Search prompts',exact:true});
|
||
await search.fill('observatory');await expect(page.locator('article[data-prompt-id]')).toHaveCount(1);
|
||
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Tags & categories',exact:true}).click();
|
||
const row=categoryRow(page,'Landscapes');await expect(row.locator('p')).toHaveText('3 positive prompts');
|
||
await row.getByRole('button',{name:'Browse',exact:true}).click();
|
||
await expectLibrary(page,{category:'Landscapes',count:3});
|
||
});
|
||
|
||
test('long vocabularies append and Load more is keyboard reachable',async({page,request})=>{
|
||
manifest=await seed(request,{extra_tags:211});expect(manifest.tags).toHaveLength(230);
|
||
await page.goto(route);
|
||
await expect(rows(page)).toHaveCount(100);
|
||
await expect(loadMore(page)).toBeVisible();
|
||
await rows(page).last().getByRole('button',{name:'Rename / merge',exact:true}).evaluate(button=>button.focus({preventScroll:true}));
|
||
await page.keyboard.press('Tab');
|
||
await expect(loadMore(page)).toBeFocused();
|
||
// Focus scrolled the end of the list into view, which appends the next page.
|
||
await expect(rows(page)).toHaveCount(200);
|
||
await expect(loadMore(page)).toBeFocused();
|
||
await page.keyboard.press('Enter');
|
||
await expect(rows(page)).toHaveCount(230);
|
||
await expect(loadMore(page)).toHaveCount(0);
|
||
await expect(rows(page).nth(200).getByRole('button',{name:'Browse',exact:true})).toBeFocused();
|
||
await expect(rows(page).locator('td:nth-child(1)')).toHaveText(manifest.tags);
|
||
// Reload at the top so restored scroll does not append before the check.
|
||
await page.evaluate(()=>window.scrollTo(0,0));
|
||
await page.reload();
|
||
await expect(rows(page)).toHaveCount(100);
|
||
const scroll=top=>page.evaluate(y=>new Promise(resolve=>{window.scrollTo(0,y ?? document.documentElement.scrollHeight);requestAnimationFrame(()=>requestAnimationFrame(resolve));}),top);
|
||
for(const count of [200,230]) {
|
||
// Each new arrival of the end of the list appends one page.
|
||
await scroll(0);await scroll(null);
|
||
await expect(rows(page)).toHaveCount(count);
|
||
}
|
||
await expect(loadMore(page)).toHaveCount(0);
|
||
await expect(rows(page).locator('td:nth-child(1)')).toHaveText(manifest.tags);
|
||
});
|
||
|
||
test('read failure shows inline error with Retry and no zero counts',async({page})=>{
|
||
let failures=0;
|
||
await page.route('**/prompt_manager/vocabulary/tags?*',route=>{
|
||
if(failures++) return route.continue();
|
||
return route.fulfill({status:500,json:{success:false,error:'Database is unavailable'}});
|
||
});
|
||
await page.goto(route);
|
||
const alert=surfaces(page).first().getByRole('alert');
|
||
await expect(alert).toHaveText('Tags could not be loaded. Nothing was changed.');
|
||
await expect(table(page)).toHaveCount(0);
|
||
await expect(surfaces(page).first().locator('td')).toHaveCount(0);
|
||
await expect(surfaces(page).first().locator('.empty')).toBeHidden();
|
||
await expect(loadMore(page)).toHaveCount(0);
|
||
await expect(categoryRow(page,'Landscapes').locator('p')).toHaveText('3 positive prompts');
|
||
const retry=surfaces(page).first().getByRole('button',{name:'Retry',exact:true});
|
||
await retry.click();
|
||
await expect(rows(page)).toHaveCount(manifest.tags.length);
|
||
await expect(alert).toHaveCount(0);await expect(retry).toHaveCount(0);
|
||
});
|
||
|
||
test('breadcrumb and document title are Tags',async({page})=>{
|
||
await page.goto(route);
|
||
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Tags');
|
||
await expect(page).toHaveTitle('Tags · Prompt Manager');
|
||
await expect(page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Tags & categories',exact:true})).toHaveAttribute('aria-current','page');
|
||
await page.reload();
|
||
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Tags');
|
||
await page.goto('/prompt_manager/workspace#/library');
|
||
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Prompt library');
|
||
await expect(page).toHaveTitle('Prompt library · Prompt Manager');
|
||
});
|
||
|
||
test('route disposal aborts requests and removes observers',async({page})=>{
|
||
await page.addInitScript(()=>{
|
||
window.__vocabularySignals=[];window.__vocabularyObservers=[];
|
||
const fetch=window.fetch;
|
||
window.fetch=(input,init={})=>{if(String(input).includes('/vocabulary/') && init.signal) window.__vocabularySignals.push(init.signal);return fetch(input,init);};
|
||
const Observer=window.IntersectionObserver;
|
||
window.IntersectionObserver=class extends Observer {
|
||
constructor(...args) {super(...args);this.record={targets:[],disconnected:false};window.__vocabularyObservers.push(this.record);}
|
||
observe(target) {this.record.targets.push(target.className);return super.observe(target);}
|
||
disconnect() {this.record.disconnected=true;return super.disconnect();}
|
||
};
|
||
});
|
||
const held=[];
|
||
await page.route('**/prompt_manager/vocabulary/**',route=>{held.push(route);});
|
||
await page.goto(route);
|
||
await expect.poll(()=>page.evaluate(()=>window.__vocabularySignals.length)).toBe(2);
|
||
expect(await page.evaluate(()=>window.__vocabularyObservers.filter(item=>item.targets.some(name=>name.includes('vocabulary-continuation'))).length)).toBe(2);
|
||
await expect(main(page).getByText('Loading…',{exact:true})).toHaveCount(2);
|
||
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Prompt library',exact:true}).click();
|
||
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Prompt library');
|
||
expect(await page.evaluate(()=>window.__vocabularySignals.map(signal=>signal.aborted))).toEqual([true,true]);
|
||
expect(await page.evaluate(()=>window.__vocabularyObservers.filter(item=>item.targets.some(name=>name.includes('vocabulary-continuation'))).map(item=>item.disconnected))).toEqual([true,true]);
|
||
await expect(main(page).locator('.two-col')).toHaveCount(0);
|
||
expect(await page.evaluate(()=>document.getElementById('workspace-main').classList.contains('vocabulary-main'))).toBe(false);
|
||
await page.unroute('**/prompt_manager/vocabulary/**');
|
||
for(const item of held) await item.abort().catch(()=>{});
|
||
});
|
||
|
||
test('390px layout stacks without horizontal page scroll; table scrolls locally',async({page,request})=>{
|
||
manifest=await seed(request,{extra_tags:3});
|
||
await page.setViewportSize({width:390,height:844});
|
||
await page.goto(route);
|
||
await expect(rows(page)).toHaveCount(manifest.tags.length);
|
||
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
|
||
const layout=await main(page).evaluate(node=>{
|
||
const grid=node.querySelector('.two-col'),[left,right]=grid.querySelectorAll(':scope > .surface'),wrap=node.querySelector('.table-wrap');
|
||
const heading=node.querySelector('.workspace-heading'),operation=node.querySelector('.operation');
|
||
return {tracks:getComputedStyle(grid).gridTemplateColumns.split(' ').length,stacked:right.getBoundingClientRect().top>=left.getBoundingClientRect().bottom,
|
||
overflow:getComputedStyle(wrap).overflowX,heading:getComputedStyle(heading).flexDirection,operation:getComputedStyle(operation).flexDirection,
|
||
actions:node.querySelector('.workspace-heading .heading-actions').getBoundingClientRect().width===heading.getBoundingClientRect().width};
|
||
});
|
||
expect(layout).toEqual({tracks:1,stacked:true,overflow:'auto',heading:'column',operation:'column',actions:true});
|
||
});
|
||
|
||
async function expectEveryRowActionVisible(page,request,css) {
|
||
const name='an_extraordinarily_long_descriptive_vocabulary_x';expect(name.length).toBeGreaterThanOrEqual(40);expect(name.length).toBeLessThanOrEqual(50);
|
||
await expect(await request.post('/prompt_manager/vocabulary/tags',{data:{expected_epoch:manifest.epoch,name}})).toBeOK();
|
||
await page.setViewportSize({width:390,height:844});
|
||
await page.goto(route);await expect(rows(page)).toHaveCount(manifest.tags.length+1);await expect(tagRow(page,name)).toHaveCount(1);
|
||
if(css)await page.addStyleTag({content:css});
|
||
const report=await table(page).evaluate(node=>{
|
||
const wrap=node.closest('.table-wrap'),box=wrap.getBoundingClientRect();
|
||
const clipped=[...node.querySelectorAll('tbody tr')].flatMap(row=>[...row.querySelectorAll('.row-actions button')]
|
||
.filter(item=>{const rect=item.getBoundingClientRect();return rect.left<box.left-0.5||rect.right>box.right+0.5;})
|
||
.map(item=>`${row.cells[0].textContent}: ${item.textContent}`));
|
||
return {overflow:wrap.scrollWidth<=wrap.clientWidth+1,clipped};
|
||
});
|
||
expect(report).toEqual({overflow:true,clipped:[]});
|
||
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
|
||
}
|
||
test('long tag names wrap at 390px so every row action stays fully visible',async({page,request})=>expectEveryRowActionVisible(page,request));
|
||
// The fit must not depend on font metrics: CI's stock DejaVu Sans fallback is about 9% wider than the pinned image's
|
||
// fallback, which pushed Rename / merge out of view. Wider letter spacing stands in for any wider fallback font.
|
||
test('row actions stay fully visible at 390px with wider font metrics',async({page,request})=>
|
||
expectEveryRowActionVisible(page,request,'.vocabulary-main table{letter-spacing:1px}'));
|
||
|
||
// Dialogs: the editor's modal shell (concept simpleInput, app.js:1138-1143) with truthful outcomes.
|
||
const dialog=page=>page.locator('dialog.vocabulary-dialog');
|
||
const button=(scope,name)=>scope.getByRole('button',{name,exact:true});
|
||
const field=(scope,label)=>scope.getByLabel(label,{exact:true});
|
||
const note=scope=>scope.locator('.warning-note');
|
||
const toast=page=>page.locator('#workspace-toast');
|
||
const actionNames=scope=>scope.locator('.dialog-actions button').evaluateAll(nodes=>nodes.map(node=>node.textContent.trim()));
|
||
const snapshot=async request=>{const response=await request.get('/__test__/vocabulary/snapshot');await expect(response).toBeOK();return response.json();};
|
||
const backup=async request=>{const response=await request.get('/__test__/vocabulary/backup');await expect(response).toBeOK();return response.body();};
|
||
const restore=async(request,buffer)=>expect(await request.post('/prompt_manager/restore',{multipart:{database_file:{name:'vocabulary.db',mimeType:'application/octet-stream',buffer}}})).toBeOK();
|
||
const tagNames=rows=>rows.tags.map(([,name])=>name);
|
||
function trackWrites(page) {
|
||
const writes=[];page.on('request',item=>{if(item.method()!=='GET')writes.push({method:item.method(),url:new URL(item.url()).pathname,body:item.postDataJSON()});});return writes;
|
||
}
|
||
async function openPage(page) {await page.goto(route);await expect(rows(page)).toHaveCount(manifest.tags.length);}
|
||
async function openDialog(page,trigger,title) {
|
||
await trigger.click();const box=dialog(page);await expect(box).toBeVisible();
|
||
expect(await box.evaluate(node=>node.matches(':modal'))).toBe(true);
|
||
await expect(box.getByRole('heading',{level:2})).toHaveText(title);
|
||
await expect(button(box,'Close dialog')).toBeFocused();
|
||
return box;
|
||
}
|
||
const openRename=(page,name)=>openDialog(page,tagRow(page,name).getByRole('button',{name:'Rename / merge',exact:true}),'Rename or merge tag');
|
||
const openManage=page=>openDialog(page,categoryRow(page,'Manage category').getByRole('button',{name:'Review',exact:true}),'Manage categories');
|
||
|
||
test('create tag adds an unused tag that every editor offers',async({page,request})=>{
|
||
const writes=trackWrites(page);await openPage(page);const before=await snapshot(request);
|
||
const box=await openDialog(page,main(page).getByRole('button',{name:'+ Create tag',exact:true}),'Create tag');
|
||
await expect(box).toHaveAttribute('aria-labelledby',await box.getByRole('heading',{level:2}).getAttribute('id'));
|
||
await expect(box.locator('.dialog-header > button')).toHaveText('×');
|
||
await expect(box.locator('.field label')).toHaveText(['Tag name']);
|
||
const input=field(box,'Tag name');await expect(input).toHaveValue('');await expect(input).toHaveAttribute('required','');
|
||
expect(await actionNames(box)).toEqual(['Cancel','Apply']);
|
||
await expect(button(box,'Apply')).toHaveClass(/ca-primary/);await expect(button(box,'Cancel')).not.toHaveClass(/ca-primary/);
|
||
await button(box,'Apply').click();
|
||
await expect(box).toBeVisible();expect(await input.evaluate(node=>node.validity.valueMissing)).toBe(true);expect(writes).toEqual([]);
|
||
await input.fill('sunset');await button(box,'Apply').click();
|
||
await expect(box).toHaveCount(0);
|
||
await expect(toast(page)).toHaveText('Tag created.');
|
||
const row=tagRow(page,'sunset');await expect(row.locator('td:nth-child(2)')).toHaveText('0');
|
||
await expect(row.getByRole('button',{name:'Rename / merge',exact:true})).toBeFocused();
|
||
expect(writes).toEqual([{method:'POST',url:'/prompt_manager/vocabulary/tags',body:{expected_epoch:manifest.epoch,name:'sunset'}}]);
|
||
const after=await snapshot(request);
|
||
expect(tagNames(after)).toEqual([...manifest.tags,'sunset']);
|
||
expect({...after,tags:before.tags}).toEqual(before);
|
||
await page.goto('/prompt_manager/workspace#/library');
|
||
await page.getByRole('button',{name:'+ New prompt',exact:true}).click();
|
||
await expect(page.locator('dialog.library-editor datalist#tag-options option[value="sunset,"]')).toHaveCount(1);
|
||
await page.keyboard.press('Escape');
|
||
await page.goto(route);await expect(tagRow(page,'sunset')).toHaveCount(1);
|
||
});
|
||
|
||
test('rename and merge persist and collapse duplicate links',async({page,request})=>{
|
||
const writes=trackWrites(page);await openPage(page);
|
||
let box=await openRename(page,'blue_hour');
|
||
await expect(box.locator('.field label')).toHaveText(['New name; existing names merge']);
|
||
const input=field(box,'New name; existing names merge');
|
||
await expect(input).toHaveValue('blue_hour');await expect(input).toHaveAttribute('required','');
|
||
expect(await actionNames(box)).toEqual(['Delete tag…','Cancel','Apply']);
|
||
await expect(button(box,'Delete tag…')).toHaveClass(/danger/);await expect(button(box,'Apply')).toHaveClass(/ca-primary/);
|
||
await input.fill('golden_hour');await button(box,'Apply').click();
|
||
await expect(box).toHaveCount(0);await expect(toast(page)).toHaveText('Tag renamed.');
|
||
await expect(tagRow(page,'blue_hour')).toHaveCount(0);
|
||
await expect(tagRow(page,'golden_hour').locator('td:nth-child(2)')).toHaveText('1');
|
||
await expect(tagRow(page,'golden_hour').getByRole('button',{name:'Rename / merge',exact:true})).toBeFocused();
|
||
expect(writes.at(-1)).toMatchObject({method:'PATCH',body:{expected_epoch:manifest.epoch,name:'blue_hour',new_name:'golden_hour'}});
|
||
expect(writes.at(-1).url).toMatch(/^\/prompt_manager\/vocabulary\/tags\/\d+$/);
|
||
// Alpine observatory carries both: merging must leave one link, not two.
|
||
box=await openRename(page,'architecture');
|
||
await field(box,'New name; existing names merge').fill('cinematic');await button(box,'Apply').click();
|
||
await expect(box).toHaveCount(0);await expect(toast(page)).toHaveText('Tag merged without duplicate links.');
|
||
await expect(tagRow(page,'architecture')).toHaveCount(0);
|
||
const target=tagRow(page,'cinematic');await expect(target.locator('td:nth-child(2)')).toHaveText('2');
|
||
await expect(target.getByRole('button',{name:'Rename / merge',exact:true})).toBeFocused();
|
||
const after=await snapshot(request);
|
||
expect(tagNames(after)).not.toContain('architecture');
|
||
const cinematic=after.tags.find(([,name])=>name==='cinematic')[0];
|
||
expect(after.prompt_tags.filter(([,tag])=>tag===cinematic)).toHaveLength(2);
|
||
await page.reload();await expect(tagRow(page,'golden_hour')).toHaveCount(1);await expect(tagRow(page,'architecture')).toHaveCount(0);
|
||
await tagRow(page,'cinematic').getByRole('button',{name:'Browse',exact:true}).click();
|
||
await expectLibrary(page,{tags:'cinematic',count:2});
|
||
});
|
||
|
||
test('manage categories previews real impact before applying',async({page,request})=>{
|
||
const writes=trackWrites(page);await openPage(page);
|
||
let box=await openManage(page);
|
||
await expect(box.locator('.field label')).toHaveText(['Category','Proposed new category name']);
|
||
const source=field(box,'Category'),target=field(box,'Proposed new category name');
|
||
await expect(source.locator('option')).toHaveText(CATEGORIES.map(([name])=>name));
|
||
await expect(source).toHaveValue('Landscapes');
|
||
await expect(target).toHaveValue('');await expect(target).toHaveAttribute('required','');await expect(target).toHaveAttribute('list','vocabulary-category-options');
|
||
// Its own id: the recipe editor's `datalist#categories` is a different list.
|
||
await expect(page.locator('datalist#categories')).toHaveCount(0);
|
||
expect(await box.locator('datalist#vocabulary-category-options option').evaluateAll(nodes=>nodes.map(node=>node.value))).toEqual(CATEGORIES.map(([name])=>name));
|
||
expect(await actionNames(box)).toEqual(['Cancel','Preview']);
|
||
await expect(button(box,'Preview')).toHaveClass(/ca-primary/);
|
||
const impact=box.locator('p.small');await expect(impact).toBeHidden();
|
||
await target.fill('Scenery');await button(box,'Preview').click();
|
||
await expect(impact).toHaveText('“Landscapes” is used by 3 positive prompts. Apply renames it to “Scenery”.');
|
||
expect(await actionNames(box)).toEqual(['Cancel','Apply']);
|
||
expect(writes.map(write=>write.url)).toEqual(['/prompt_manager/vocabulary/categories/preview']);
|
||
await target.fill('Sceneries');
|
||
await expect(impact).toBeHidden();expect(await actionNames(box)).toEqual(['Cancel','Preview']);
|
||
await target.fill('Scenery');await button(box,'Preview').click();await expect(impact).toBeVisible();
|
||
await button(box,'Apply').click();
|
||
await expect(box).toHaveCount(0);await expect(toast(page)).toHaveText('Category renamed.');
|
||
await expect(categoryRow(page,'Landscapes')).toHaveCount(0);
|
||
await expect(categoryRow(page,'Scenery').locator('p')).toHaveText('3 positive prompts');
|
||
await expect(categoryRow(page,'Scenery').getByRole('button',{name:'Browse',exact:true})).toBeFocused();
|
||
expect(writes.at(-1)).toMatchObject({url:'/prompt_manager/vocabulary/categories/apply',body:{expected_epoch:manifest.epoch,source:'Landscapes',target:'Scenery',impact_token:expect.stringMatching(/^[0-9a-f]{64}$/)}});
|
||
|
||
box=await openManage(page);
|
||
await field(box,'Category').selectOption('Illustration');await field(box,'Proposed new category name').fill('Architecture');
|
||
await button(box,'Preview').click();
|
||
await expect(box.locator('p.small')).toHaveText('“Illustration” is used by 1 positive prompt. Apply consolidates it into the existing “Architecture”.');
|
||
await field(box,'Category').selectOption('Environments');await expect(box.locator('p.small')).toBeHidden();
|
||
await field(box,'Category').selectOption('Illustration');await button(box,'Preview').click();
|
||
await button(box,'Apply').click();
|
||
await expect(box).toHaveCount(0);await expect(toast(page)).toHaveText('Categories consolidated.');
|
||
await expect(categoryRow(page,'Illustration')).toHaveCount(0);
|
||
await expect(categoryRow(page,'Architecture').locator('p')).toHaveText('2 positive prompts');
|
||
await page.reload();await expect(categoryRow(page,'Scenery')).toHaveCount(1);await expect(categoryRow(page,'Illustration')).toHaveCount(0);
|
||
const after=await snapshot(request);
|
||
expect(after.prompts.filter(row=>row[2]==='Scenery')).toHaveLength(3);
|
||
});
|
||
|
||
test('an edit during a pending preview needs a new Preview; Apply never sends the old values',async({page,request})=>{
|
||
const writes=trackWrites(page);await openPage(page);const before=await snapshot(request);
|
||
let release;const held=new Promise(resolve=>{release=resolve;});
|
||
await page.route('**/prompt_manager/vocabulary/categories/preview',async route=>{await held;await route.continue();},{times:1});
|
||
const box=await openManage(page);
|
||
const source=field(box,'Category'),target=field(box,'Proposed new category name'),impact=box.locator('p.small');
|
||
await target.fill('Scenery');await button(box,'Preview').click();
|
||
await expect(button(box,'Preview')).toBeDisabled();
|
||
await target.fill('Vistas');await source.selectOption('Illustration');
|
||
release();
|
||
await expect.poll(()=>writes.length).toBe(1);
|
||
expect(writes[0]).toMatchObject({url:'/prompt_manager/vocabulary/categories/preview',body:{source:'Landscapes',target:'Scenery'}});
|
||
// The late "Landscapes → Scenery" preview no longer describes the form: no impact, no Apply.
|
||
await expect(button(box,'Preview')).toBeEnabled();
|
||
await expect(impact).toBeHidden();expect(await actionNames(box)).toEqual(['Cancel','Preview']);
|
||
await expect(target).toHaveValue('Vistas');await expect(source).toHaveValue('Illustration');
|
||
await button(box,'Preview').click();
|
||
await expect(impact).toHaveText('“Illustration” is used by 1 positive prompt. Apply renames it to “Vistas”.');
|
||
expect(writes.at(-1)).toMatchObject({url:'/prompt_manager/vocabulary/categories/preview',body:{source:'Illustration',target:'Vistas'}});
|
||
expect(writes.filter(write=>write.url.endsWith('/apply'))).toEqual([]);
|
||
expect(await snapshot(request)).toEqual(before);
|
||
});
|
||
|
||
test('delete tag confirms real impact and removes only the vocabulary name',async({page,request})=>{
|
||
const writes=trackWrites(page);await openPage(page);const before=await snapshot(request);
|
||
let box=await openRename(page,'cinematic');
|
||
await button(box,'Delete tag…').click();
|
||
await expect(box.getByRole('heading',{level:2})).toHaveText('Delete tag');
|
||
await expect(button(box,'Close dialog')).toBeFocused();
|
||
await expect(box.locator('p.small')).toHaveText('Deleting “cinematic” removes the name and its links from 2 positive prompts. The prompts, negative variants, images and history themselves are kept.');
|
||
expect(await actionNames(box)).toEqual(['Cancel','Delete']);
|
||
await expect(button(box,'Delete')).toHaveClass(/danger/);await expect(box.locator('.field')).toHaveCount(0);
|
||
await button(box,'Cancel').click();await expect(box).toHaveCount(0);
|
||
await expect(tagRow(page,'cinematic').getByRole('button',{name:'Rename / merge',exact:true})).toBeFocused();
|
||
expect(writes).toEqual([]);expect(await snapshot(request)).toEqual(before);
|
||
|
||
box=await openRename(page,'cinematic');await button(box,'Delete tag…').click();
|
||
await expect(box.locator('p.small')).toBeVisible();await button(box,'Delete').click();
|
||
await expect(box).toHaveCount(0);await expect(toast(page)).toHaveText('Tag deleted.');
|
||
await expect(tagRow(page,'cinematic')).toHaveCount(0);
|
||
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();
|
||
expect(writes).toEqual([{method:'POST',url:expect.stringMatching(/^\/prompt_manager\/vocabulary\/tags\/\d+\/delete$/),body:{expected_epoch:manifest.epoch,name:'cinematic'}}]);
|
||
const after=await snapshot(request);
|
||
const cinematic=before.tags.find(([,name])=>name==='cinematic')[0];
|
||
expect(after.tags).toEqual(before.tags.filter(([id])=>id!==cinematic));
|
||
expect(after.prompt_tags).toEqual(before.prompt_tags.filter(([,tag])=>tag!==cinematic));
|
||
for(const table of ['prompt_pair_tags','prompt_pairs','generated_images','image_prompt_pairs']) expect(after[table]).toEqual(before[table]);
|
||
expect(after.prompts.map(([id,text,category])=>[id,text,category])).toEqual(before.prompts.map(([id,text,category])=>[id,text,category]));
|
||
await page.goto('/prompt_manager/workspace#/library');
|
||
await expect(page.locator('article[data-prompt-id]')).toHaveCount(6);
|
||
await expect(page.locator('.library-chip').filter({hasText:/^cinematic$/})).toHaveCount(0);
|
||
await expect(page.locator('.library-chip').filter({hasText:/^blue_hour$/})).toHaveCount(1);
|
||
});
|
||
|
||
test('cancel and escape write nothing and restore focus',async({page,request})=>{
|
||
const writes=trackWrites(page);await openPage(page);const before=await snapshot(request);
|
||
const create=main(page).getByRole('button',{name:'+ Create tag',exact:true});
|
||
let box=await openDialog(page,create,'Create tag');
|
||
await field(box,'Tag name').fill('never_saved');await page.keyboard.press('Escape');
|
||
await expect(box).toHaveCount(0);await expect(create).toBeFocused();
|
||
box=await openRename(page,'mist');await field(box,'New name; existing names merge').fill('never_renamed');
|
||
await button(box,'Close dialog').click();await expect(box).toHaveCount(0);
|
||
await expect(tagRow(page,'mist').getByRole('button',{name:'Rename / merge',exact:true})).toBeFocused();
|
||
box=await openRename(page,'mist');await button(box,'Delete tag…').click();await expect(box.locator('p.small')).toBeVisible();
|
||
await page.keyboard.press('Escape');await expect(box).toHaveCount(0);
|
||
await expect(tagRow(page,'mist').getByRole('button',{name:'Rename / merge',exact:true})).toBeFocused();
|
||
box=await openManage(page);await field(box,'Proposed new category name').fill('Scenery');await button(box,'Preview').click();
|
||
await expect(box.locator('p.small')).toBeVisible();await button(box,'Cancel').click();
|
||
await expect(box).toHaveCount(0);await expect(categoryRow(page,'Manage category').getByRole('button',{name:'Review',exact:true})).toBeFocused();
|
||
expect(writes.map(write=>write.url)).toEqual(['/prompt_manager/vocabulary/categories/preview']);
|
||
expect(await snapshot(request)).toEqual(before);
|
||
await expect(toast(page)).toHaveText('');
|
||
});
|
||
|
||
test('conflict and restore states keep the dialog and write nothing',async({page,request})=>{
|
||
const saved=await backup(request);const writes=trackWrites(page);await openPage(page);
|
||
let box=await openRename(page,'mist');
|
||
await expect(await request.put('/prompt_manager/tags/mist',{data:{new_name:'fog'}})).toBeOK();
|
||
await field(box,'New name; existing names merge').fill('haze');await button(box,'Apply').click();
|
||
await expect(note(box)).toHaveText('This tag changed since this dialog opened. Nothing was changed. The list was re-read; review it before trying again.');
|
||
await expect(box).toBeVisible();await expect(button(box,'Apply')).toBeDisabled();
|
||
await expect(tagRow(page,'fog')).toHaveCount(1);await expect(tagRow(page,'mist')).toHaveCount(0);
|
||
expect(tagNames(await snapshot(request))).not.toContain('haze');
|
||
await button(box,'Cancel').click();await expect(box).toHaveCount(0);
|
||
|
||
box=await openManage(page);await field(box,'Proposed new category name').fill('Scenery');await button(box,'Preview').click();
|
||
await expect(box.locator('p.small')).toHaveText('“Landscapes” is used by 3 positive prompts. Apply renames it to “Scenery”.');
|
||
const geometry=manifest.recipes[1].positive_id;
|
||
const current=await(await request.get(`/prompt_manager/library/${geometry}`)).json();
|
||
await expect(await request.patch(`/prompt_manager/library/${geometry}`,{data:{expected_epoch:current.library_epoch,if_match:current.positive.edit_token,metadata:{category:'Landscapes'}}})).toBeOK();
|
||
await button(box,'Apply').click();
|
||
await expect(note(box)).toHaveText('This category changed since this dialog opened. Nothing was changed. Preview again before applying.');
|
||
expect(await actionNames(box)).toEqual(['Cancel','Preview']);await expect(box.locator('p.small')).toBeHidden();
|
||
expect((await snapshot(request)).prompts.some(row=>row[2]==='Scenery')).toBe(false);
|
||
await button(box,'Preview').click();
|
||
await expect(box.locator('p.small')).toHaveText('“Landscapes” is used by 4 positive prompts. Apply renames it to “Scenery”.');
|
||
await expect(note(box)).toBeHidden();
|
||
await button(box,'Cancel').click();
|
||
|
||
const before=await snapshot(request);
|
||
box=await openDialog(page,main(page).getByRole('button',{name:'+ Create tag',exact:true}),'Create tag');
|
||
await restore(request,saved);
|
||
await field(box,'Tag name').fill('sunrise');await button(box,'Apply').click();
|
||
await expect(note(box)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was changed.');
|
||
await expect(button(box,'Apply')).toBeDisabled();
|
||
expect(await actionNames(box)).toEqual(['Reload','Cancel','Apply']);
|
||
const restored=await snapshot(request);expect(tagNames(restored)).not.toContain('sunrise');expect(restored.tags).not.toEqual(before.tags);
|
||
await button(box,'Reload').click();await expect(box).toHaveCount(0);
|
||
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();
|
||
await expect(tagRow(page,'mist')).toHaveCount(1);await expect(tagRow(page,'fog')).toHaveCount(0);
|
||
expect(writes.filter(write=>write.url==='/prompt_manager/vocabulary/tags')).toHaveLength(1);
|
||
});
|
||
|
||
test('lost response is outcome-unknown',async({page,request})=>{
|
||
const writes=trackWrites(page);await openPage(page);
|
||
await page.route('**/prompt_manager/vocabulary/tags/*',async route=>{
|
||
if(route.request().method()!=='PATCH')return route.continue();
|
||
await route.fetch();await route.abort('failed');
|
||
});
|
||
const box=await openRename(page,'forest');
|
||
await field(box,'New name; existing names merge').fill('woodland');await button(box,'Apply').click();
|
||
await expect(note(box)).toHaveText('The result could not be confirmed. The lists were re-read; check them before applying again.');
|
||
await expect(box).toBeVisible();
|
||
await expect(tagRow(page,'woodland')).toHaveCount(1);await expect(tagRow(page,'forest')).toHaveCount(0);
|
||
expect(tagNames(await snapshot(request))).toContain('woodland');
|
||
// Never resent on its own.
|
||
await page.evaluate(()=>new Promise(resolve=>setTimeout(resolve,300)));
|
||
expect(writes.filter(write=>write.method==='PATCH')).toHaveLength(1);
|
||
await expect(button(box,'Apply')).toBeEnabled();
|
||
await button(box,'Cancel').click();await expect(box).toHaveCount(0);
|
||
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();
|
||
});
|
||
|
||
const nav=(page,name)=>page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name,exact:true});
|
||
const chips=(page,name)=>main(page).locator('article[data-prompt-id] .library-chip').filter({hasText:new RegExp(`^${name}$`)});
|
||
/** Visit the Library so its shelf is cached, then open Rename on blue_hour and start a rename whose PATCH is held. */
|
||
async function heldRename(page) {
|
||
await page.goto('/prompt_manager/workspace#/library');await expect(chips(page,'blue_hour')).toHaveCount(1);
|
||
await nav(page,'Overview').click();await nav(page,'Tags & categories').click();
|
||
await expect(rows(page)).toHaveCount(manifest.tags.length);
|
||
let held;const arrived=new Promise(resolve=>{held=resolve;});
|
||
await page.route('**/prompt_manager/vocabulary/tags/*',route=>route.request().method()==='PATCH'?held(route):route.continue());
|
||
const box=await openRename(page,'blue_hour');
|
||
await field(box,'New name; existing names merge').fill('golden_hour');await button(box,'Apply').click();
|
||
return {box,patch:await arrived};
|
||
}
|
||
async function releaseAndExpectFreshLibrary(page,patch) {
|
||
const landed=page.waitForResponse(response=>response.request().method()==='PATCH');
|
||
await patch.continue();expect((await landed).ok()).toBe(true);
|
||
await nav(page,'Prompt library').click();
|
||
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Prompt library');
|
||
await expect(chips(page,'golden_hour')).toHaveCount(1);await expect(chips(page,'blue_hour')).toHaveCount(0);
|
||
}
|
||
|
||
test('a write that lands after a forced dialog close still refreshes the lists and the cached Library',async({page,request})=>{
|
||
const {box,patch}=await heldRename(page);
|
||
// Chromium force-closes a modal on a repeated Escape; close() is the same event in every engine.
|
||
await box.evaluate(node=>node.close());
|
||
await expect(box).toHaveCount(0);
|
||
await expect(tagRow(page,'blue_hour').getByRole('button',{name:'Rename / merge',exact:true})).toBeFocused();
|
||
const landed=page.waitForResponse(response=>response.request().method()==='PATCH');
|
||
await patch.continue();await landed;
|
||
await expect(tagRow(page,'golden_hour')).toHaveCount(1);await expect(tagRow(page,'blue_hour')).toHaveCount(0);
|
||
expect(tagNames(await snapshot(request))).toContain('golden_hour');
|
||
await expect(toast(page)).not.toHaveText('Tag renamed.');
|
||
await nav(page,'Prompt library').click();
|
||
await expect(chips(page,'golden_hour')).toHaveCount(1);await expect(chips(page,'blue_hour')).toHaveCount(0);
|
||
});
|
||
|
||
test('Back during a write removes the dialog and the landed write refreshes the cached Library',async({page})=>{
|
||
const {box,patch}=await heldRename(page);
|
||
await page.goBack();
|
||
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Overview');
|
||
await expect(box).toHaveCount(0);
|
||
expect(await page.evaluate(()=>document.querySelectorAll('dialog').length)).toBe(0);
|
||
// Nothing stays inert: the page takes keyboard focus again.
|
||
await nav(page,'Prompt library').focus();await expect(nav(page,'Prompt library')).toBeFocused();
|
||
await releaseAndExpectFreshLibrary(page,patch);
|
||
});
|
||
|
||
test('a failed Load more read moves keyboard focus to Retry',async({page,request})=>{
|
||
manifest=await seed(request,{extra_tags:211});
|
||
await page.route('**/prompt_manager/vocabulary/tags?*',route=>{
|
||
if(new URL(route.request().url()).searchParams.has('cursor')) return route.fulfill({status:500,json:{success:false,error:'Database is unavailable'}});
|
||
return route.continue();
|
||
});
|
||
await page.goto(route);await expect(rows(page)).toHaveCount(100);
|
||
await loadMore(page).evaluate(button=>button.focus({preventScroll:true}));
|
||
await page.keyboard.press('Enter');
|
||
const retry=surfaces(page).first().getByRole('button',{name:'Retry',exact:true});
|
||
await expect(retry).toBeVisible();await expect(retry).toBeFocused();
|
||
await expect(rows(page)).toHaveCount(100);
|
||
});
|
||
|
||
test('an unconfirmed delete disables Delete and re-reads the impact before anything else',async({page,request})=>{
|
||
const writes=trackWrites(page);await openPage(page);
|
||
await page.route('**/prompt_manager/vocabulary/tags/*/delete',async route=>{await route.fetch();await route.abort('failed');});
|
||
const box=await openRename(page,'forest');await button(box,'Delete tag…').click();
|
||
const impact=box.locator('p.small');await expect(impact).toBeVisible();
|
||
await button(box,'Delete').click();
|
||
await expect(note(box)).toHaveText('The result could not be confirmed. The lists were re-read; check them before applying again.');
|
||
await expect(impact).toBeHidden();await expect(button(box,'Delete')).toBeDisabled();
|
||
expect(await actionNames(box)).toEqual(['Retry','Cancel','Delete']);
|
||
await expect(tagRow(page,'forest')).toHaveCount(0);expect(tagNames(await snapshot(request))).not.toContain('forest');
|
||
await page.evaluate(()=>new Promise(resolve=>setTimeout(resolve,300)));
|
||
expect(writes.filter(write=>write.method==='POST')).toHaveLength(1);
|
||
// Retry re-reads the impact and resends nothing: the tag is gone, so it is a changed tag.
|
||
await button(box,'Retry').click();
|
||
await expect(note(box)).toHaveText('This tag changed since this dialog opened. Nothing was changed. The list was re-read; review it before trying again.');
|
||
await expect(button(box,'Delete')).toBeDisabled();await expect(impact).toBeHidden();
|
||
expect(writes.filter(write=>write.method==='POST')).toHaveLength(1);
|
||
});
|
||
|
||
test('delete confirmation read failure, missing tag and restore keep Delete disabled',async({page,request})=>{
|
||
const saved=await backup(request);const writes=trackWrites(page);await openPage(page);
|
||
let failures=1;
|
||
await page.route('**/prompt_manager/vocabulary/tags/*/impact',route=>failures-->0?route.fulfill({status:500,json:{success:false,error:'Database is unavailable'}}):route.continue());
|
||
let box=await openRename(page,'mist');await button(box,'Delete tag…').click();
|
||
await expect(note(box)).toHaveText('Linked prompts could not be read. Nothing was deleted.');
|
||
await expect(button(box,'Delete')).toBeDisabled();expect(await actionNames(box)).toEqual(['Retry','Cancel','Delete']);
|
||
await button(box,'Retry').click();
|
||
await expect(box.locator('p.small')).toHaveText('Deleting “mist” removes the name and its links from 1 positive prompt. The prompts, negative variants, images and history themselves are kept.');
|
||
await expect(note(box)).toBeHidden();await expect(button(box,'Delete')).toBeEnabled();
|
||
await button(box,'Cancel').click();
|
||
|
||
// Deleted elsewhere: the impact read finds no tag, which is a changed tag.
|
||
box=await openRename(page,'forest');
|
||
await expect(await request.delete('/prompt_manager/tags/forest')).toBeOK();
|
||
await button(box,'Delete tag…').click();
|
||
await expect(note(box)).toHaveText('This tag changed since this dialog opened. Nothing was changed. The list was re-read; review it before trying again.');
|
||
await expect(button(box,'Delete')).toBeDisabled();await expect(tagRow(page,'forest')).toHaveCount(0);
|
||
await button(box,'Cancel').click();
|
||
// Renamed elsewhere after the impact read: the guarded delete is refused and writes nothing.
|
||
box=await openRename(page,'peaceful');await button(box,'Delete tag…').click();await expect(button(box,'Delete')).toBeEnabled();
|
||
await expect(await request.put('/prompt_manager/tags/peaceful',{data:{new_name:'calm'}})).toBeOK();
|
||
await button(box,'Delete').click();
|
||
await expect(note(box)).toHaveText('This tag changed since this dialog opened. Nothing was changed. The list was re-read; review it before trying again.');
|
||
await expect(button(box,'Delete')).toBeDisabled();await expect(tagRow(page,'calm').locator('td:nth-child(2)')).toHaveText('2');
|
||
await button(box,'Cancel').click();
|
||
|
||
box=await openRename(page,'mist');await restore(request,saved);
|
||
await button(box,'Delete tag…').click();
|
||
await expect(note(box)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was changed.');
|
||
await expect(button(box,'Delete')).toBeDisabled();expect(await actionNames(box)).toEqual(['Reload','Cancel','Delete']);
|
||
await button(box,'Reload').click();await expect(box).toHaveCount(0);
|
||
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();await expect(tagRow(page,'forest')).toHaveCount(1);
|
||
expect(writes.map(write=>write.url)).toEqual([expect.stringMatching(/^\/prompt_manager\/vocabulary\/tags\/\d+\/delete$/)]);
|
||
});
|
||
|
||
test('manage categories lists every category page without Load more',async({page,request})=>{
|
||
manifest=await seed(request,{extra_categories:150});
|
||
let release;const held=new Promise(resolve=>{release=resolve;});
|
||
await page.route('**/prompt_manager/vocabulary/categories?*',async route=>{
|
||
if(new URL(route.request().url()).searchParams.has('cursor')) await held;
|
||
return route.continue();
|
||
});
|
||
await page.goto(route);await expect(categoryRow(page,'Landscapes')).toHaveCount(1);
|
||
const box=await openManage(page);
|
||
await expect(note(box)).toHaveText('Loading categories…');await expect(button(box,'Preview')).toBeDisabled();
|
||
release();
|
||
await expect(note(box)).toBeHidden();await expect(button(box,'Preview')).toBeEnabled();
|
||
const source=field(box,'Category');
|
||
await expect(source.locator('option')).toHaveCount(154);
|
||
await expect(source.locator('option').last()).toHaveText('bulk_category_0149');
|
||
expect(await box.locator('datalist#vocabulary-category-options option').count()).toBe(154);
|
||
await source.selectOption('bulk_category_0149');await field(box,'Proposed new category name').fill('Bulk renamed');
|
||
await button(box,'Preview').click();
|
||
await expect(box.locator('p.small')).toHaveText('“bulk_category_0149” is used by 1 positive prompt. Apply renames it to “Bulk renamed”.');
|
||
await expect(main(page).getByRole('button',{name:'Load more categories',exact:true})).toHaveCount(0);
|
||
});
|
||
|
||
test('manage categories stops at 1,000 categories and says so',async({page})=>{
|
||
await page.route('**/prompt_manager/vocabulary/categories?*',route=>{
|
||
const cursor=new URL(route.request().url()).searchParams.get('cursor');const index=cursor?Number(cursor.slice(1)):0;
|
||
const categories=Array.from({length:100},(_,item)=>({value:`many_${String(index*100+item).padStart(5,'0')}`,prompt_count:1}));
|
||
return route.fulfill({json:{success:true,library_epoch:manifest.epoch,total:5000,has_more:true,next_cursor:`c${index+1}`,categories}});
|
||
});
|
||
await page.goto(route);await expect(categoryRow(page,'many_00000')).toHaveCount(1);
|
||
// Every mocked page has more, so scrolling to Review would keep appending under the pointer.
|
||
const review=categoryRow(page,'Manage category').getByRole('button',{name:'Review',exact:true});
|
||
const box=await openDialog(page,{click:()=>review.evaluate(node=>node.click())},'Manage categories');
|
||
await expect(note(box)).toHaveText('Only the first 1,000 categories are listed.');
|
||
await expect(field(box,'Category').locator('option')).toHaveCount(1000);
|
||
await expect(field(box,'Category').locator('option').last()).toHaveText('many_00999');
|
||
await expect(button(box,'Preview')).toBeEnabled();
|
||
});
|
||
|
||
test('a failed category read in Manage categories offers Retry',async({page,request})=>{
|
||
manifest=await seed(request,{extra_categories:150});
|
||
let failures=1;
|
||
await page.route('**/prompt_manager/vocabulary/categories?*',route=>{
|
||
if(new URL(route.request().url()).searchParams.has('cursor') && failures-->0) return route.fulfill({status:500,json:{success:false,error:'Database is unavailable'}});
|
||
return route.continue();
|
||
});
|
||
await page.goto(route);await expect(categoryRow(page,'Landscapes')).toHaveCount(1);
|
||
const box=await openManage(page);
|
||
await expect(note(box)).toHaveText('Categories could not be loaded. Nothing was changed.');
|
||
await expect(button(box,'Preview')).toBeDisabled();expect(await actionNames(box)).toEqual(['Retry','Cancel','Preview']);
|
||
await button(box,'Retry').click();
|
||
await expect(field(box,'Category').locator('option')).toHaveCount(154);
|
||
await expect(note(box)).toBeHidden();await expect(button(box,'Preview')).toBeEnabled();
|
||
});
|
||
|
||
// Concept comparison (Task 9): the immutable concept at /__test__/canonical/workspace against production with the
|
||
// concept fixture, same browser, both themes, both widths, normal and reduced motion. Only recorded deviations are mapped.
|
||
const concept='/__test__/canonical/workspace';
|
||
const SIZES=[[1600,1100],[390,844]];
|
||
const MOTIONS=['no-preference','reduce'];
|
||
async function openConceptTags(page,theme,motion) {
|
||
await page.emulateMedia({reducedMotion:motion});
|
||
await page.goto('about:blank');await page.goto(`${concept}#tags`);
|
||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||
await expect(page.locator('#main h1')).toHaveText('Tags & categories');
|
||
}
|
||
async function openProductionTags(page,theme,motion) {
|
||
await page.emulateMedia({reducedMotion:motion});
|
||
await page.goto('about:blank');await page.goto(route);
|
||
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
|
||
await expect(rows(page)).toHaveCount(manifest.tags.length);await expect(categoryRow(page,'Landscapes')).toHaveCount(1);
|
||
}
|
||
const pageOf=(page,production)=>page.locator(production?'#workspace-main':'#main').evaluate(pageInventoryOf,production);
|
||
/** Breadcrumb, document title (D7: "Tags", only the concept suffix differs) and the keyboard focus ring on a row action. */
|
||
async function chromeOf(page,production) {
|
||
const scope=page.locator(production?'#workspace-main':'#main');
|
||
await scope.locator('tbody tr').first().getByRole('button',{name:'Rename / merge',exact:true}).focus();
|
||
await page.keyboard.press('Shift+Tab');
|
||
const browse=scope.locator('tbody tr').first().getByRole('button',{name:'Browse',exact:true});await expect(browse).toBeFocused();
|
||
const ring=await browse.evaluate(node=>{const style=getComputedStyle(node);return {visible:node.matches(':focus-visible'),
|
||
outlineStyle:style.outlineStyle,outlineWidth:style.outlineWidth,outlineColor:style.outlineColor,outlineOffset:style.outlineOffset};});
|
||
await page.keyboard.press('Tab');
|
||
const brandLink=page.locator('a.brand').first();await brandLink.focus();
|
||
const linkRing=await brandLink.evaluate(node=>{const style=getComputedStyle(node);return {visible:node.matches(':focus-visible'),
|
||
outlineStyle:style.outlineStyle,outlineWidth:style.outlineWidth,outlineOffset:style.outlineOffset};});
|
||
await page.keyboard.press('Tab');
|
||
await expect(page).toHaveTitle(/^Tags( · Prompt Manager( concept)?)?$/);
|
||
return {breadcrumb:(await page.locator(production?'#workspace-breadcrumb':'#breadcrumb').textContent()).trim(),
|
||
title:(await page.title()).replace(/ · Prompt Manager( concept)?$/,''),ring,linkRing};
|
||
}
|
||
/** Concept rows are the concept's own sample tags; production appends the fixture-only tags after them (D4 creation order). */
|
||
function comparePage(reference,actual) {
|
||
const expected=mapVocabularyConcept(reference);
|
||
const shared=expected.table.rows.length;
|
||
return {diff:diffInventory(expected,{...actual,table:{...actual.table,rows:actual.table.rows.slice(0,shared)}}),
|
||
extra:actual.table.rows.slice(shared).map(row=>row.tag)};
|
||
}
|
||
const fixtureOnly=()=>[manifest.pair_only_tag,manifest.archived_only_tag,manifest.negative_only_tag,manifest.unused_tag];
|
||
|
||
for(const [width,height] of SIZES)for(const theme of ['dark','light'])test(`tags page geometry, tokens and controls match the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{
|
||
await page.setViewportSize({width,height});const result={};
|
||
for(const motion of MOTIONS) {
|
||
await openConceptTags(page,theme,motion);const reference=await pageOf(page,false);
|
||
if(motion==='no-preference')await captureCanonicalState(page,testInfo,`concept-tags-${width}-${theme}`,'#main');
|
||
const referenceChrome=await chromeOf(page,false);
|
||
await openProductionTags(page,theme,motion);const actual=await pageOf(page,true);
|
||
if(motion==='no-preference')await captureCanonicalState(page,testInfo,`production-tags-${width}-${theme}`,'#workspace-main');
|
||
result[motion]={reference,actual,...comparePage(reference,actual),referenceChrome,actualChrome:await chromeOf(page,true)};
|
||
}
|
||
const path=testInfo.outputPath('tags-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
|
||
await testInfo.attach('tags-geometry',{path,contentType:'application/json'});
|
||
for(const [motion,{reference,diff,extra,referenceChrome,actualChrome}] of Object.entries(result)) {
|
||
expect.soft(referenceChrome,`${motion} concept chrome`).toMatchObject({breadcrumb:'Tags',title:'Tags',ring:{visible:true},linkRing:{outlineOffset:'3px'}});
|
||
expect.soft(actualChrome,`${motion} breadcrumb, title and focus ring`).toEqual(referenceChrome);
|
||
expect.soft(reference.heading.actions.buttons.map(item=>item.text),`${motion} concept heading actions`).toEqual(['+ Create tag','Auto-tag images']);
|
||
expect.soft(reference.table.rows.map(row=>[row.tag,Number(row.prompts)]),`${motion} concept rows`).toEqual(CONCEPT_COUNTS);
|
||
expect.soft(diff,`${motion} differences after the recorded deviations`).toEqual([]);
|
||
expect.soft(extra,`${motion} fixture-only rows`).toEqual(fixtureOnly());
|
||
}
|
||
const {actual}=result['no-preference'];
|
||
expect(actual.noPageScroll).toBe(true);
|
||
expect(actual.twoCol.columns).toBe(width<=700?1:2);
|
||
});
|
||
|
||
const conceptDialogs={
|
||
create:async page=>page.locator('#main').getByRole('button',{name:'+ Create tag',exact:true}).click(),
|
||
rename:async page=>page.locator('#main tbody tr').first().getByRole('button',{name:'Rename / merge',exact:true}).click(),
|
||
manage:async page=>page.locator('#main').getByRole('button',{name:'Review',exact:true}).click(),
|
||
};
|
||
const productionDialogs={
|
||
create:page=>openDialog(page,main(page).getByRole('button',{name:'+ Create tag',exact:true}),'Create tag'),
|
||
rename:page=>openRename(page,'cinematic'),
|
||
manage:async page=>{const box=await openManage(page);await expect(button(box,'Preview')).toBeEnabled();return box;},
|
||
};
|
||
async function conceptDonors(page,theme) {
|
||
await page.goto('about:blank');await page.goto(`${concept}#images`);await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||
await page.locator('#main').getByRole('button',{name:'Filters',exact:true}).click();await expect(page.locator('#modal')).toBeVisible();
|
||
const filters=await page.locator('#modal').evaluate(inventoryOf);
|
||
await page.goto('about:blank');await page.goto(`${concept}#prompt/alpine`);await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();await expect(page.locator('#modal')).toBeVisible();
|
||
const editor=await page.locator('#modal').evaluate(inventoryOf);
|
||
return {filters,editor,categories:CATEGORIES.map(([name])=>name)};
|
||
}
|
||
|
||
for(const [width,height] of SIZES)for(const theme of ['dark','light'])test(`dialog inventories match the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{
|
||
await page.setViewportSize({width,height});const result={};
|
||
const donors=await conceptDonors(page,theme);
|
||
for(const mode of Object.keys(conceptDialogs)) {
|
||
await openConceptTags(page,theme,'no-preference');await conceptDialogs[mode](page);
|
||
const modal=page.locator('#modal');await expect(modal).toBeVisible();
|
||
result[mode]={reference:await modal.evaluate(inventoryOf),referenceValue:await modal.locator('input').inputValue()};
|
||
await captureCanonicalState(page,testInfo,`concept-dialog-${mode}-${width}-${theme}`,'#modal');
|
||
}
|
||
await openProductionTags(page,theme,'no-preference');
|
||
for(const mode of Object.keys(productionDialogs)) {
|
||
const box=await productionDialogs[mode](page);
|
||
result[mode].actual=await box.evaluate(inventoryOf);result[mode].actualValue=await box.locator('input').inputValue();
|
||
await captureCanonicalState(page,testInfo,`production-dialog-${mode}-${width}-${theme}`,'dialog.vocabulary-dialog');
|
||
await page.keyboard.press('Escape');await expect(box).toHaveCount(0);
|
||
}
|
||
const path=testInfo.outputPath('vocabulary-dialog-geometry.json');await writeFile(path,JSON.stringify({donors,result},null,2));
|
||
await testInfo.attach('vocabulary-dialog-geometry',{path,contentType:'application/json'});
|
||
for(const [mode,{reference,actual,referenceValue,actualValue}] of Object.entries(result)) {
|
||
expect.soft(reference.actions.buttons.map(item=>item.text),`${mode} concept actions`).toEqual(['Cancel','Apply in prototype']);
|
||
expect.soft(diffInventory(mapVocabularyDialog(mode,reference,donors),actual),`${mode} differences after the recorded deviations`).toEqual([]);
|
||
expect.soft(actualValue,`${mode} prefilled value`).toBe(referenceValue);
|
||
}
|
||
});
|
||
|
||
test('mutation check: the comparison reports a changed surface padding and a changed Browse label',async({page})=>{
|
||
await page.setViewportSize({width:1600,height:1100});
|
||
await openConceptTags(page,'dark','no-preference');const reference=await pageOf(page,false);
|
||
await openProductionTags(page,'dark','no-preference');
|
||
expect(comparePage(reference,await pageOf(page,true)).diff).toEqual([]);
|
||
await page.addStyleTag({content:'.vocabulary-main .surface{padding:21px!important}'});
|
||
await rows(page).first().getByRole('button',{name:'Browse',exact:true}).evaluate(node=>{node.textContent='Open';});
|
||
const paths=comparePage(reference,await pageOf(page,true)).diff.map(item=>item.path);
|
||
expect(paths).toContain('surfaces.0.paddingTop');
|
||
expect(paths).toContain('surfaces.1.paddingLeft');
|
||
expect(paths).toContain('table.rows.0.actions.0');
|
||
expect(paths).toContain('table.rowActions.buttons.0.text');
|
||
// The dialog comparison is not vacuous either: a removed Cancel and a recolored field are reported.
|
||
const donors=await conceptDonors(page,'dark');
|
||
await openConceptTags(page,'dark','no-preference');await conceptDialogs.create(page);
|
||
const dialogReference=mapVocabularyDialog('create',await page.locator('#modal').evaluate(inventoryOf),donors);
|
||
await openProductionTags(page,'dark','no-preference');const box=await productionDialogs.create(page);
|
||
expect(diffInventory(dialogReference,await box.evaluate(inventoryOf))).toEqual([]);
|
||
await page.addStyleTag({content:'dialog.vocabulary-dialog .field input{color:rgb(4,5,6)!important}'});
|
||
await button(box,'Cancel').evaluate(node=>node.remove());
|
||
const dialogPaths=diffInventory(dialogReference,await box.evaluate(inventoryOf)).map(item=>item.path);
|
||
expect(dialogPaths).toContain('actions.buttons.length');
|
||
expect(dialogPaths).toContain('fields.0.control.color');
|
||
await page.keyboard.press('Escape');
|
||
});
|