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 }); }); }); 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(); }); }); 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(); }); }); 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(); }); }); // ---- 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); }); });