@@ -157,8 +157,8 @@ has("description leads with licence, stance and credit", p, '<meta name="descrip
157157has ( "canonical" , p , '<link rel="canonical" href="https://x.test/s/entry/e1/" />' ) ;
158158eq ( "one h1" , ( p . match ( / < h 1 [ \s > ] / g) || [ ] ) . length , 1 ) ;
159159has ( "credit line escaped" , p , "Art by "X" </script>" ) ;
160- has ( "entry script with a credit line" , p , '<script src="../../entry.js" defer ></script>' ) ;
161- has ( "entry script without one, for the back link" , page ( { attribution_string : undefined } ) , '<script src="../../entry.js" defer ></script>' ) ;
160+ has ( "entry script with a credit line" , p , '<script type="module" src="../../entry.js"></script>' ) ;
161+ has ( "entry script without one, for the back link" , page ( { attribution_string : undefined } ) , '<script type="module" src="../../entry.js"></script>' ) ;
162162lacks ( "no copy button without a credit line" , page ( { attribution_string : undefined } ) , "copy-attribution" ) ;
163163has ( "breadcrumb catalog link can return to the results" , p , '<a href="../../#catalog" data-back-to-results>Catalog</a>' ) ;
164164has ( "note when credit is required but not canned" , page ( { attribution_string : undefined } ) , "no canned credit line" ) ;
@@ -2017,9 +2017,7 @@ eq("search: q from the URL is capped", readState(`?q=${"a".repeat(5000)}`, { q:
20172017 has ( "thumbs flags: a limit needs a value" , errOf ( ( ) => thumbsOptions ( [ "--limit" ] ) ) , "argument missing" ) ;
20182018
20192019 const { esc } = await import ( "./shared.mjs" ) ;
2020- const short = nodeFs . readFileSync ( new URL ( "../public/shortlist.js" , import . meta. url ) , "utf8" ) ;
2021- const body = short . match ( / f u n c t i o n e s c a p e H t m l \( s t r \) \{ ( [ \s \S ] * ?) \n \} / ) [ 1 ] ;
2022- const escapeHtml = new Function ( "str" , body ) ;
2020+ const { escapeHtml } = await import ( "./client-state.mjs" ) ;
20232021 const sample = `<a href="x" title='y'>Tom & "Jerry"</a>` ;
20242022 eq ( "shortlist's escapeHtml matches shared esc" , escapeHtml ( sample ) , esc ( sample ) ) ;
20252023}
@@ -2263,6 +2261,57 @@ eq("search: q from the URL is capped", readState(`?q=${"a".repeat(5000)}`, { q:
22632261 lacks ( "footer without repo does not" , siteFooterHtml ( { root : "" , stamp : "s" , total : 1 } ) , "Contribute on GitHub" ) ;
22642262}
22652263
2264+ /* client behaviour, pure parts (#263) -------------------------------------- */
2265+ {
2266+ const { activeFilterChips, FILTER_DEFAULTS : D } = await import ( "./search.mjs" ) ;
2267+ const labels = { license : { "cc-by" : "CC BY" } , category : { "2d" : "2D" } } ;
2268+ eq ( "chips: the default state has none" , activeFilterChips ( { ...D } , D , labels ) . length , 0 ) ;
2269+ const q = activeFilterChips ( { ...D , q : " x " } , D , labels ) ;
2270+ eq ( "chips: a search gives one chip" , q . map ( ( c ) => `${ c . key } =${ c . label } ` ) . join ( ) , "q=search: x" ) ;
2271+ eq ( "chips: the search chip is not a filter" , q . filter ( ( c ) => c . key !== "q" ) . length , 0 ) ;
2272+ eq ( "chips: active off reads hiding active" , activeFilterChips ( { ...D , active : false } , D , labels ) [ 0 ] . label , "hiding active" ) ;
2273+ eq ( "chips: a known licence uses its label" , activeFilterChips ( { ...D , license : "cc-by" } , D , labels ) [ 0 ] . label , "licence: CC BY" ) ;
2274+ eq ( "chips: an unknown licence shows as written" , activeFilterChips ( { ...D , license : "odd" } , D , labels ) [ 0 ] . label , "licence: odd" ) ;
2275+ eq ( "chips: the category comes first" , activeFilterChips ( { ...D , category : "2d" , noAttr : true } , D , labels ) . map ( ( c ) => c . key ) . join ( ) , "category,noAttr" ) ;
2276+
2277+ const { parseShortlist, backLinkHref } = await import ( "./client-state.mjs" ) ;
2278+ for ( const raw of [ null , "{" , '{"a":1}' ] ) eq ( `shortlist: ${ raw } reads as empty` , parseShortlist ( raw ) . length , 0 ) ;
2279+ eq ( "shortlist: only string ids are kept" , parseShortlist ( '["a",1,null]' ) . join ( ) , "a" ) ;
2280+ const byId = Object . assign ( Object . create ( null ) , { a : { } , b : { } } ) ;
2281+ eq ( "shortlist: constructor is not an entry" , parseShortlist ( '["constructor"]' , byId ) . length , 0 ) ;
2282+ eq ( "shortlist: a removed id drops out" , parseShortlist ( '["a","gone","b"]' , byId ) . join ( ) , "a,b" ) ;
2283+
2284+ eq ( "back link: a stored search returns to it" , backLinkHref ( "q=a&sort=added" ) , "../../?q=a&sort=added#catalog" ) ;
2285+ eq ( "back link: nothing stored keeps the built link" , backLinkHref ( null ) , null ) ;
2286+ eq ( "back link: a value that is not a query string is refused" , backLinkHref ( '"><script>' ) , null ) ;
2287+
2288+ // views.js: the remembered Cards / List choice, with storage stubbed.
2289+ const had = Object . getOwnPropertyDescriptor ( globalThis , "localStorage" ) ;
2290+ const store = new Map ( ) ;
2291+ const fake = { getItem : ( k ) => ( store . has ( k ) ? store . get ( k ) : null ) , setItem : ( k , v ) => store . set ( k , String ( v ) ) , removeItem : ( k ) => store . delete ( k ) } ;
2292+ Object . defineProperty ( globalThis , "localStorage" , { value : fake , configurable : true , writable : true } ) ;
2293+ try {
2294+ const { readStoredView, storeView } = await import ( "../public/views.js" ) ;
2295+ for ( const v of [ "cards" , "list" ] ) {
2296+ store . set ( "fgda:view:cards-default" , v ) ;
2297+ eq ( `views: ${ v } is read back` , readStoredView ( "cards" ) , v ) ;
2298+ }
2299+ for ( const v of [ "grid" , "" ] ) {
2300+ store . set ( "fgda:view:cards-default" , v ) ;
2301+ eq ( `views: "${ v } " reads as nothing` , readStoredView ( "cards" ) , null ) ;
2302+ }
2303+ storeView ( "cards" , "list" ) ;
2304+ eq ( "views: a non-default view is stored" , store . get ( "fgda:view:cards-default" ) , "list" ) ;
2305+ storeView ( "cards" , "cards" ) ;
2306+ eq ( "views: the default view removes the key" , store . has ( "fgda:view:cards-default" ) , false ) ;
2307+ Object . defineProperty ( globalThis , "localStorage" , { get ( ) { throw new Error ( "blocked" ) ; } , configurable : true } ) ;
2308+ eq ( "views: blocked storage reads as nothing" , readStoredView ( "cards" ) , null ) ;
2309+ } finally {
2310+ if ( had ) Object . defineProperty ( globalThis , "localStorage" , had ) ;
2311+ else delete globalThis . localStorage ;
2312+ }
2313+ }
2314+
22662315/* report (keep last) ------------------------------------------------------ */
22672316if ( failures . length ) {
22682317 console . error ( `lib.test failed (${ failures . length } ):` ) ;
0 commit comments