diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs index 85a9ca3..7055766 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -256,6 +256,19 @@ test('returning from history restores library context and mobile content stays i await expect(history(page)).toHaveCount(0);await expect(page.locator('dialog[open]')).toHaveCount(0); }); +test('leaving the shelf before its first page answers and returning reads page one without a page error',async({page})=>{ + // Hold the first shelf page so the route changes while the shelf is still loading (a slow CI runner's natural order). + let reached;const firstRead=new Promise(resolve=>{reached=resolve;}); + await page.route(url=>url.pathname==='/prompt_manager/library'&&!url.searchParams.has('cursor'),route=>{reached();}); + await page.goto(route);await firstRead;await expect(page.locator('.library-status')).toHaveText('Loading prompt library…'); + await page.evaluate(()=>{window.location.hash='#/tags';}); + await expect(page.locator('#workspace-main > .heading h1')).toHaveText('Tags & categories'); + await page.unrouteAll({behavior:'ignoreErrors'}); + await page.evaluate(()=>{window.location.hash='#/library';}); + await expect(page.locator('#workspace-main > .heading h1')).toHaveText('Prompt library'); + await expect(cards(page)).toHaveCount(3);await expect(page.locator('.section-caption')).toContainText('3 positive prompts'); + expect(page.libraryErrors).toEqual([]); +}); test('empty shelf visits restore saved scroll without inventing a parent anchor',async({page})=>{ await page.setViewportSize({width:1600,height:300});await page.goto(route); await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('No matching saved positive'); diff --git a/tests/js/workspace-library-state.test.js b/tests/js/workspace-library-state.test.js index 98cdc5a..2442364 100644 --- a/tests/js/workspace-library-state.test.js +++ b/tests/js/workspace-library-state.test.js @@ -47,6 +47,22 @@ test('restored shelves keep filters, loaded parents and continuation without a f const next=createLibraryStream({api:{get:async url=>{urls.push(url);return page([item(2)]);}},restore:{filters:{text:'retained',sort:'name'},state:old.state}});assert.equal(next.state.items.length,1);await next.loadMore();assert.match(urls[0],/text=retained/);assert.match(urls[0],/cursor=next/);assert.equal(next.state.items.length,2);next.dispose(); assert.throws(()=>createLibraryStream({api:{},restore:{filters:{},state:{items:[item(1),item(1)],total:2,variantTotal:0,cursor:null,hasMore:false}}})); }); +test('a shelf left before its first page answered or after it failed restores and reads page one',async()=>{ + const {createLibraryStream}=await source; + // The shelf saves whatever state it holds when the route changes; a first page still pending or failed has no variant total yet. + let pending;const left=createLibraryStream({api:{get:()=>new Promise(resolve=>{pending=resolve;})}});const first=left.reset({text:'kept'}); + assert.equal(left.state.loading,true);assert.equal(left.state.variantTotal,null);const saved=left.state;left.dispose();pending(page([item(9)]));await first; + const failed=createLibraryStream({api:{get:async()=>{throw Error('offline');}}});await failed.reset();assert.ok(failed.state.error);failed.dispose(); + for(const state of [saved,failed.state]) { + const urls=[];const next=createLibraryStream({api:{get:async url=>{urls.push(url);return page([item(1)]);}},restore:{filters:{text:'kept'},state}}); + assert.deepEqual([next.state.items.length,next.state.loading,next.state.error,next.state.variantTotal,next.state.hasMore],[0,false,null,null,true]); + assert.equal(await next.loadMore(),1);assert.equal(new URL(urls[0],'https://x').searchParams.get('text'),'kept');assert.equal(new URL(urls[0],'https://x').searchParams.has('cursor'),false); + assert.equal(next.state.variantTotal,6);next.dispose(); + } + // A pending first page is only the empty, cursorless state: anything else without a variant total stays invalid. + for(const state of [{items:[item(1)],total:1,variantTotal:null,cursor:null,hasMore:false},{items:[],total:0,variantTotal:null,cursor:'next',hasMore:true},{items:[],total:3,variantTotal:null,cursor:null,hasMore:true},{items:[],total:0,variantTotal:null,cursor:null,hasMore:false}]) + assert.throws(()=>createLibraryStream({api:{},restore:{filters:{},state}}),/Invalid library page/); +}); test('parent shelf window bounds DOM and pins focused rows without changing logical order',async()=>{ const {libraryWindow}=await source;const first=libraryWindow(500,2,0,900);assert.equal(first[0],0);assert.ok(first.length<24); const middle=libraryWindow(500,2,50000,900,0);assert.equal(middle[0],0);assert.ok(middle.some(index=>index>150));assert.ok(middle.length<24);assert.deepEqual(middle,[...middle].sort((a,b)=>a-b));assert.deepEqual(libraryWindow(3,2,0,900),[0,1,2]); diff --git a/web/js/workspace/library-state.mjs b/web/js/workspace/library-state.mjs index 52a771c..7ba2b79 100644 --- a/web/js/workspace/library-state.mjs +++ b/web/js/workspace/library-state.mjs @@ -23,7 +23,9 @@ export function createLibraryStream({api,onChange=()=>{},restore}) { let query=parameters({},40),request,disposed=false,seen=new Set(); if(restore) { query=parameters(restore.filters,40);const saved=restore.state; - validatePage({items:saved.items,total:saved.total,variant_total:saved.variantTotal,next_cursor:saved.cursor,has_more:saved.cursor!==null},null); + // A shelf left before its first page answered (or after it failed) has no page yet: it restores as a fresh stream. + const firstPagePending=saved?.variantTotal===null && Array.isArray(saved.items) && !saved.items.length && saved.total===0 && saved.cursor===null && saved.hasMore===true; + if(!firstPagePending) validatePage({items:saved.items,total:saved.total,variant_total:saved.variantTotal,next_cursor:saved.cursor,has_more:saved.cursor!==null},null); if(new Set(saved.items.map(item=>item.id)).size!==saved.items.length) throw new TypeError('Duplicate restored parent'); state=Object.freeze({...saved,items:Object.freeze(saved.items.map(item=>Object.freeze({...item}))),loading:false,error:null});seen=new Set(state.items.map(item=>item.id)); }