5-step profile wizard (name/type, services, area, tone, palette preview) saved to extras.sitesmithProfile and sent with every Sitesmith request. Regenerate this section rewrites the selected block for the business as a single undo step. Refs #58 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
151 lines
7.0 KiB
TypeScript
151 lines
7.0 KiB
TypeScript
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import React from 'react';
|
|
import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness';
|
|
import { EditorConfigProvider } from '../../state/EditorConfigContext';
|
|
import { SaveGuardProvider } from '../../state/SaveGuardContext';
|
|
import { ProjectExtrasProvider, useProjectExtras } from '../../state/ProjectExtrasContext';
|
|
import { NoticeProvider, useNotices } from '../../state/NoticeContext';
|
|
import { SitesmithProvider } from '../../state/SitesmithContext';
|
|
import { SitesmithModal } from './SitesmithModal';
|
|
import { buildSitesmithTarget } from '../../utils/sitesmith-target';
|
|
import { __resetProfileWizardForTests, ProfileWizardHost } from './ProfileWizard';
|
|
import { getCommand, __resetCommandsForTests } from '../../editor/commands';
|
|
|
|
const CFG = { user: 'u', apiUrl: '/api/site-builder.php', csrfToken: 't', siteId: 7, siteDomain: 'x.com', siteName: 'x', backUrl: '/', isRoot: false };
|
|
const PROFILE = { business_name: 'Acme', business_type: 'Plumber', services: ['Drains'], area: 'Austin', tone: 'bold' };
|
|
|
|
const node = (type: string, props: any, extra: any = {}) => ({
|
|
type: { resolvedName: type }, isCanvas: type === 'Container', props, displayName: type,
|
|
custom: {}, hidden: false, nodes: [], linkedNodes: {}, ...extra,
|
|
});
|
|
const INITIAL = JSON.stringify({
|
|
ROOT: node('Container', { style: {}, tag: 'div' }, { nodes: ['intro', 'sec-1'] }),
|
|
intro: node('Heading', { text: 'Untouched intro', level: 'h2' }, { parent: 'ROOT' }),
|
|
'sec-1': node('Container', { style: {}, tag: 'section' }, { parent: 'ROOT', nodes: ['h-1'] }),
|
|
'h-1': node('Heading', { text: 'Original heading', level: 'h2' }, { parent: 'sec-1' }),
|
|
});
|
|
|
|
let h: EditorHarness;
|
|
let notices: ReturnType<typeof useNotices>;
|
|
let extras: ReturnType<typeof useProjectExtras>;
|
|
let sendBodies: any[];
|
|
let sendReply: any;
|
|
const onClose = vi.fn();
|
|
|
|
const Spy: React.FC = () => { notices = useNotices(); extras = useProjectExtras(); return null; };
|
|
|
|
const flush = () => h.act(async () => { await new Promise((r) => setTimeout(r, 0)); });
|
|
|
|
async function mount(withTarget: boolean, profile: unknown = PROFILE, initialState: string = INITIAL) {
|
|
h = renderEditorHarness({
|
|
initialState,
|
|
wrap: (tree) => (
|
|
<EditorConfigProvider config={CFG as any}>
|
|
<SaveGuardProvider><NoticeProvider><ProjectExtrasProvider><SitesmithProvider>{tree}</SitesmithProvider></ProjectExtrasProvider></NoticeProvider></SaveGuardProvider>
|
|
</EditorConfigProvider>
|
|
),
|
|
});
|
|
const target = withTarget ? buildSitesmithTarget(h.query, 'sec-1') : null;
|
|
h.mountChild(<><Spy /><ProfileWizardHost /><SitesmithModal onClose={onClose} target={target} /></>);
|
|
if (profile) h.act(() => extras.set('sitesmithProfile', profile));
|
|
await flush();
|
|
}
|
|
const regenBtn = () => [...document.querySelectorAll('button')].find((b) => b.textContent?.includes('Regenerate this section')) as HTMLButtonElement | undefined;
|
|
|
|
beforeEach(() => {
|
|
__resetProfileWizardForTests();
|
|
__resetCommandsForTests();
|
|
onClose.mockReset();
|
|
sendBodies = [];
|
|
sendReply = {
|
|
ok: true,
|
|
response: { type: 'patch', message: 'done', ops: [{
|
|
op: 'replace_node', node_id: 'sec-1',
|
|
tree: { type: { resolvedName: 'Container' }, props: { tag: 'section', style: {} }, nodes: [
|
|
{ type: { resolvedName: 'Heading' }, props: { text: 'Fresh copy for Acme', level: 'h2' }, nodes: [] },
|
|
] },
|
|
}] },
|
|
};
|
|
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: any) => {
|
|
const json = (v: unknown) => ({ json: async () => v });
|
|
if (url.includes('action=send')) { sendBodies.push(JSON.parse(init.body)); return json(sendReply); }
|
|
if (url.includes('action=entitlement')) return json({ ok: true, summary: { enabled: true, status: 'OK_MONTHLY', monthly_used: 0, monthly_cap: 10, bonus_credits: 0 } });
|
|
return json({ ok: true, messages: [] });
|
|
}));
|
|
});
|
|
afterEach(() => { h?.unmount(); vi.unstubAllGlobals(); });
|
|
|
|
describe('SitesmithModal regenerate', () => {
|
|
test('replaces the target section, sends intent + profile, and ONE undo restores it', async () => {
|
|
await mount(true);
|
|
expect(h.container.textContent).toContain('Original heading');
|
|
h.act(() => { regenBtn()!.click(); });
|
|
await flush();
|
|
|
|
expect(sendBodies).toHaveLength(1);
|
|
expect(sendBodies[0].intent).toBe('regenerate_section');
|
|
expect(sendBodies[0].profile).toMatchObject({ business_name: 'Acme' });
|
|
expect(sendBodies[0].target.node_id).toBe('sec-1');
|
|
|
|
expect(h.container.textContent).toContain('Fresh copy for Acme');
|
|
expect(h.container.textContent).not.toContain('Original heading');
|
|
expect(h.container.textContent).toContain('Untouched intro');
|
|
expect(notices.notices.some((n) => n.message === 'Section regenerated - Undo to go back')).toBe(true);
|
|
expect(onClose).toHaveBeenCalled();
|
|
|
|
h.act(() => { h.actions.history.undo(); });
|
|
expect(h.container.textContent).toContain('Original heading');
|
|
expect(h.container.textContent).not.toContain('Fresh copy for Acme');
|
|
// Section is back in its original slot (after the intro heading).
|
|
const rootKids: string[] = h.query.node('ROOT').get().data.nodes;
|
|
expect(rootKids).toHaveLength(2);
|
|
expect(rootKids[0]).toBe('intro');
|
|
});
|
|
|
|
test('a failed model reply leaves the section untouched', async () => {
|
|
sendReply = { ok: true, response: { type: 'patch', message: 'x', ops: [] } };
|
|
await mount(true);
|
|
h.act(() => { regenBtn()!.click(); });
|
|
await flush();
|
|
expect(h.container.textContent).toContain('Original heading');
|
|
expect(document.querySelector('[role="alert"]')?.textContent).toMatch(/did not return/i);
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('the button is absent without a target', async () => {
|
|
await mount(false);
|
|
expect(regenBtn()).toBeUndefined();
|
|
});
|
|
|
|
test('profile summary line shows the business', async () => {
|
|
await mount(true);
|
|
expect(document.querySelector('.sitesmith-profile-line')?.textContent).toContain('Acme');
|
|
});
|
|
});
|
|
|
|
describe('first run and commands', () => {
|
|
test('empty canvas + no profile: the chat steps aside and the wizard opens first', async () => {
|
|
await mount(false, null, '');
|
|
expect(onClose).toHaveBeenCalled();
|
|
expect(document.querySelector('.sitesmith-wiz')).not.toBeNull();
|
|
});
|
|
|
|
test('a canvas with content is not interrupted by the wizard', async () => {
|
|
await mount(false, null);
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
expect(document.querySelector('.sitesmith-wiz')).toBeNull();
|
|
});
|
|
|
|
test('edit.regenerate-section is enabled only for one selected top-level block', async () => {
|
|
await mount(false);
|
|
const cmd = getCommand('edit.regenerate-section')!;
|
|
expect(cmd).toBeDefined();
|
|
const ctx = (ids: string[]) => ({ query: h.query, selectedIds: ids } as any);
|
|
expect(cmd.when!(ctx(['sec-1']))).toBe(true);
|
|
expect(cmd.when!(ctx(['h-1']))).toBe(false); // nested heading
|
|
expect(cmd.when!(ctx([]))).toBe(false);
|
|
expect(cmd.when!(ctx(['sec-1', 'intro']))).toBe(false);
|
|
expect(getCommand('site.business-profile')).toBeDefined();
|
|
});
|
|
});
|