2026-11-01 16:14:25 -08:00
import { describe , test , expect } from 'vitest' ;
import { explicitSiteRequest , isDefaultChrome , planReplace } from './replace-plan' ;
import { DEFAULT_HEADER_STATE } from '../state/PageContext' ;
import type { SitesmithResponse } from '../types/sitesmith' ;
const tree = ( name = 'Container' ) => ({ type : { resolvedName : name }, props : {}, nodes : [] }) as any ;
const siteResp = ( n = 1 ) : SitesmithResponse => ({
type : 'replace' , scope : 'site' , message : 'Built it' ,
pages : Array.from ({ length : n }, ( _ , i ) => ({ name : i ? `P ${ i } ` : 'Home' , tree : tree () })),
header : { tree : tree ( 'Navbar' ) }, footer : { tree : tree ( 'Footer' ) },
});
describe ( 'explicitSiteRequest' , () => {
test . each ([
'Build me a website for my bakery' ,
'Build my website' ,
'Create a new site for my plumbing business' ,
'Redo the whole site' ,
'Rebuild the entire website in a darker style' ,
'Make a 4 page site with home, about, menu and contact' ,
'Update all pages to use the new colours' ,
'Change the font on every page' ,
'Build my bakery website' ,
])( 'site: %s' , ( t ) => expect ( explicitSiteRequest ( t )). toBe ( true ));
test . each ([
'Build my home page following the layout of the attached mockup.' ,
'Build the home page for my site.' ,
'Build the home page for my site. Use the site I like as inspiration.' ,
'Redo the home page' ,
'Make this page look more premium' ,
'Add a pricing section' ,
'' ,
])( 'page: %s' , ( t ) => expect ( explicitSiteRequest ( t )). toBe ( false ));
});
describe ( 'isDefaultChrome' , () => {
const header = ( mut : ( n : any ) => void ) => { const s = JSON . parse ( DEFAULT_HEADER_STATE ); mut ( s ); return JSON . stringify ( s ); };
test ( 'the seed header + no footer is default, also after pages were added to the nav' , () => {
expect ( isDefaultChrome ( DEFAULT_HEADER_STATE , null )). toBe ( true );
expect ( isDefaultChrome ( header (( s ) => s [ 'header-navbar' ]. props . links . splice ( 1 , 0 , { text : 'About' , href : '/about' , source : 'page' , pageId : 'p2' })), null )). toBe ( true );
expect ( isDefaultChrome ( null , null )). toBe ( true );
});
test ( 'any owner change makes it custom' , () => {
expect ( isDefaultChrome ( header (( s ) => { s [ 'header-navbar' ]. props . logoText = 'Acme' ; }), null )). toBe ( false );
expect ( isDefaultChrome ( header (( s ) => { s [ 'header-navbar' ]. props . logoType = 'image' ; s [ 'header-navbar' ]. props . logoImage = 'assets/logo.png' ; }), null )). toBe ( false );
expect ( isDefaultChrome ( header (( s ) => { s [ 'header-navbar' ]. props . links [ 1 ]. text = 'Book now' ; }), null )). toBe ( false );
expect ( isDefaultChrome ( header (( s ) => { s . ROOT . nodes . push ( 'x' ); s . x = { type : { resolvedName : 'TextBlock' }, props : {}, nodes : [] }; }), null )). toBe ( false );
const footer = JSON . stringify ({ ROOT : { type : { resolvedName : 'Container' }, props : {}, nodes : [ 'f' ] }, f : { type : { resolvedName : 'Footer' }, props : { text : '© 2026 Acme Ltd' }, nodes : [] } });
expect ( isDefaultChrome ( DEFAULT_HEADER_STATE , footer )). toBe ( false );
const seedFooter = JSON . stringify ({ ROOT : { type : { resolvedName : 'Container' }, props : {}, nodes : [ 'f' ] }, f : { type : { resolvedName : 'Footer' }, props : { text : '© 2026 MySite. All rights reserved.' }, nodes : [] } });
expect ( isDefaultChrome ( DEFAULT_HEADER_STATE , seedFooter )). toBe ( true );
expect ( isDefaultChrome ( '{not json' , null )). toBe ( false );
});
});
describe ( 'planReplace' , () => {
test ( 'eval case: "Build my home page" answered with scope site -> a PAGE replace, no site confirm' , () => {
const p = planReplace ( siteResp ( 1 ), { userText : 'Build my home page following the layout of the attached mockup.' , chromeDefault : false });
expect ( p . confirm ). toBeNull ();
expect ( p . resp ). toMatchObject ({ type : 'replace' , scope : 'page' , pages : [{ name : 'Home' }] });
expect (( p . resp as any ). header ). toBeUndefined ();
expect (( p . resp as any ). footer ). toBeUndefined ();
expect ( p . note ). toMatch ( /only this page/i );
});
test ( 'page request + site answer + default chrome -> page replace that ASKS about header/footer' , () => {
const p = planReplace ( siteResp ( 3 ), { userText : 'Build my home page' , chromeDefault : true });
expect ( p . confirm ). toBe ( 'chrome' );
expect ( p . resp ). toMatchObject ({ scope : 'page' , pages : [{ name : 'Home' }], header : { tree : { type : { resolvedName : 'Navbar' } } } });
expect (( p . resp as any ). pages ). toHaveLength ( 1 );
});
test ( 'explicit site request keeps the site replace and its confirm' , () => {
const p = planReplace ( siteResp ( 3 ), { userText : 'Build me a website for my bakery' , chromeDefault : false });
expect ( p . confirm ). toBe ( 'site' );
expect ( p . resp ). toEqual ( siteResp ( 3 ));
});
test ( 'page answer with header/footer: asked only when chrome is default, otherwise dropped' , () => {
const resp : SitesmithResponse = { ...( siteResp ( 1 ) as any ), scope : 'page' };
expect ( planReplace ( resp , { userText : 'Build my home page' , chromeDefault : true }). confirm ). toBe ( 'chrome' );
const kept = planReplace ( resp , { userText : 'Build my home page' , chromeDefault : false });
expect ( kept . confirm ). toBeNull ();
expect (( kept . resp as any ). header ). toBeUndefined ();
expect ( kept . note ). toMatch ( /kept your header and footer/i );
});
test ( 'plain page / section / patch answers pass through untouched' , () => {
const page : SitesmithResponse = { type : 'replace' , scope : 'page' , message : 'm' , pages : [{ name : 'Home' , tree : tree () }] };
expect ( planReplace ( page , { userText : 'x' , chromeDefault : true })). toEqual ({ resp : page , confirm : null });
const patch : SitesmithResponse = { type : 'patch' , message : 'm' , ops : [] };
expect ( planReplace ( patch , { userText : 'x' , chromeDefault : true })). toEqual ({ resp : patch , confirm : null });
});
});
2026-11-01 16:16:04 -08:00
import { fullBleedPageRoots } from './replace-plan' ;
describe ( 'fullBleedPageRoots (AI-built pages have no white frame around every section)' , () => {
const page = ( rootProps : any ) : SitesmithResponse => ({ type : 'replace' , scope : 'page' , message : 'm' , pages : [{ name : 'Home' , tree : { type : { resolvedName : 'Container' }, props : rootProps , nodes : [] } as any }] });
test ( 'a root Container without padding gets padding 0 (the Container default is 20px)' , () => {
const out = fullBleedPageRoots ( page ({ tag : 'div' , node_id : 'ai-root-1' })) as any ;
expect ( out . pages [ 0 ]. tree . props . style ). toEqual ({ padding : '0px' });
const out2 = fullBleedPageRoots ( page ({ tag : 'div' , style : { backgroundColor : '#000' } })) as any ;
expect ( out2 . pages [ 0 ]. tree . props . style ). toEqual ({ padding : '0px' , backgroundColor : '#000' });
});
test ( 'padding the model set on purpose is kept; non-Container roots, sections and patches are untouched' , () => {
expect (( fullBleedPageRoots ( page ({ style : { padding : '40px' } })) as any ). pages [ 0 ]. tree . props . style ). toEqual ({ padding : '40px' });
expect (( fullBleedPageRoots ( page ({ style : { paddingTop : '8px' } })) as any ). pages [ 0 ]. tree . props . style ). toEqual ({ paddingTop : '8px' });
const sec : SitesmithResponse = { ...( page ({}) as any ), scope : 'section' };
expect ( fullBleedPageRoots ( sec )). toBe ( sec );
const patch : SitesmithResponse = { type : 'patch' , message : 'm' , ops : [] };
expect ( fullBleedPageRoots ( patch )). toBe ( patch );
const hero : SitesmithResponse = { type : 'replace' , scope : 'page' , message : 'm' , pages : [{ name : 'Home' , tree : { type : { resolvedName : 'HeroSimple' }, props : {}, nodes : [] } as any }] };
expect (( fullBleedPageRoots ( hero ) as any ). pages [ 0 ]. tree . props . style ). toBeUndefined ();
});
test ( 'site replace: every page root; the input is not mutated' , () => {
const r : SitesmithResponse = { type : 'replace' , scope : 'site' , message : 'm' , pages : [ 0 , 1 ]. map (( i ) => ({ name : `P ${ i } ` , tree : { type : { resolvedName : 'Container' }, props : {}, nodes : [] } as any })) };
const out = fullBleedPageRoots ( r ) as any ;
expect ( out . pages . map (( p : any ) => p . tree . props . style )). toEqual ([{ padding : '0px' }, { padding : '0px' }]);
expect (( r as any ). pages [ 0 ]. tree . props . style ). toBeUndefined ();
});
});
2026-11-01 16:23:33 -08:00
import { missingUploadedImageNote } from './replace-plan' ;
describe ( 'missingUploadedImageNote (the brief requires an uploaded image when there are uploads)' , () => {
const pageWith = ( props : any ) : SitesmithResponse => ({ type : 'replace' , scope : 'page' , message : 'm' , pages : [{ name : 'Home' , tree : { type : { resolvedName : 'Container' }, props : {}, nodes : [{ type : { resolvedName : 'ImageBlock' }, props , nodes : [] }] } as any }] });
test ( 'uploads offered but none used -> an honest note; used (src or bgImage) or no uploads -> none' , () => {
expect ( missingUploadedImageNote ( pageWith ({ src : 'https://placehold.co/800x600' }), 2 )). toMatch ( /did not use any of your 2 uploaded images/ );
expect ( missingUploadedImageNote ( pageWith ({ src : 'assets/team.jpg' }), 2 )). toBeNull ();
expect ( missingUploadedImageNote ( pageWith ({ bgImage : 'assets/team.jpg' }), 1 )). toBeNull ();
expect ( missingUploadedImageNote ( pageWith ({ src : 'https://placehold.co/800x600' }), 0 )). toBeNull ();
expect ( missingUploadedImageNote ({ type : 'patch' , message : 'm' , ops : [] }, 3 )). toBeNull ();
});
});
2026-11-01 17:21:14 -08:00
describe ( 'planReplace: header link repair' , () => {
const mk = ( scope : 'page' | 'site' ) : any => ({
type : 'replace' , scope , message : 'm' ,
pages : [{ name : 'Home' , tree : { type : { resolvedName : 'Container' }, props : {}, nodes : [] } }],
header : { tree : { type : { resolvedName : 'Navbar' }, props : { links : [{ text : 'Home' , href : '/' }] }, nodes : [] } },
});
const pages : any [] = [{ id : 'h' , name : 'Home' , slug : 'index' , craftState : null }, { id : 'a' , name : 'About' , slug : 'about' , craftState : null }];
test ( 'page scope + chrome confirm: header nav repaired and the miss count reported' , () => {
const plan = planReplace ( mk ( 'page' ), { userText : 'redo this page' , chromeDefault : true , pages });
expect ( plan . confirm ). toBe ( 'chrome' );
expect ( plan . addedLinks ). toEqual ([ 'About' ]);
expect (( plan . resp as any ). header . tree . props . followPages ). toBe ( true );
});
test ( 'site answer downgraded to a page is repaired as a page' , () => {
const plan = planReplace ( mk ( 'site' ), { userText : 'build my home page' , chromeDefault : true , pages });
expect ( plan . addedLinks ). toEqual ([ 'About' ]);
});
test ( 'explicit whole-site replace: header left as the model wrote it' , () => {
const r = mk ( 'site' );
const plan = planReplace ( r , { userText : 'build the whole site' , chromeDefault : true , pages });
expect ( plan . confirm ). toBe ( 'site' );
expect ( plan . resp ). toBe ( r );
expect ( plan . addedLinks ). toBeUndefined ();
});
});
2026-11-01 18:29:56 -08:00
// ---- round 4 item 1: a PARTIAL whole-site build (live case 2: "Built 1 page: Home", About/Brands/Contact missing)
// must never be offered as a bare "Replace site" -- that deleted the owner's other pages.
import { mergeAiPages } from '../state/PageContext' ;
describe ( 'round 4: partial whole-site builds' , () => {
const tree = { type : { resolvedName : 'Container' }, props : {}, nodes : [] } as any ;
const existing : any [] = [
{ id : 'home' , name : 'Home' , slug : 'index' , craftState : 'H' , seo : { title : 'Old' } },
{ id : 'p2' , name : 'MG TEST (delete me)' , slug : 'mobile-guard-test' , craftState : 'M' },
{ id : 'p3' , name : 'About' , slug : 'about' , craftState : 'A' },
];
const siteResp = ( names : string []) : SitesmithResponse => ({ type : 'replace' , scope : 'site' , message : 'Built 1 page: Home.' , pages : names.map (( name ) => ({ name , tree })),
header : { tree : { type : { resolvedName : 'Navbar' }, props : { links : [{ text : 'Home' , href : '/' }] }, nodes : [] } as any } });
test ( 'mergeAiPages (pure): built pages replace the page at the same address, new ones are appended, the rest are kept' , () => {
let n = 0 ;
const m = mergeAiPages ( existing , [{ name : 'Home' , craftState : 'NEW-H' }, { name : 'About' , craftState : 'NEW-A' }, { name : 'Brands' , craftState : 'NEW-B' }], () => `new ${ ++ n } ` );
expect ( m . pages . map (( p ) => [ p . id , p . name , p . slug , p . craftState ])). toEqual ([
[ 'home' , 'Home' , 'index' , 'NEW-H' ], [ 'p2' , 'MG TEST (delete me)' , 'mobile-guard-test' , 'M' ], [ 'p3' , 'About' , 'about' , 'NEW-A' ], [ 'new1' , 'Brands' , 'brands' , 'NEW-B' ]]);
expect ( m . pages [ 0 ]. seo ). toEqual ({ title : 'Old' }); // the owner's page settings stay
expect ( m . firstId ). toBe ( 'home' );
expect ( m . replaced ). toEqual ([ 'Home' , 'About' ]);
expect ( m . added ). toEqual ([ 'Brands' ]);
expect ( m . kept ). toEqual ([ 'MG TEST (delete me)' ]);
expect ( existing [ 0 ]. craftState ). toBe ( 'H' ); // input not mutated
});
test ( 'mergeAiPages: an empty site gets the first page as index/home' , () => {
const m = mergeAiPages ([], [{ name : 'Home' , craftState : 'X' }, { name : 'Contact' , craftState : 'Y' }], () => 'n1' );
expect ( m . pages . map (( p ) => [ p . id , p . slug ])). toEqual ([[ 'home' , 'index' ], [ 'n1' , 'contact' ]]);
});
test ( 'missing pages -> confirm "site-partial" with explicit counts, never the bare site confirm' , () => {
const plan = planReplace ( siteResp ([ 'Home' ]), { userText : 'Build me a website for OLS Digital' , chromeDefault : true , pages : existing ,
siteBuild : { planned : [ 'Home' , 'About' , 'Brands' , 'Contact' ], built : [ 'Home' ], missing : [ 'About' , 'Brands' , 'Contact' ] } });
expect ( plan . confirm ). toBe ( 'site-partial' );
expect ( plan . impact ). toEqual ({ planned : 4 , built : [ 'Home' ], missing : [ 'About' , 'Brands' , 'Contact' ],
replaced : [ 'Home' ], added : [], kept : [ 'MG TEST (delete me)' , 'About' ] });
});
test ( 'complete site build -> "site" confirm, still with the pages a replace would delete' , () => {
const plan = planReplace ( siteResp ([ 'Home' , 'Contact' ]), { userText : 'Build me a website' , chromeDefault : true , pages : existing ,
siteBuild : { planned : [ 'Home' , 'Contact' ], built : [ 'Home' , 'Contact' ], missing : [] } });
expect ( plan . confirm ). toBe ( 'site' );
expect ( plan . impact ? . kept ). toEqual ([ 'MG TEST (delete me)' , 'About' ]);
});
test ( 'an older server (no site_build) keeps the whole-site confirm' , () => {
expect ( planReplace ( siteResp ([ 'Home' ]), { userText : 'Build me a website' , chromeDefault : true , pages : existing }). confirm ). toBe ( 'site' );
});
test ( 'addPagesPlan: the header gets links to the kept pages (followPages), the built pages are untouched' , async () => {
const { addPagesResponse } = await import ( './replace-plan' );
const out = addPagesResponse ( siteResp ([ 'Home' ]), existing ) as any ;
expect ( out . header . tree . props . followPages ). toBe ( true );
expect ( out . header . tree . props . links . map (( l : any ) => l . href )). toEqual ([ '/' , '/mobile-guard-test' , '/about' ]);
expect ( out . pages ). toEqual (( siteResp ([ "Home" ]) as any ). pages );
});
});