Compare commits
3 Commits
f039fd6ab4
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 602f1eabde | |||
| d6510e2b05 | |||
| a9bbc357e7 |
@@ -0,0 +1,44 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, master ]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: [macos, host]
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install deps
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Unit/Component tests (Vitest)
|
||||||
|
run: npm test
|
||||||
|
env:
|
||||||
|
CI: true
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
run: npx playwright install --with-deps
|
||||||
|
|
||||||
|
- name: E2E tests (Playwright)
|
||||||
|
run: npm run test:e2e -- --reporter=line
|
||||||
|
env:
|
||||||
|
CI: true
|
||||||
|
|
||||||
|
- name: Upload Playwright report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: playwright-report
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 3
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, master ]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: [macos, host]
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install deps
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Unit/Component tests (Vitest)
|
||||||
|
run: npm test
|
||||||
|
env:
|
||||||
|
CI: true
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
run: npx playwright install --with-deps
|
||||||
|
|
||||||
|
- name: E2E tests (Playwright)
|
||||||
|
run: npm run test:e2e
|
||||||
|
env:
|
||||||
|
CI: true
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||||
|
|
||||||
|
export default function BuilderPage() {
|
||||||
|
return <BuilderClientPage />
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { getMessages } from '@/lib/i18n/getMessages'
|
||||||
|
import type { AppLocale } from '@/lib/i18n/locales'
|
||||||
|
import I18nProvider from '@/components/I18nProvider'
|
||||||
|
import '../globals.css'
|
||||||
|
|
||||||
|
export const dynamic = 'force-static'
|
||||||
|
|
||||||
|
export default async function LocaleLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
params: Promise<{ locale: AppLocale }>
|
||||||
|
}) {
|
||||||
|
const { locale } = await params
|
||||||
|
const messages = await getMessages(locale)
|
||||||
|
return (
|
||||||
|
<html lang={locale}>
|
||||||
|
<body>
|
||||||
|
<I18nProvider locale={locale} messages={messages}>{children}</I18nProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
import { defaultLocale } from '@/lib/i18n/locales'
|
||||||
|
|
||||||
|
export default function IndexPage({ params }: { params: { locale: string } }) {
|
||||||
|
const loc = params.locale || defaultLocale
|
||||||
|
return (
|
||||||
|
<main className="p-6">
|
||||||
|
<h1 className="text-2xl font-semibold mb-4">Landing Builder</h1>
|
||||||
|
<p className="mb-4">Start building your landing page.</p>
|
||||||
|
<Link className="text-sky-600 underline" href={`/${loc}/builder`}>
|
||||||
|
Go to Builder
|
||||||
|
</Link>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
export default function BuilderPage() {
|
||||||
|
redirect('/en/builder')
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import BuilderClientPage from './BuilderClientPage'
|
||||||
|
import messages from '../messages/en.json'
|
||||||
|
|
||||||
|
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<NextIntlClientProvider messages={messages} locale="en">
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Inspector Add buttons title consistency', () => {
|
||||||
|
test('Features/Testimonials/FAQ Add buttons have title matching visible label', async () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Features
|
||||||
|
fireEvent.click(screen.getByTestId('card-features'))
|
||||||
|
fireEvent.click(await screen.findByLabelText('Select 0'))
|
||||||
|
const addFeature = await screen.findByRole('button', { name: /Add Feature Item/i })
|
||||||
|
expect(addFeature).toBeInTheDocument()
|
||||||
|
expect(addFeature.getAttribute('title')).toBe('Add Feature Item')
|
||||||
|
|
||||||
|
// Testimonials
|
||||||
|
fireEvent.click(screen.getByTestId('card-testimonials'))
|
||||||
|
fireEvent.click(await screen.findByLabelText('Select 1'))
|
||||||
|
const addTestimonial = await screen.findByRole('button', { name: /Add Testimonial/i })
|
||||||
|
expect(addTestimonial).toBeInTheDocument()
|
||||||
|
expect(addTestimonial.getAttribute('title')).toBe('Add Testimonial')
|
||||||
|
|
||||||
|
// FAQ
|
||||||
|
fireEvent.click(screen.getByTestId('card-faq'))
|
||||||
|
fireEvent.click(await screen.findByLabelText('Select 2'))
|
||||||
|
const addFaq = await screen.findByRole('button', { name: /Add FAQ Item/i })
|
||||||
|
expect(addFaq).toBeInTheDocument()
|
||||||
|
expect(addFaq.getAttribute('title')).toBe('Add FAQ Item')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import BuilderClientPage from './BuilderClientPage'
|
||||||
|
import messages from '../messages/en.json'
|
||||||
|
|
||||||
|
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<NextIntlClientProvider messages={messages} locale="en">
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BuilderClientPage - inspector aria-describedby (Features/Testimonials)', () => {
|
||||||
|
test('Features inputs reference help via aria-describedby', async () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('card-features'))
|
||||||
|
fireEvent.click(await screen.findByLabelText('Select 0'))
|
||||||
|
|
||||||
|
const feature0 = await screen.findByLabelText('Feature 0')
|
||||||
|
expect(feature0).toHaveAttribute('aria-describedby', 'ins-features-help')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Testimonials inputs reference help via aria-describedby', async () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('card-testimonials'))
|
||||||
|
fireEvent.click(await screen.findByLabelText('Select 0'))
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Add Testimonial/i }))
|
||||||
|
|
||||||
|
const author0 = await screen.findByLabelText('Author 0')
|
||||||
|
const quote0 = await screen.findByLabelText('Quote 0')
|
||||||
|
expect(author0).toHaveAttribute('aria-describedby', 'ins-testimonials-help')
|
||||||
|
expect(quote0).toHaveAttribute('aria-describedby', 'ins-testimonials-help')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import BuilderClientPage from './BuilderClientPage'
|
||||||
|
import messages from '../messages/en.json'
|
||||||
|
|
||||||
|
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<NextIntlClientProvider messages={messages} locale="en">
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BuilderClientPage - inspector aria-describedby (Hero/CTA/FAQ)', () => {
|
||||||
|
test('Hero input references help via aria-describedby', async () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('card-hero'))
|
||||||
|
fireEvent.click(await screen.findByLabelText('Select 0'))
|
||||||
|
|
||||||
|
const hero = await screen.findByLabelText('Hero Heading')
|
||||||
|
expect(hero).toHaveAttribute('aria-describedby', 'ins-hero-heading-help')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('CTA inputs reference help via aria-describedby', async () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('card-cta'))
|
||||||
|
fireEvent.click(await screen.findByLabelText('Select 0'))
|
||||||
|
|
||||||
|
const text = await screen.findByLabelText('CTA Text')
|
||||||
|
const href = await screen.findByLabelText('CTA Href')
|
||||||
|
expect(text).toHaveAttribute('aria-describedby', 'ins-cta-text-help')
|
||||||
|
expect(href).toHaveAttribute('aria-describedby', 'ins-cta-href-help')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('FAQ inputs reference help via aria-describedby', async () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('card-faq'))
|
||||||
|
fireEvent.click(await screen.findByLabelText('Select 0'))
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'Add FAQ Item' }))
|
||||||
|
|
||||||
|
const q0 = await screen.findByLabelText('FAQ Q 0')
|
||||||
|
const a0 = await screen.findByLabelText('FAQ A 0')
|
||||||
|
expect(q0).toHaveAttribute('aria-describedby', 'ins-faq-help')
|
||||||
|
expect(a0).toHaveAttribute('aria-describedby', 'ins-faq-help')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import BuilderClientPage from './BuilderClientPage'
|
||||||
|
import messages from '../messages/en.json'
|
||||||
|
|
||||||
|
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<NextIntlClientProvider messages={messages} locale="en">
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BuilderClientPage - inspector aria-describedby smoke', () => {
|
||||||
|
test('SEO/Analytics inputs reference helptext via aria-describedby', async () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
const seoTitle = await screen.findByLabelText('SEO Title')
|
||||||
|
const seoDesc = await screen.findByLabelText('SEO Description')
|
||||||
|
const ogImage = await screen.findByLabelText('OG Image URL')
|
||||||
|
const ga4 = await screen.findByLabelText('GA4 Measurement ID')
|
||||||
|
const pixel = await screen.findByLabelText('Meta Pixel ID')
|
||||||
|
|
||||||
|
expect(seoTitle).toHaveAttribute('aria-describedby', 'ins-seo-title-help')
|
||||||
|
expect(seoDesc).toHaveAttribute('aria-describedby', 'ins-seo-desc-help')
|
||||||
|
expect(ogImage).toHaveAttribute('aria-describedby', 'ins-og-image-help')
|
||||||
|
expect(ga4).toHaveAttribute('aria-describedby', 'ins-ga4-help')
|
||||||
|
expect(pixel).toHaveAttribute('aria-describedby', 'ins-meta-pixel-help')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import React from 'react'
|
||||||
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
|
import enMessages from '@/messages/en.json'
|
||||||
|
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||||
|
|
||||||
|
const messages: Record<string, string> = enMessages as Record<string, string>
|
||||||
|
|
||||||
|
describe('BuilderClientPage - features/testimonials preview', () => {
|
||||||
|
it('사이드바 카드 클릭으로 features/testimonials 섹션이 추가되고 프리뷰에 표기된다', () => {
|
||||||
|
render(
|
||||||
|
<NextIntlClientProvider locale="en" messages={messages}>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
// features 추가
|
||||||
|
fireEvent.click(screen.getByTestId('card-features'))
|
||||||
|
// testimonials 추가
|
||||||
|
fireEvent.click(screen.getByTestId('card-testimonials'))
|
||||||
|
|
||||||
|
// 프리뷰에서 섹션 요약 표기 확인
|
||||||
|
expect(screen.getByText(/features — items: 3/)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/testimonials — items: 2/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import React from 'react'
|
||||||
|
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||||
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
|
import enMessages from '@/messages/en.json'
|
||||||
|
const messages: Record<string, string> = enMessages as Record<string, string>
|
||||||
|
|
||||||
|
describe('BuilderClientPage - form preview', () => {
|
||||||
|
it('폼 필드 목록과 필수 여부가 미리보기에 표시된다', () => {
|
||||||
|
render(
|
||||||
|
<NextIntlClientProvider locale="en" messages={messages}>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
// FormBuilderPanel의 텍스트 필드 추가 버튼 클릭 (data-testid 사용)
|
||||||
|
fireEvent.click(screen.getByTestId('btn-add-text'))
|
||||||
|
|
||||||
|
// 프리뷰 존재 확인(컴팩트 인스펙터 영역으로 이동됨)
|
||||||
|
expect(screen.getByText(/Locale:/)).toBeInTheDocument()
|
||||||
|
|
||||||
|
// 첫 필드 항목 표기: 라벨/네임/required 여부(초기 false)
|
||||||
|
expect(screen.getByText(/Text 1 \(field1\)/)).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText(/required/)).not.toBeNull()
|
||||||
|
|
||||||
|
// required 토글 후 프리뷰 갱신 확인
|
||||||
|
fireEvent.click(screen.getByLabelText('Required 0'))
|
||||||
|
expect(screen.getAllByText('required').length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import React from 'react'
|
||||||
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
|
import enMessages from '@/messages/en.json'
|
||||||
|
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||||
|
|
||||||
|
const messages: Record<string, string> = enMessages as Record<string, string>
|
||||||
|
|
||||||
|
describe('BuilderClientPage - Inspector CTA', () => {
|
||||||
|
it('CTA 선택 후 인스펙터에서 텍스트/링크를 편집할 수 있다', () => {
|
||||||
|
render(
|
||||||
|
<NextIntlClientProvider locale="en" messages={messages}>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
// CTA 섹션 추가
|
||||||
|
fireEvent.click(screen.getByTestId('btn-add-cta'))
|
||||||
|
|
||||||
|
// 선택
|
||||||
|
fireEvent.click(screen.getByLabelText('Select 0'))
|
||||||
|
|
||||||
|
// 인풋 존재 및 값 변경
|
||||||
|
const textInput = screen.getByLabelText('CTA Text') as HTMLInputElement
|
||||||
|
const hrefInput = screen.getByLabelText('CTA Href') as HTMLInputElement
|
||||||
|
|
||||||
|
expect(textInput.value).toBeTruthy()
|
||||||
|
expect(hrefInput.value).toBeTruthy()
|
||||||
|
|
||||||
|
fireEvent.change(textInput, { target: { value: 'Buy Now' } })
|
||||||
|
fireEvent.change(hrefInput, { target: { value: 'https://example.com' } })
|
||||||
|
|
||||||
|
expect((screen.getByLabelText('CTA Text') as HTMLInputElement).value).toBe('Buy Now')
|
||||||
|
expect((screen.getByLabelText('CTA Href') as HTMLInputElement).value).toBe('https://example.com')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import React from 'react'
|
||||||
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
|
import enMessages from '@/messages/en.json'
|
||||||
|
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||||
|
|
||||||
|
const messages: Record<string, string> = enMessages as Record<string, string>
|
||||||
|
|
||||||
|
describe('BuilderClientPage - Inspector FAQ', () => {
|
||||||
|
it('FAQ 선택 후 인스펙터에서 아이템을 편집할 수 있다(쉼표 구분)', () => {
|
||||||
|
render(
|
||||||
|
<NextIntlClientProvider locale="en" messages={messages}>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
// FAQ 섹션 추가 및 선택
|
||||||
|
fireEvent.click(screen.getByTestId('btn-add-faq'))
|
||||||
|
fireEvent.click(screen.getByLabelText('Select 0'))
|
||||||
|
|
||||||
|
// 리스트 UI: Add FAQ Item 후 Q/A 입력 변경 확인
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add FAQ Item' }))
|
||||||
|
const q0 = screen.getByLabelText('FAQ Q 0') as HTMLInputElement
|
||||||
|
const a0 = screen.getByLabelText('FAQ A 0') as HTMLInputElement
|
||||||
|
fireEvent.change(q0, { target: { value: 'Q1?' } })
|
||||||
|
fireEvent.change(a0, { target: { value: 'A1' } })
|
||||||
|
expect((screen.getByLabelText('FAQ Q 0') as HTMLInputElement).value).toBe('Q1?')
|
||||||
|
expect((screen.getByLabelText('FAQ A 0') as HTMLInputElement).value).toBe('A1')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import React from 'react'
|
||||||
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
|
import enMessages from '@/messages/en.json'
|
||||||
|
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||||
|
|
||||||
|
const messages: Record<string, string> = enMessages as Record<string, string>
|
||||||
|
|
||||||
|
describe('BuilderClientPage - Inspector FAQ List UI', () => {
|
||||||
|
it('FAQ 항목을 추가/편집/삭제할 수 있다', () => {
|
||||||
|
render(
|
||||||
|
<NextIntlClientProvider locale="en" messages={messages}>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
// FAQ 섹션 추가 및 선택
|
||||||
|
fireEvent.click(screen.getByTestId('btn-add-faq'))
|
||||||
|
fireEvent.click(screen.getByLabelText('Select 0'))
|
||||||
|
|
||||||
|
// Add FAQ Item 버튼 클릭 → 첫 항목 생성
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add FAQ Item' }))
|
||||||
|
|
||||||
|
const q0 = screen.getByLabelText('FAQ Q 0') as HTMLInputElement
|
||||||
|
const a0 = screen.getByLabelText('FAQ A 0') as HTMLInputElement
|
||||||
|
|
||||||
|
fireEvent.change(q0, { target: { value: 'What is it?' } })
|
||||||
|
fireEvent.change(a0, { target: { value: 'A landing builder' } })
|
||||||
|
|
||||||
|
expect((screen.getByLabelText('FAQ Q 0') as HTMLInputElement).value).toBe('What is it?')
|
||||||
|
expect((screen.getByLabelText('FAQ A 0') as HTMLInputElement).value).toBe('A landing builder')
|
||||||
|
|
||||||
|
// 두 번째 항목 추가 후 삭제
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add FAQ Item' }))
|
||||||
|
const q1 = screen.getByLabelText('FAQ Q 1') as HTMLInputElement
|
||||||
|
fireEvent.change(q1, { target: { value: 'How to export?' } })
|
||||||
|
fireEvent.click(screen.getByLabelText('Remove FAQ 1'))
|
||||||
|
|
||||||
|
// 삭제 후 첫 항목만 남음
|
||||||
|
expect(screen.queryByLabelText('FAQ Q 1')).toBeNull()
|
||||||
|
expect(screen.getByLabelText('FAQ Q 0')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import BuilderClientPage from './BuilderClientPage'
|
||||||
|
import messages from '../messages/en.json'
|
||||||
|
|
||||||
|
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<NextIntlClientProvider messages={messages} locale="en">
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Inspector - Features & Testimonials editing', () => {
|
||||||
|
test('Features: add item and edit value', () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
// add Features section via sidebar
|
||||||
|
fireEvent.click(screen.getByTestId('card-features'))
|
||||||
|
|
||||||
|
// select the section in canvas (index 0)
|
||||||
|
fireEvent.click(screen.getByLabelText('Select 0'))
|
||||||
|
|
||||||
|
// add item
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Add Feature Item/i }))
|
||||||
|
|
||||||
|
// edit item 0
|
||||||
|
const input = screen.getByLabelText('Feature 0') as HTMLInputElement
|
||||||
|
fireEvent.change(input, { target: { value: 'New Feature' } })
|
||||||
|
expect(input.value).toBe('New Feature')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Testimonials: add item and edit author/quote', () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
// add Testimonials section via sidebar
|
||||||
|
fireEvent.click(screen.getByTestId('card-testimonials'))
|
||||||
|
|
||||||
|
// select the section (index 0)
|
||||||
|
fireEvent.click(screen.getByLabelText('Select 0'))
|
||||||
|
|
||||||
|
// add item
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Add Testimonial/i }))
|
||||||
|
|
||||||
|
const author = screen.getByLabelText('Author 0') as HTMLInputElement
|
||||||
|
const quote = screen.getByLabelText('Quote 0') as HTMLInputElement
|
||||||
|
|
||||||
|
fireEvent.change(author, { target: { value: 'Alice' } })
|
||||||
|
fireEvent.change(quote, { target: { value: 'Awesome!' } })
|
||||||
|
|
||||||
|
expect(author.value).toBe('Alice')
|
||||||
|
expect(quote.value).toBe('Awesome!')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import React from 'react'
|
||||||
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
|
import enMessages from '@/messages/en.json'
|
||||||
|
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||||
|
|
||||||
|
const messages: Record<string, string> = enMessages as Record<string, string>
|
||||||
|
|
||||||
|
describe('BuilderClientPage - Inspector panel', () => {
|
||||||
|
it('섹션 선택 후 인스펙터에서 Hero heading을 편집할 수 있다', () => {
|
||||||
|
render(
|
||||||
|
<NextIntlClientProvider locale="en" messages={messages}>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Hero 섹션 추가 (data-testid 사용)
|
||||||
|
fireEvent.click(screen.getByTestId('btn-add-hero'))
|
||||||
|
|
||||||
|
// 캔버스에서 첫 섹션 선택
|
||||||
|
fireEvent.click(screen.getByLabelText('Select 0'))
|
||||||
|
|
||||||
|
// 인스펙터에서 Hero Heading 입력 존재
|
||||||
|
const heading = screen.getByLabelText('Hero Heading') as HTMLInputElement
|
||||||
|
expect(heading).toBeInTheDocument()
|
||||||
|
// 기본값 존재(Welcome)
|
||||||
|
expect(heading.value).toBeTruthy()
|
||||||
|
|
||||||
|
// 값 변경
|
||||||
|
fireEvent.change(heading, { target: { value: 'Hello World' } })
|
||||||
|
expect((screen.getByLabelText('Hero Heading') as HTMLInputElement).value).toBe('Hello World')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { render, screen, within } from '@testing-library/react'
|
||||||
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import BuilderClientPage from './BuilderClientPage'
|
||||||
|
import messages from '../messages/en.json'
|
||||||
|
|
||||||
|
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<NextIntlClientProvider messages={messages} locale="en">
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BuilderClientPage - Inspector helptexts (Hero/CTA/FAQ)', () => {
|
||||||
|
test('Hero, CTA, FAQ helptexts render', async () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
// add hero and select it from canvas
|
||||||
|
screen.getByTestId('card-hero').click()
|
||||||
|
const sec0 = await screen.findByTestId('sec-item-0')
|
||||||
|
within(sec0).getByRole('button', { name: /Select/ }).click()
|
||||||
|
expect(await screen.findByText(/Main heading displayed prominently/i)).toBeInTheDocument()
|
||||||
|
|
||||||
|
// add cta and select it from canvas
|
||||||
|
screen.getByTestId('card-cta').click()
|
||||||
|
const sec1 = await screen.findByTestId('sec-item-1')
|
||||||
|
within(sec1).getByRole('button', { name: /Select/ }).click()
|
||||||
|
expect(await screen.findByLabelText('CTA Text')).toBeInTheDocument()
|
||||||
|
expect(await screen.findByLabelText('CTA Href')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// add faq and select it from canvas
|
||||||
|
screen.getByTestId('card-faq').click()
|
||||||
|
const sec2 = await screen.findByTestId('sec-item-2')
|
||||||
|
within(sec2).getByRole('button', { name: /Select/ }).click()
|
||||||
|
expect(await screen.findByText(/Add at least one FAQ item with Q and A/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import BuilderClientPage from './BuilderClientPage'
|
||||||
|
import messages from '../messages/en.json'
|
||||||
|
|
||||||
|
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<NextIntlClientProvider messages={messages} locale="en">
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Inspector keyboard UX - Features & Testimonials add item', () => {
|
||||||
|
test('Features: Space/Enter activates Add Feature Item', () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add features section and select it
|
||||||
|
fireEvent.click(screen.getByTestId('card-features'))
|
||||||
|
fireEvent.click(screen.getByLabelText('Select 0'))
|
||||||
|
|
||||||
|
const addBtn = screen.getByRole('button', { name: /Add Feature Item/i })
|
||||||
|
|
||||||
|
// Space key
|
||||||
|
addBtn.focus()
|
||||||
|
fireEvent.keyDown(addBtn, { key: ' ', code: 'Space', charCode: 32 })
|
||||||
|
// Expect first input to appear (Feature 0)
|
||||||
|
expect(screen.getByLabelText('Feature 0')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Enter key adds another
|
||||||
|
addBtn.focus()
|
||||||
|
fireEvent.keyDown(addBtn, { key: 'Enter', code: 'Enter', charCode: 13 })
|
||||||
|
expect(screen.getByLabelText('Feature 1')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Testimonials: Space/Enter activates Add Testimonial', () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add testimonials section and select it
|
||||||
|
fireEvent.click(screen.getByTestId('card-testimonials'))
|
||||||
|
fireEvent.click(screen.getByLabelText('Select 0'))
|
||||||
|
|
||||||
|
const addBtn = screen.getByRole('button', { name: /Add Testimonial/i })
|
||||||
|
|
||||||
|
// Space
|
||||||
|
addBtn.focus()
|
||||||
|
fireEvent.keyDown(addBtn, { key: ' ', code: 'Space', charCode: 32 })
|
||||||
|
expect(screen.getByLabelText('Author 0')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Quote 0')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Enter adds another
|
||||||
|
addBtn.focus()
|
||||||
|
fireEvent.keyDown(addBtn, { key: 'Enter', code: 'Enter', charCode: 13 })
|
||||||
|
expect(screen.getByLabelText('Author 1')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Quote 1')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import BuilderClientPage from './BuilderClientPage'
|
||||||
|
import messages from '../messages/en.json'
|
||||||
|
|
||||||
|
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<NextIntlClientProvider messages={messages} locale="en">
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Inspector keyboard UX - Remove actions', () => {
|
||||||
|
test('Features: Remove with Space/Enter', async () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add features and select
|
||||||
|
fireEvent.click(screen.getByTestId('card-features'))
|
||||||
|
fireEvent.click(screen.getByLabelText('Select 0'))
|
||||||
|
|
||||||
|
// Add two items
|
||||||
|
const addBtn = screen.getByRole('button', { name: /Add Feature Item/i })
|
||||||
|
let inputs = screen.queryAllByRole('textbox', { name: /Feature \d+/ })
|
||||||
|
const beforeAdd = inputs.length
|
||||||
|
fireEvent.click(addBtn)
|
||||||
|
fireEvent.click(addBtn)
|
||||||
|
inputs = screen.queryAllByRole('textbox', { name: /Feature \d+/ })
|
||||||
|
expect(inputs.length).toBe(beforeAdd + 2)
|
||||||
|
|
||||||
|
// Remove last item via Space
|
||||||
|
inputs = screen.queryAllByRole('textbox', { name: /Feature \d+/ })
|
||||||
|
const beforeRemoveA = inputs.length
|
||||||
|
let removeButtons = screen.queryAllByRole('button', { name: /Remove Feature \d+/ })
|
||||||
|
const rmLastA = removeButtons[removeButtons.length - 1]
|
||||||
|
rmLastA.focus()
|
||||||
|
fireEvent.keyUp(rmLastA, { key: ' ', code: 'Space', charCode: 32 })
|
||||||
|
await waitFor(() => {
|
||||||
|
const now = screen.queryAllByRole('textbox', { name: /Feature \d+/ })
|
||||||
|
expect(now.length).toBe(beforeRemoveA - 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add another then remove last via Enter
|
||||||
|
fireEvent.click(addBtn)
|
||||||
|
inputs = screen.queryAllByRole('textbox', { name: /Feature \d+/ })
|
||||||
|
const beforeRemoveB = inputs.length
|
||||||
|
removeButtons = screen.queryAllByRole('button', { name: /Remove Feature \d+/ })
|
||||||
|
const rmLastB = removeButtons[removeButtons.length - 1]
|
||||||
|
rmLastB.focus()
|
||||||
|
fireEvent.keyUp(rmLastB, { key: 'Enter', code: 'Enter', charCode: 13 })
|
||||||
|
await waitFor(() => {
|
||||||
|
const now = screen.queryAllByRole('textbox', { name: /Feature \d+/ })
|
||||||
|
expect(now.length).toBe(beforeRemoveB - 1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Testimonials: Remove with Space/Enter', async () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add testimonials and select
|
||||||
|
fireEvent.click(screen.getByTestId('card-testimonials'))
|
||||||
|
fireEvent.click(screen.getByLabelText('Select 0'))
|
||||||
|
|
||||||
|
// Add two items
|
||||||
|
const addBtn = screen.getByRole('button', { name: /Add Testimonial/i })
|
||||||
|
let authors = screen.queryAllByRole('textbox', { name: /Author \d+/ })
|
||||||
|
let quotes = screen.queryAllByRole('textbox', { name: /Quote \d+/ })
|
||||||
|
const beforeAddT = authors.length
|
||||||
|
fireEvent.click(addBtn)
|
||||||
|
fireEvent.click(addBtn)
|
||||||
|
authors = screen.queryAllByRole('textbox', { name: /Author \d+/ })
|
||||||
|
quotes = screen.queryAllByRole('textbox', { name: /Quote \d+/ })
|
||||||
|
expect(authors.length).toBe(beforeAddT + 2)
|
||||||
|
expect(quotes.length).toBe(beforeAddT + 2)
|
||||||
|
|
||||||
|
// Remove last item via Enter
|
||||||
|
authors = screen.queryAllByRole('textbox', { name: /Author \d+/ })
|
||||||
|
let beforeRemoveT = authors.length
|
||||||
|
let rmTButtons = screen.queryAllByRole('button', { name: /Remove Testimonial \d+/ })
|
||||||
|
let rmLastT = rmTButtons[rmTButtons.length - 1]
|
||||||
|
rmLastT.focus()
|
||||||
|
fireEvent.keyUp(rmLastT, { key: 'Enter', code: 'Enter', charCode: 13 })
|
||||||
|
await waitFor(() => {
|
||||||
|
const aNow = screen.queryAllByRole('textbox', { name: /Author \d+/ })
|
||||||
|
expect(aNow.length).toBe(beforeRemoveT - 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add another then remove last via Space
|
||||||
|
fireEvent.click(addBtn)
|
||||||
|
authors = screen.queryAllByRole('textbox', { name: /Author \d+/ })
|
||||||
|
beforeRemoveT = authors.length
|
||||||
|
rmTButtons = screen.queryAllByRole('button', { name: /Remove Testimonial \d+/ })
|
||||||
|
rmLastT = rmTButtons[rmTButtons.length - 1]
|
||||||
|
rmLastT.focus()
|
||||||
|
fireEvent.keyUp(rmLastT, { key: ' ', code: 'Space', charCode: 32 })
|
||||||
|
await waitFor(() => {
|
||||||
|
const aNow = screen.queryAllByRole('textbox', { name: /Author \d+/ })
|
||||||
|
expect(aNow.length).toBe(beforeRemoveT - 1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
|
||||||
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
|
import { describe, expect, test, beforeEach } from 'vitest'
|
||||||
|
import BuilderClientPage from './BuilderClientPage'
|
||||||
|
import messages from '../messages/en.json'
|
||||||
|
|
||||||
|
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<NextIntlClientProvider messages={messages} locale="en">
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BuilderClientPage - preview viewport persistence & placeholders', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('persists viewport selection in localStorage', () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
const container = screen.getByTestId('preview-container')
|
||||||
|
expect(container).toHaveClass('w-[768px]')
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Mobile/i }))
|
||||||
|
expect(container).toHaveClass('w-[375px]')
|
||||||
|
|
||||||
|
// remount
|
||||||
|
cleanup()
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
const container2 = screen.getByTestId('preview-container')
|
||||||
|
expect(container2).toHaveClass('w-[375px]')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('inspector inputs have helpful placeholders', () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('SEO Title')).toHaveAttribute('placeholder')
|
||||||
|
expect(screen.getByLabelText('SEO Description')).toHaveAttribute('placeholder')
|
||||||
|
expect(screen.getByLabelText('OG Image URL')).toHaveAttribute('placeholder')
|
||||||
|
expect(screen.getByLabelText('GA4 Measurement ID')).toHaveAttribute('placeholder')
|
||||||
|
expect(screen.getByLabelText('Meta Pixel ID')).toHaveAttribute('placeholder')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import BuilderClientPage from './BuilderClientPage'
|
||||||
|
import messages from '../messages/en.json'
|
||||||
|
|
||||||
|
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<NextIntlClientProvider messages={messages} locale="en">
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BuilderClientPage - preview viewport toggle', () => {
|
||||||
|
test('toggles preview width between mobile and desktop', () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
const container = screen.getByTestId('preview-container')
|
||||||
|
// default is desktop
|
||||||
|
expect(container).toHaveClass('w-[768px]')
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Mobile/i }))
|
||||||
|
expect(container).toHaveClass('w-[375px]')
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Desktop/i }))
|
||||||
|
expect(container).toHaveClass('w-[768px]')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import React from 'react'
|
||||||
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
|
import enMessages from '@/messages/en.json'
|
||||||
|
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||||
|
|
||||||
|
const messages: Record<string, string> = enMessages as Record<string, string>
|
||||||
|
|
||||||
|
describe('BuilderClientPage - SectionCanvas integration', () => {
|
||||||
|
it('섹션을 추가하고 이동/삭제가 동작한다', () => {
|
||||||
|
render(
|
||||||
|
<NextIntlClientProvider locale="en" messages={messages}>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 섹션 3개 추가: Hero, FAQ, CTA (data-testid 사용)
|
||||||
|
fireEvent.click(screen.getByTestId('btn-add-hero'))
|
||||||
|
fireEvent.click(screen.getByTestId('btn-add-faq'))
|
||||||
|
fireEvent.click(screen.getByTestId('btn-add-cta'))
|
||||||
|
|
||||||
|
const canvas = screen.getByTestId('section-canvas')
|
||||||
|
expect(canvas).toBeInTheDocument()
|
||||||
|
|
||||||
|
// 초기 순서 확인: hero, faq, cta
|
||||||
|
let items = canvas.querySelectorAll('li')
|
||||||
|
expect(items.length).toBe(3)
|
||||||
|
expect(items[0].textContent).toMatch(/hero/)
|
||||||
|
expect(items[1].textContent).toMatch(/faq/)
|
||||||
|
expect(items[2].textContent).toMatch(/cta/)
|
||||||
|
|
||||||
|
// 두 번째 항목(faq)을 위로 이동 (Up 1)
|
||||||
|
fireEvent.click(screen.getByLabelText('Up 1'))
|
||||||
|
items = canvas.querySelectorAll('li')
|
||||||
|
expect(items[0].textContent).toMatch(/faq/)
|
||||||
|
expect(items[1].textContent).toMatch(/hero/)
|
||||||
|
|
||||||
|
// 마지막 항목(cta)을 위로 이동 (Up 2)
|
||||||
|
fireEvent.click(screen.getByLabelText('Up 2'))
|
||||||
|
items = canvas.querySelectorAll('li')
|
||||||
|
expect(items[0].textContent).toMatch(/faq/)
|
||||||
|
expect(items[1].textContent).toMatch(/cta/)
|
||||||
|
expect(items[2].textContent).toMatch(/hero/)
|
||||||
|
|
||||||
|
// 가운데 항목(cta) 삭제 (Remove 1)
|
||||||
|
fireEvent.click(screen.getByLabelText('Remove 1'))
|
||||||
|
items = canvas.querySelectorAll('li')
|
||||||
|
expect(items.length).toBe(2)
|
||||||
|
expect(items[0].textContent).toMatch(/faq/)
|
||||||
|
expect(items[1].textContent).toMatch(/hero/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import BuilderClientPage from './BuilderClientPage'
|
||||||
|
import messages from '../messages/en.json'
|
||||||
|
|
||||||
|
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<NextIntlClientProvider messages={messages} locale="en">
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SEO/Analytics inspector', () => {
|
||||||
|
test('edits SEO title/description and shows in preview', async () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
const seoTitle = screen.getByLabelText('SEO Title')
|
||||||
|
const seoDesc = screen.getByLabelText('SEO Description')
|
||||||
|
|
||||||
|
fireEvent.change(seoTitle, { target: { value: 'Landing SEO' } })
|
||||||
|
fireEvent.change(seoDesc, { target: { value: 'Great page' } })
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('preview-seo-title')).toHaveTextContent('Landing SEO')
|
||||||
|
expect(await screen.findByTestId('preview-seo-description')).toHaveTextContent('Great page')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('edits Analytics fields and shows in preview', async () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ga4 = screen.getByLabelText('GA4 Measurement ID')
|
||||||
|
const mp = screen.getByLabelText('Meta Pixel ID')
|
||||||
|
|
||||||
|
fireEvent.change(ga4, { target: { value: 'G-TEST123' } })
|
||||||
|
fireEvent.change(mp, { target: { value: '1234567890' } })
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('preview-ga4')).toHaveTextContent('G-TEST123')
|
||||||
|
expect(await screen.findByTestId('preview-meta-pixel')).toHaveTextContent('1234567890')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import BuilderClientPage from './BuilderClientPage'
|
||||||
|
import messages from '../messages/en.json'
|
||||||
|
|
||||||
|
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<NextIntlClientProvider messages={messages} locale="en">
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BuilderClientPage - SEO/Analytics helptexts', () => {
|
||||||
|
test('renders helpful hints under SEO/Analytics/OG inputs', () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText(/Shown in browser title and social previews/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/Used by search engines and OG description/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/Absolute URL recommended/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/Format: G-XXXXXXXXXX/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/Numeric Page ID/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,698 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
import { exportPage } from '@/lib/exporter/html'
|
||||||
|
import { AssetManager } from '@/lib/assets/assetManager'
|
||||||
|
import { buildZip } from '@/lib/exporter/zip'
|
||||||
|
import type { Section } from '@/lib/state/store'
|
||||||
|
import { createFormStore, type FormState } from '@/lib/state/formStore'
|
||||||
|
import FormBuilderPanel from '@/components/FormBuilderPanel'
|
||||||
|
import BuilderShell from '@/components/BuilderShell'
|
||||||
|
import SectionCanvas from '@/components/SectionCanvas'
|
||||||
|
import SidebarComponents from '@/components/SidebarComponents'
|
||||||
|
|
||||||
|
function download(filename: string, data: Uint8Array) {
|
||||||
|
const ab = data.slice().buffer as ArrayBuffer
|
||||||
|
const blob = new Blob([ab], { type: 'application/zip' })
|
||||||
|
try {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const w = window as unknown as { __captureExport?: boolean; __lastBlob?: Blob }
|
||||||
|
if (w.__captureExport) {
|
||||||
|
w.__lastBlob = blob
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
a.remove()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BuilderClientPage() {
|
||||||
|
const t = useTranslations()
|
||||||
|
const [locale, setLocale] = useState<'en' | 'ko'>('en')
|
||||||
|
const [title, setTitle] = useState('My Landing')
|
||||||
|
const [primaryColor, setPrimaryColor] = useState('#0ea5e9')
|
||||||
|
const [fontFamily, setFontFamily] = useState('Inter')
|
||||||
|
const [sections, setSections] = useState<Section[]>([])
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
|
||||||
|
const [seoTitle, setSeoTitle] = useState<string>('')
|
||||||
|
const [seoDescription, setSeoDescription] = useState<string>('')
|
||||||
|
const [ogImage, setOgImage] = useState<string>('')
|
||||||
|
const [ga4MeasurementId, setGa4MeasurementId] = useState<string>('')
|
||||||
|
const [metaPixelId, setMetaPixelId] = useState<string>('')
|
||||||
|
const [viewport, setViewport] = useState<'desktop' | 'mobile'>(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('builder.viewport') as 'desktop' | 'mobile' | null
|
||||||
|
if (saved === 'desktop' || saved === 'mobile') return saved
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return 'desktop'
|
||||||
|
})
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
const assetManagerRef = useRef<AssetManager>(
|
||||||
|
new AssetManager({ maxBytes: 10 * 1024 * 1024, allowedExts: ['png', 'jpg', 'jpeg', 'webp', 'svg'] })
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('builder.viewport', viewport)
|
||||||
|
} catch {}
|
||||||
|
}, [viewport])
|
||||||
|
const [am, setAm] = useState<AssetManager | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
setAm(assetManagerRef.current)
|
||||||
|
}, [])
|
||||||
|
const [selectedFileName, setSelectedFileName] = useState<string>('')
|
||||||
|
|
||||||
|
// form store (stable instance without ref access in render)
|
||||||
|
const formStore = useMemo(() => createFormStore(), [])
|
||||||
|
const formState: FormState = useSyncExternalStore(
|
||||||
|
formStore.subscribe,
|
||||||
|
formStore.getState,
|
||||||
|
formStore.getState
|
||||||
|
)
|
||||||
|
|
||||||
|
const addHero = useCallback(async () => {
|
||||||
|
const fileEl = fileInputRef.current
|
||||||
|
if (fileEl && fileEl.files && fileEl.files.length > 0) {
|
||||||
|
const file = fileEl.files[0]
|
||||||
|
const buf = new Uint8Array(await file.arrayBuffer())
|
||||||
|
if (am) {
|
||||||
|
await am.add({ name: file.name, type: file.type, bytes: buf })
|
||||||
|
}
|
||||||
|
setSelectedFileName(file.name)
|
||||||
|
}
|
||||||
|
// 파일이 없어도 Hero 섹션은 추가(이미지는 placeholder 사용)
|
||||||
|
const hero: Section = { type: 'hero', props: { heading: 'Welcome', subheading: '' } }
|
||||||
|
setSections((prev) => [...prev, hero])
|
||||||
|
}, [am])
|
||||||
|
|
||||||
|
const addFaq = useCallback(() => {
|
||||||
|
const faq: Section = { type: 'faq', props: { items: [] } }
|
||||||
|
setSections((prev) => [...prev, faq])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const addCta = useCallback(() => {
|
||||||
|
const cta: Section = { type: 'cta', props: { text: 'Get Started', href: '#' } }
|
||||||
|
setSections((prev) => [...prev, cta])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const addFeatures = useCallback(() => {
|
||||||
|
const features: Section = { type: 'features', props: { items: ['Feature A', 'Feature B', 'Feature C'] } }
|
||||||
|
setSections((prev) => [...prev, features])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const addTestimonials = useCallback(() => {
|
||||||
|
const testimonials: Section = {
|
||||||
|
type: 'testimonials',
|
||||||
|
props: { items: [{ author: 'Jane', quote: 'Great product!' }, { author: 'John', quote: 'Loved it.' }] },
|
||||||
|
}
|
||||||
|
setSections((prev) => [...prev, testimonials])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const removeAt = useCallback((idx: number) => {
|
||||||
|
setSections((prev) => prev.filter((_, i) => i !== idx))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const moveSection = useCallback((from: number, to: number) => {
|
||||||
|
setSections((prev) => {
|
||||||
|
if (from < 0 || to < 0 || from >= prev.length || to >= prev.length) return prev
|
||||||
|
const next = prev.slice()
|
||||||
|
const [sp] = next.splice(from, 1)
|
||||||
|
next.splice(to, 0, sp)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const pageData = useMemo<Page>(() => {
|
||||||
|
const firstName = selectedFileName
|
||||||
|
|
||||||
|
const mappedSections = sections.map((s: Section) => {
|
||||||
|
if (s.type === 'hero') {
|
||||||
|
return {
|
||||||
|
type: 'hero' as const,
|
||||||
|
props: {
|
||||||
|
heading: s.props?.heading || 'Welcome',
|
||||||
|
subheading: s.props?.subheading || '',
|
||||||
|
imageUrl: firstName ? `local:${firstName}` : 'https://via.placeholder.com/800x400',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
})
|
||||||
|
|
||||||
|
return ({
|
||||||
|
title,
|
||||||
|
locale,
|
||||||
|
theme: { primaryColor, fontFamily },
|
||||||
|
sections: (mappedSections as unknown) as Page['sections'],
|
||||||
|
form: formState,
|
||||||
|
analytics: { ga4MeasurementId, metaPixelId, customHeadHtml: '' },
|
||||||
|
seo: { title: seoTitle || title, description: (seoDescription && seoDescription.trim()) ? seoDescription : 'Landing page', ogImage: (ogImage && ogImage.trim()) ? ogImage : undefined },
|
||||||
|
} as unknown) as Page
|
||||||
|
}, [title, locale, primaryColor, fontFamily, sections, formState, selectedFileName, seoTitle, seoDescription, ga4MeasurementId, metaPixelId, ogImage])
|
||||||
|
|
||||||
|
// test-only: expose preview exporter for E2E to read exported HTML/CSS/JS directly
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const w = window as unknown as { __exportPreview?: () => { html: string; css: string; js: string } }
|
||||||
|
w.__exportPreview = () => {
|
||||||
|
try {
|
||||||
|
return exportPage(pageSchema.parse(pageData), { assetManager: am ?? undefined })
|
||||||
|
} catch {
|
||||||
|
const fixed: Page = {
|
||||||
|
...pageData,
|
||||||
|
form: {
|
||||||
|
actionUrl: pageData.form?.actionUrl ?? '',
|
||||||
|
fields: pageData.form?.fields ?? [],
|
||||||
|
spamProtection: pageData.form?.spamProtection ?? { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return exportPage(pageSchema.parse(fixed), { assetManager: am ?? undefined })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}, [pageData, am])
|
||||||
|
|
||||||
|
const doExport = useCallback(async () => {
|
||||||
|
const valid = pageSchema.parse(pageData)
|
||||||
|
const exported = exportPage(valid, { assetManager: am ?? undefined })
|
||||||
|
try {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const w = window as unknown as { __captureExport?: boolean; __lastExport?: { html: string; css: string; js: string } }
|
||||||
|
if (w.__captureExport) {
|
||||||
|
w.__lastExport = exported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
const assets = am ? am.toZipStructure() : {}
|
||||||
|
const zipBytes = await buildZip({ html: exported.html, css: exported.css, js: exported.js, assets })
|
||||||
|
download('landing.zip', zipBytes)
|
||||||
|
}, [pageData, am])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<h1 className="text-2xl font-semibold">{t('builder.title')}</h1>
|
||||||
|
|
||||||
|
<BuilderShell
|
||||||
|
left={
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SidebarComponents
|
||||||
|
onAddHero={addHero}
|
||||||
|
onAddFAQ={addFaq}
|
||||||
|
onAddCTA={addCta}
|
||||||
|
onAddFeatures={addFeatures}
|
||||||
|
onAddTestimonials={addTestimonials}
|
||||||
|
/>
|
||||||
|
<FormBuilderPanel store={formStore} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
center={
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium" htmlFor="builder-title">{t('builder.field.title')}</label>
|
||||||
|
<input id="builder-title" className="border rounded px-3 py-2 w-full" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm font-medium" htmlFor="builder-locale">{t('builder.field.locale')}</label>
|
||||||
|
<select id="builder-locale" className="border rounded px-3 py-2 w-full" value={locale} onChange={(e) => setLocale(e.target.value as 'en' | 'ko')}>
|
||||||
|
<option value="en">en</option>
|
||||||
|
<option value="ko">ko</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm font-medium" htmlFor="builder-primary-color">{t('builder.field.primaryColor')}</label>
|
||||||
|
<input id="builder-primary-color" type="color" className="border rounded px-2 py-2 w-full h-10" value={primaryColor} onChange={(e) => setPrimaryColor(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm font-medium" htmlFor="builder-font-family">{t('builder.field.fontFamily')}</label>
|
||||||
|
<input id="builder-font-family" className="border rounded px-3 py-2 w-full" value={fontFamily} onChange={(e) => setFontFamily(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium" htmlFor="builder-hero-file">{t('builder.upload.hero')}</label>
|
||||||
|
<input id="builder-hero-file" ref={fileInputRef} type="file" accept="image/*" className="block" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button data-testid="btn-add-hero" className="bg-sky-600 text-white rounded px-4 py-2" onClick={addHero}>{t('builder.action.addHero')}</button>
|
||||||
|
<button data-testid="btn-add-faq" className="bg-gray-200 rounded px-4 py-2" onClick={addFaq}>{t('builder.action.addFAQ')}</button>
|
||||||
|
<button data-testid="btn-add-cta" className="bg-gray-200 rounded px-4 py-2" onClick={addCta}>{t('builder.action.addCTA')}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium">{t('builder.sections')}</label>
|
||||||
|
<SectionCanvas
|
||||||
|
sections={sections}
|
||||||
|
onMove={moveSection}
|
||||||
|
onRemove={removeAt}
|
||||||
|
onSelect={(i) => setSelectedIndex(i)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
right={
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button className="bg-emerald-600 text-white rounded px-4 py-2 w-full" onClick={doExport}>{t('builder.action.export')}</button>
|
||||||
|
<div className="border rounded p-3 space-y-2">
|
||||||
|
<h2 className="text-sm font-semibold mb-1">SEO / Analytics</h2>
|
||||||
|
<label className="block" htmlFor="ins-seo-title">
|
||||||
|
<span className="sr-only">SEO Title</span>
|
||||||
|
<input
|
||||||
|
id="ins-seo-title"
|
||||||
|
aria-label="SEO Title"
|
||||||
|
aria-describedby="ins-seo-title-help"
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
placeholder="e.g. Product landing page"
|
||||||
|
value={seoTitle}
|
||||||
|
onChange={(e) => setSeoTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p id="ins-seo-title-help" className="mt-1 text-xs text-gray-500">Shown in browser title and social previews</p>
|
||||||
|
</label>
|
||||||
|
<label className="block" htmlFor="ins-seo-desc">
|
||||||
|
<span className="sr-only">SEO Description</span>
|
||||||
|
<input
|
||||||
|
id="ins-seo-desc"
|
||||||
|
aria-label="SEO Description"
|
||||||
|
aria-describedby="ins-seo-desc-help"
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
placeholder="Short description shown in search/OG"
|
||||||
|
value={seoDescription}
|
||||||
|
onChange={(e) => setSeoDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p id="ins-seo-desc-help" className="mt-1 text-xs text-gray-500">Used by search engines and OG description</p>
|
||||||
|
</label>
|
||||||
|
<label className="block" htmlFor="ins-og-image">
|
||||||
|
<span className="sr-only">OG Image URL</span>
|
||||||
|
<input
|
||||||
|
id="ins-og-image"
|
||||||
|
aria-label="OG Image URL"
|
||||||
|
aria-describedby="ins-og-image-help"
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
placeholder="https://.../og-image.png"
|
||||||
|
value={ogImage}
|
||||||
|
onChange={(e) => setOgImage(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p id="ins-og-image-help" className="mt-1 text-xs text-gray-500">Absolute URL recommended</p>
|
||||||
|
</label>
|
||||||
|
<label className="block" htmlFor="ins-ga4">
|
||||||
|
<span className="sr-only">GA4 Measurement ID</span>
|
||||||
|
<input
|
||||||
|
id="ins-ga4"
|
||||||
|
aria-label="GA4 Measurement ID"
|
||||||
|
aria-describedby="ins-ga4-help"
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
placeholder="G-XXXXXXXXXX"
|
||||||
|
value={ga4MeasurementId}
|
||||||
|
onChange={(e) => setGa4MeasurementId(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p id="ins-ga4-help" className="mt-1 text-xs text-gray-500">Format: G-XXXXXXXXXX</p>
|
||||||
|
</label>
|
||||||
|
<label className="block" htmlFor="ins-meta-pixel">
|
||||||
|
<span className="sr-only">Meta Pixel ID</span>
|
||||||
|
<input
|
||||||
|
id="ins-meta-pixel"
|
||||||
|
aria-label="Meta Pixel ID"
|
||||||
|
aria-describedby="ins-meta-pixel-help"
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
placeholder="1234567890"
|
||||||
|
value={metaPixelId}
|
||||||
|
onChange={(e) => setMetaPixelId(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p id="ins-meta-pixel-help" className="mt-1 text-xs text-gray-500">Numeric Page ID</p>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="border rounded p-3">
|
||||||
|
<h2 className="text-sm font-semibold mb-2">Inspector</h2>
|
||||||
|
{selectedIndex !== null && sections[selectedIndex] ? (
|
||||||
|
(() => {
|
||||||
|
const s = sections[selectedIndex]
|
||||||
|
if (s.type === 'hero') {
|
||||||
|
const heading = s.props?.heading ?? 'Welcome'
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block" htmlFor="ins-hero-heading">
|
||||||
|
<span className="sr-only">Hero Heading</span>
|
||||||
|
<input
|
||||||
|
id="ins-hero-heading"
|
||||||
|
aria-label="Hero Heading"
|
||||||
|
aria-describedby="ins-hero-heading-help"
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
value={heading}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value
|
||||||
|
setSections((prev) => prev.map((it, idx) => {
|
||||||
|
if (idx !== selectedIndex) return it
|
||||||
|
return { ...it, props: { ...(it.props || {}), heading: v } } as Section
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p id="ins-hero-heading-help" className="mt-1 text-xs text-gray-500">Main heading displayed prominently</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (s.type === 'cta') {
|
||||||
|
const text = s.props?.text ?? 'Get Started'
|
||||||
|
const href = s.props?.href ?? '#'
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block" htmlFor="ins-cta-text">
|
||||||
|
<span className="sr-only">CTA Text</span>
|
||||||
|
<input
|
||||||
|
id="ins-cta-text"
|
||||||
|
aria-label="CTA Text"
|
||||||
|
aria-describedby="ins-cta-text-help"
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value
|
||||||
|
setSections((prev) => prev.map((it, idx) => {
|
||||||
|
if (idx !== selectedIndex) return it
|
||||||
|
return { ...it, props: { ...(it.props || {}), text: v } } as Section
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p id="ins-cta-text-help" className="mt-1 text-xs text-gray-500">Button label and link for primary action</p>
|
||||||
|
<label className="block" htmlFor="ins-cta-href">
|
||||||
|
<span className="sr-only">CTA Href</span>
|
||||||
|
<input
|
||||||
|
id="ins-cta-href"
|
||||||
|
aria-label="CTA Href"
|
||||||
|
aria-describedby="ins-cta-href-help"
|
||||||
|
className="border rounded px-3 py-2 w-full"
|
||||||
|
value={href}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value
|
||||||
|
setSections((prev) => prev.map((it, idx) => {
|
||||||
|
if (idx !== selectedIndex) return it
|
||||||
|
return { ...it, props: { ...(it.props || {}), href: v } } as Section
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p id="ins-cta-href-help" className="mt-1 text-xs text-gray-500">Button label and link for primary action</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (s.type === 'faq') {
|
||||||
|
const items: Array<{ q: string; a: string }> = Array.isArray(s.props?.items) ? (s.props.items as Array<{ q: string; a: string }>) : []
|
||||||
|
const addItem = () => {
|
||||||
|
const nextItems = [...items, { q: '', a: '' }]
|
||||||
|
setSections((prev) => prev.map((it, idx) => {
|
||||||
|
if (idx !== selectedIndex) return it
|
||||||
|
return { ...it, props: { ...(it.props || {}), items: nextItems } } as Section
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
const updateItem = (i: number, patch: { q?: string; a?: string }) => {
|
||||||
|
const nextItems = items.map((it: { q: string; a: string }, idx: number) => (idx === i ? { ...it, ...patch } : it))
|
||||||
|
setSections((prev) => prev.map((it, idx) => {
|
||||||
|
if (idx !== selectedIndex) return it
|
||||||
|
return { ...it, props: { ...(it.props || {}), items: nextItems } } as Section
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
const removeItem = (i: number) => {
|
||||||
|
const nextItems = items.filter((_: { q: string; a: string }, idx: number) => idx !== i)
|
||||||
|
setSections((prev) => prev.map((it, idx) => {
|
||||||
|
if (idx !== selectedIndex) return it
|
||||||
|
return { ...it, props: { ...(it.props || {}), items: nextItems } } as Section
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button type="button" className="px-2 py-1 border rounded text-sm" title="Add FAQ Item" onClick={addItem}>Add FAQ Item</button>
|
||||||
|
<p id="ins-faq-help" className="text-xs text-gray-500">Add at least one FAQ item with Q and A</p>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{items.map((it: { q: string; a: string }, i: number) => (
|
||||||
|
<li key={i} className="flex items-start gap-2">
|
||||||
|
<div className="flex-1 grid grid-cols-2 gap-2">
|
||||||
|
<label className="block" htmlFor={`ins-faq-q-${i}`}>
|
||||||
|
<span className="sr-only">FAQ Q {i}</span>
|
||||||
|
<input
|
||||||
|
id={`ins-faq-q-${i}`}
|
||||||
|
aria-label={`FAQ Q ${i}`}
|
||||||
|
aria-describedby="ins-faq-help"
|
||||||
|
className="border rounded px-2 py-1 w-full"
|
||||||
|
value={it.q ?? ''}
|
||||||
|
onChange={(e) => updateItem(i, { q: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block" htmlFor={`ins-faq-a-${i}`}>
|
||||||
|
<span className="sr-only">FAQ A {i}</span>
|
||||||
|
<input
|
||||||
|
id={`ins-faq-a-${i}`}
|
||||||
|
aria-label={`FAQ A ${i}`}
|
||||||
|
aria-describedby="ins-faq-help"
|
||||||
|
className="border rounded px-2 py-1 w-full"
|
||||||
|
value={it.a ?? ''}
|
||||||
|
onChange={(e) => updateItem(i, { a: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="button" aria-label={`Remove FAQ ${i}`} className="px-2 py-1 border rounded text-sm text-red-600" onClick={() => removeItem(i)}>Remove</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (s.type === 'features') {
|
||||||
|
const items: string[] = Array.isArray(s.props?.items) ? (s.props.items as string[]) : []
|
||||||
|
const addItem = () => {
|
||||||
|
const nextItems = [...items, '']
|
||||||
|
setSections((prev) => prev.map((it, idx) => (idx === selectedIndex ? ({ ...it, props: { ...(it.props || {}), items: nextItems } } as Section) : it)))
|
||||||
|
}
|
||||||
|
const updateItem = (i: number, v: string) => {
|
||||||
|
const nextItems = items.map((it, idx) => (idx === i ? v : it))
|
||||||
|
setSections((prev) => prev.map((it, idx) => (idx === selectedIndex ? ({ ...it, props: { ...(it.props || {}), items: nextItems } } as Section) : it)))
|
||||||
|
}
|
||||||
|
const removeItem = (i: number) => {
|
||||||
|
const nextItems = items.filter((_, idx) => idx !== i)
|
||||||
|
setSections((prev) => prev.map((it, idx) => (idx === selectedIndex ? ({ ...it, props: { ...(it.props || {}), items: nextItems } } as Section) : it)))
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button type="button" className="px-2 py-1 border rounded text-sm" title="Add Feature Item" onClick={addItem}>Add Feature Item</button>
|
||||||
|
<p id="ins-features-help" className="text-xs text-gray-500">Describe a single feature per line</p>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{items.map((val, i) => (
|
||||||
|
<li key={i} className="flex items-center gap-2">
|
||||||
|
<label className="block flex-1" htmlFor={`ins-feature-${i}`}>
|
||||||
|
<span className="sr-only">Feature {i}</span>
|
||||||
|
<input
|
||||||
|
id={`ins-feature-${i}`}
|
||||||
|
aria-label={`Feature ${i}`}
|
||||||
|
aria-describedby="ins-features-help"
|
||||||
|
className="border rounded px-2 py-1 w-full"
|
||||||
|
value={val}
|
||||||
|
onChange={(e) => updateItem(i, e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Remove Feature ${i}`}
|
||||||
|
title={`Remove Feature ${i}`}
|
||||||
|
className="px-2 py-1 border rounded text-sm text-red-600"
|
||||||
|
onClick={() => removeItem(i)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') removeItem(i)
|
||||||
|
}}
|
||||||
|
onKeyUp={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') removeItem(i)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (s.type === 'testimonials') {
|
||||||
|
const items: Array<{ author: string; quote: string }> = Array.isArray(s.props?.items)
|
||||||
|
? (s.props.items as Array<{ author: string; quote: string }>)
|
||||||
|
: []
|
||||||
|
const addItem = () => {
|
||||||
|
const nextItems = [...items, { author: '', quote: '' }]
|
||||||
|
setSections((prev) => prev.map((it, idx) => (idx === selectedIndex ? ({ ...it, props: { ...(it.props || {}), items: nextItems } } as Section) : it)))
|
||||||
|
}
|
||||||
|
const updateItem = (i: number, patch: Partial<{ author: string; quote: string }>) => {
|
||||||
|
const nextItems = items.map((it, idx) => (idx === i ? { ...it, ...patch } : it))
|
||||||
|
setSections((prev) => prev.map((it, idx) => (idx === selectedIndex ? ({ ...it, props: { ...(it.props || {}), items: nextItems } } as Section) : it)))
|
||||||
|
}
|
||||||
|
const removeItem = (i: number) => {
|
||||||
|
const nextItems = items.filter((_, idx) => idx !== i)
|
||||||
|
setSections((prev) => prev.map((it, idx) => (idx === selectedIndex ? ({ ...it, props: { ...(it.props || {}), items: nextItems } } as Section) : it)))
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button type="button" className="px-2 py-1 border rounded text-sm" title="Add Testimonial" onClick={addItem}>Add Testimonial</button>
|
||||||
|
<p id="ins-testimonials-help" className="text-xs text-gray-500">Add author and quote for each testimonial</p>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{items.map((it, i) => (
|
||||||
|
<li key={i} className="grid grid-cols-2 gap-2 items-start">
|
||||||
|
<label className="block" htmlFor={`ins-author-${i}`}>
|
||||||
|
<span className="sr-only">Author {i}</span>
|
||||||
|
<input
|
||||||
|
id={`ins-author-${i}`}
|
||||||
|
aria-label={`Author ${i}`}
|
||||||
|
aria-describedby="ins-testimonials-help"
|
||||||
|
className="border rounded px-2 py-1 w-full"
|
||||||
|
value={it.author}
|
||||||
|
onChange={(e) => updateItem(i, { author: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block" htmlFor={`ins-quote-${i}`}>
|
||||||
|
<span className="sr-only">Quote {i}</span>
|
||||||
|
<input
|
||||||
|
id={`ins-quote-${i}`}
|
||||||
|
aria-label={`Quote ${i}`}
|
||||||
|
aria-describedby="ins-testimonials-help"
|
||||||
|
className="border rounded px-2 py-1 w-full"
|
||||||
|
value={it.quote}
|
||||||
|
onChange={(e) => updateItem(i, { quote: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Remove Testimonial ${i}`}
|
||||||
|
title={`Remove Testimonial ${i}`}
|
||||||
|
className="px-2 py-1 border rounded text-sm text-red-600"
|
||||||
|
onClick={() => removeItem(i)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') removeItem(i)
|
||||||
|
}}
|
||||||
|
onKeyUp={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') removeItem(i)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <div className="text-xs text-gray-500">No editable fields</div>
|
||||||
|
})()
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-gray-500">Nothing selected</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="border rounded p-3">
|
||||||
|
<h2 className="text-sm font-semibold mb-2">{t('builder.preview')}</h2>
|
||||||
|
<p id="preview-toggle-help" className="sr-only">Toggle preview viewport for the builder preview area</p>
|
||||||
|
<div className="mb-2 flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Desktop"
|
||||||
|
aria-pressed={viewport === 'desktop'}
|
||||||
|
aria-controls="preview-container"
|
||||||
|
aria-describedby="preview-toggle-help"
|
||||||
|
className={`rounded px-2 py-1 text-sm transition-colors border focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-600 ${
|
||||||
|
viewport === 'desktop'
|
||||||
|
? 'bg-sky-600 text-white border-sky-600'
|
||||||
|
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
onClick={() => setViewport('desktop')}
|
||||||
|
>
|
||||||
|
Desktop
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Mobile"
|
||||||
|
aria-pressed={viewport === 'mobile'}
|
||||||
|
aria-controls="preview-container"
|
||||||
|
aria-describedby="preview-toggle-help"
|
||||||
|
className={`rounded px-2 py-1 text-sm transition-colors border focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-600 ${
|
||||||
|
viewport === 'mobile'
|
||||||
|
? 'bg-sky-600 text-white border-sky-600'
|
||||||
|
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
onClick={() => setViewport('mobile')}
|
||||||
|
>
|
||||||
|
Mobile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="prose max-w-none">
|
||||||
|
<div
|
||||||
|
data-testid="preview-container"
|
||||||
|
className={`border rounded p-3 ${viewport === 'desktop' ? 'w-[768px]' : 'w-[375px]'} mx-auto`}
|
||||||
|
>
|
||||||
|
<div className="mb-2"><strong>Title:</strong> {pageData.title}</div>
|
||||||
|
<div className="mb-2"><strong>Locale:</strong> {pageData.locale}</div>
|
||||||
|
<div className="mb-2"><strong>SEO Title:</strong> <span data-testid="preview-seo-title">{pageData.seo.title}</span></div>
|
||||||
|
<div className="mb-2"><strong>SEO Description:</strong> <span data-testid="preview-seo-description">{pageData.seo.description}</span></div>
|
||||||
|
<div className="mb-2"><strong>OG Image:</strong> <span data-testid="preview-og-image">{pageData.seo.ogImage || ''}</span></div>
|
||||||
|
<div className="mb-2"><strong>GA4:</strong> <span data-testid="preview-ga4">{pageData.analytics?.ga4MeasurementId || ''}</span></div>
|
||||||
|
<div className="mb-2"><strong>Meta Pixel:</strong> <span data-testid="preview-meta-pixel">{pageData.analytics?.metaPixelId || ''}</span></div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sections.map((s: Section, i: number) => {
|
||||||
|
switch (s.type) {
|
||||||
|
case 'hero':
|
||||||
|
return (
|
||||||
|
<div key={i} className="text-sm text-gray-700">• hero — heading: {s.props?.heading || 'Welcome'}</div>
|
||||||
|
)
|
||||||
|
case 'faq': {
|
||||||
|
const count = s.props.items?.length || 0
|
||||||
|
return (
|
||||||
|
<div key={i} className="text-sm text-gray-700">• faq — items: {count}</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'cta': {
|
||||||
|
const text = s.props.text || 'CTA'
|
||||||
|
const href = s.props.href || '#'
|
||||||
|
return (
|
||||||
|
<div key={i} className="text-sm text-gray-700">• cta — {text} → {href}</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'features': {
|
||||||
|
const count = s.props.items?.length || 0
|
||||||
|
return (
|
||||||
|
<div key={i} className="text-sm text-gray-700">• features — items: {count}</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'testimonials': {
|
||||||
|
const count = s.props.items?.length || 0
|
||||||
|
return (
|
||||||
|
<div key={i} className="text-sm text-gray-700">• testimonials — items: {count}</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<ul className="list-disc pl-5 mt-2">
|
||||||
|
{formState.fields.map((f, idx) => (
|
||||||
|
<li key={`${f.name}-${idx}`} className="text-sm">
|
||||||
|
{f.label} ({f.name}) {f.required ? 'required' : ''}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import React from 'react'
|
||||||
|
import BuilderShell from '@/components/BuilderShell'
|
||||||
|
|
||||||
|
describe('BuilderShell', () => {
|
||||||
|
it('좌/중/우 3패널과 접근성 라벨을 렌더링한다', () => {
|
||||||
|
render(
|
||||||
|
<BuilderShell left={<div>LEFT</div>} center={<div>CENTER</div>} right={<div>RIGHT</div>} />
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('builder-shell')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Components Panel')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Canvas')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Inspector')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export default function BuilderShell({
|
||||||
|
left,
|
||||||
|
center,
|
||||||
|
right,
|
||||||
|
}: {
|
||||||
|
left: React.ReactNode
|
||||||
|
center: React.ReactNode
|
||||||
|
right: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full grid grid-cols-1 md:grid-cols-[280px_1fr_320px] gap-4 p-4" data-testid="builder-shell">
|
||||||
|
<aside className="border rounded p-3 min-h-[200px]" aria-label="Components Panel" data-testid="panel-left">
|
||||||
|
{left}
|
||||||
|
</aside>
|
||||||
|
<main className="border rounded p-3 min-h-[200px]" aria-label="Canvas" data-testid="panel-center">
|
||||||
|
{center}
|
||||||
|
</main>
|
||||||
|
<aside className="border rounded p-3 min-h-[200px]" aria-label="Inspector" data-testid="panel-right">
|
||||||
|
{right}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
|
import { expect, test } from 'vitest'
|
||||||
|
import { axe, toHaveNoViolations } from 'jest-axe'
|
||||||
|
import FormBuilderPanel from './FormBuilderPanel'
|
||||||
|
import { createFormStore } from '@/lib/state/formStore'
|
||||||
|
|
||||||
|
expect.extend(toHaveNoViolations)
|
||||||
|
import messages from '../messages/en.json'
|
||||||
|
|
||||||
|
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<NextIntlClientProvider messages={messages} locale="en">
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('FormBuilderPanel has no obvious a11y violations', async () => {
|
||||||
|
const store = createFormStore()
|
||||||
|
const { container } = render(
|
||||||
|
<Wrapper>
|
||||||
|
<FormBuilderPanel store={store} />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
const results = await axe(container)
|
||||||
|
expect(results).toHaveNoViolations()
|
||||||
|
})
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import React from 'react'
|
||||||
|
import FormBuilderPanel from '@/components/FormBuilderPanel'
|
||||||
|
import { createFormStore } from '@/lib/state/formStore'
|
||||||
|
import type { SelectField, RadioField } from '@/lib/state/formStore'
|
||||||
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
|
import enMessages from '@/messages/en.json'
|
||||||
|
const messages: Record<string, string> = enMessages as Record<string, string>
|
||||||
|
|
||||||
|
describe('FormBuilderPanel', () => {
|
||||||
|
it('필드 추가와 action URL/스팸 옵션 변경을 수행한다', () => {
|
||||||
|
const store = createFormStore()
|
||||||
|
render(
|
||||||
|
<NextIntlClientProvider locale="en" messages={messages}>
|
||||||
|
<FormBuilderPanel store={store} />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 초기 상태
|
||||||
|
expect(screen.queryAllByTestId('field-item').length).toBe(0)
|
||||||
|
|
||||||
|
// 필드 추가 2개 (번역 키 이슈 회피: 첫 번째 버튼 클릭)
|
||||||
|
fireEvent.click(screen.getAllByRole('button')[0])
|
||||||
|
fireEvent.click(screen.getAllByRole('button')[0])
|
||||||
|
expect(screen.queryAllByTestId('field-item').length).toBe(2)
|
||||||
|
|
||||||
|
// action URL 입력
|
||||||
|
const actionInput = screen.getByLabelText('Action URL') as HTMLInputElement
|
||||||
|
fireEvent.change(actionInput, { target: { value: 'https://example.com/form' } })
|
||||||
|
expect(actionInput.value).toBe('https://example.com/form')
|
||||||
|
expect(store.getState().actionUrl).toBe('https://example.com/form')
|
||||||
|
|
||||||
|
// 스팸 옵션 - honeypot 변경
|
||||||
|
const hpInput = screen.getByLabelText('Honeypot Field') as HTMLInputElement
|
||||||
|
fireEvent.change(hpInput, { target: { value: '_trap' } })
|
||||||
|
expect(store.getState().spam.honeypotFieldName).toBe('_trap')
|
||||||
|
|
||||||
|
// 스팸 옵션 - min seconds 변경
|
||||||
|
const tsInput = screen.getByLabelText('Min Submit Seconds') as HTMLInputElement
|
||||||
|
fireEvent.change(tsInput, { target: { value: '5' } })
|
||||||
|
expect(store.getState().spam.minSubmitSeconds).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pattern과 maxLength 편집이 상태에 반영된다', () => {
|
||||||
|
const store = createFormStore()
|
||||||
|
render(
|
||||||
|
<NextIntlClientProvider locale="en" messages={messages}>
|
||||||
|
<FormBuilderPanel store={store} />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 텍스트 필드 1개 추가
|
||||||
|
fireEvent.click(screen.getAllByRole('button')[0])
|
||||||
|
|
||||||
|
const pat0 = screen.getByLabelText('Pattern 0') as HTMLInputElement
|
||||||
|
fireEvent.change(pat0, { target: { value: '^[A-Za-z ]+$' } })
|
||||||
|
expect(store.getState().fields[0].pattern).toBe('^[A-Za-z ]+$')
|
||||||
|
|
||||||
|
const max0 = screen.getByLabelText('MaxLength 0') as HTMLInputElement
|
||||||
|
fireEvent.change(max0, { target: { value: '50' } })
|
||||||
|
expect(store.getState().fields[0].maxLength).toBe(50)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('select/radio 옵션 편집이 동작하고 추가 버튼으로 타입별 필드를 만든다', () => {
|
||||||
|
const store = createFormStore()
|
||||||
|
render(
|
||||||
|
<NextIntlClientProvider locale="en" messages={messages}>
|
||||||
|
<FormBuilderPanel store={store} />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
// select 추가 후 옵션 편집 (버튼 인덱스로 접근)
|
||||||
|
const buttons1 = screen.getAllByRole('button')
|
||||||
|
// 버튼 순서: Text, Email, Tel, Date, Textarea, Checkbox, Select, Radio
|
||||||
|
fireEvent.click(buttons1[6])
|
||||||
|
const opts0 = screen.getByLabelText('Options 0') as HTMLInputElement
|
||||||
|
fireEvent.change(opts0, { target: { value: 'A,B,C' } })
|
||||||
|
expect(store.getState().fields[0].type).toBe('select')
|
||||||
|
expect((store.getState().fields[0] as SelectField).options).toEqual(['A','B','C'])
|
||||||
|
|
||||||
|
// radio 추가 후 옵션 편집
|
||||||
|
const buttons2 = screen.getAllByRole('button')
|
||||||
|
fireEvent.click(buttons2[7])
|
||||||
|
const opts1 = screen.getByLabelText('Options 1') as HTMLInputElement
|
||||||
|
fireEvent.change(opts1, { target: { value: 'S,M,L' } })
|
||||||
|
expect(store.getState().fields[1].type).toBe('radio')
|
||||||
|
expect((store.getState().fields[1] as RadioField).options).toEqual(['S','M','L'])
|
||||||
|
|
||||||
|
// email/checkbox/textarea/date/tel도 추가 버튼 존재 (인덱스 기반 클릭)
|
||||||
|
const buttons3 = screen.getAllByRole('button')
|
||||||
|
fireEvent.click(buttons3[1]) // Email
|
||||||
|
fireEvent.click(buttons3[5]) // Checkbox
|
||||||
|
fireEvent.click(buttons3[4]) // Textarea
|
||||||
|
fireEvent.click(buttons3[3]) // Date
|
||||||
|
fireEvent.click(buttons3[2]) // Tel
|
||||||
|
|
||||||
|
const names = store.getState().fields.map((f) => f.type)
|
||||||
|
expect(names).toContain('email')
|
||||||
|
expect(names).toContain('checkbox')
|
||||||
|
expect(names).toContain('textarea')
|
||||||
|
expect(names).toContain('date')
|
||||||
|
expect(names).toContain('tel')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('필드 레이블/필수 편집, 이동/삭제가 동작한다', () => {
|
||||||
|
const store = createFormStore()
|
||||||
|
render(
|
||||||
|
<NextIntlClientProvider locale="en" messages={messages}>
|
||||||
|
<FormBuilderPanel store={store} />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 3개 추가
|
||||||
|
fireEvent.click(screen.getAllByRole('button')[0])
|
||||||
|
fireEvent.click(screen.getAllByRole('button')[0])
|
||||||
|
fireEvent.click(screen.getAllByRole('button')[0])
|
||||||
|
expect(screen.queryAllByTestId('field-item').length).toBe(3)
|
||||||
|
|
||||||
|
// 첫 필드 레이블 변경
|
||||||
|
const label0 = screen.getByLabelText('Label 0') as HTMLInputElement
|
||||||
|
fireEvent.change(label0, { target: { value: 'Name' } })
|
||||||
|
expect(store.getState().fields[0].label).toBe('Name')
|
||||||
|
|
||||||
|
// 첫 필드 required 토글
|
||||||
|
const req0 = screen.getByLabelText('Required 0') as HTMLInputElement
|
||||||
|
fireEvent.click(req0)
|
||||||
|
expect(store.getState().fields[0].required).toBe(true)
|
||||||
|
|
||||||
|
// 첫 필드를 아래로 이동 (0 -> 1)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Down 0' }))
|
||||||
|
expect(store.getState().fields.map(f => f.name)).toEqual(['field2','field1','field3'])
|
||||||
|
|
||||||
|
// 두 번째(이전 첫) 필드 삭제
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Remove 1' }))
|
||||||
|
expect(store.getState().fields.map(f => f.name)).toEqual(['field2','field3'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import React, { useCallback, useSyncExternalStore } from 'react'
|
||||||
|
import type { FormState, FormField } from '@/lib/state/formStore'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
|
||||||
|
type Store = {
|
||||||
|
getState: () => FormState
|
||||||
|
subscribe: (listener: () => void) => () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FormBuilderPanel({ store }: { store: Store }) {
|
||||||
|
const t = useTranslations()
|
||||||
|
const state = useSyncExternalStore(store.subscribe, store.getState, store.getState)
|
||||||
|
|
||||||
|
const onAddText = useCallback(() => {
|
||||||
|
const idx = state.fields.length + 1
|
||||||
|
const name = `field${idx}`
|
||||||
|
store.getState().addField({ type: 'text', name, label: `Text ${idx}`, required: false })
|
||||||
|
}, [state.fields.length, store])
|
||||||
|
|
||||||
|
const nextName = () => `field${state.fields.length + 1}`
|
||||||
|
const onAddEmail = () => store.getState().addField({ type: 'email', name: nextName(), label: 'Email', required: false })
|
||||||
|
const onAddTel = () => store.getState().addField({ type: 'tel', name: nextName(), label: 'Tel', required: false })
|
||||||
|
const onAddDate = () => store.getState().addField({ type: 'date', name: nextName(), label: 'Date', required: false })
|
||||||
|
const onAddTextarea = () => store.getState().addField({ type: 'textarea', name: nextName(), label: 'Message', required: false })
|
||||||
|
const onAddCheckbox = () => store.getState().addField({ type: 'checkbox', name: nextName(), label: 'Agree', required: false })
|
||||||
|
const onAddSelect = () => store.getState().addField({ type: 'select', name: nextName(), label: 'Select', required: false, options: [] })
|
||||||
|
const onAddRadio = () => store.getState().addField({ type: 'radio', name: nextName(), label: 'Radio', required: false, options: [] })
|
||||||
|
|
||||||
|
const onChangeAction = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
store.getState().setActionUrl(e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onChangeHp = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
store.getState().setSpam({ honeypotFieldName: e.target.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
const onChangeTs = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const v = parseInt(e.target.value || '0', 10)
|
||||||
|
store.getState().setSpam({ minSubmitSeconds: Number.isNaN(v) ? 0 : v })
|
||||||
|
}
|
||||||
|
|
||||||
|
const onChangeLabel = (i: number, e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
store.getState().updateField(i, { label: e.target.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
const onToggleRequired = (i: number) => {
|
||||||
|
const cur = store.getState().fields[i]?.required ?? false
|
||||||
|
store.getState().updateField(i, { required: !cur })
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDown = (i: number) => {
|
||||||
|
if (i < state.fields.length - 1) {
|
||||||
|
store.getState().moveField(i, i + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onRemove = (i: number) => {
|
||||||
|
store.getState().removeField(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button type="button" data-testid="btn-add-text" onClick={onAddText} className="px-2 py-1 border rounded">{t('builder.action.addText')}</button>
|
||||||
|
<button type="button" onClick={onAddEmail} className="px-2 py-1 border rounded">{t('builder.action.addEmail')}</button>
|
||||||
|
<button type="button" onClick={onAddTel} className="px-2 py-1 border rounded">{t('builder.action.addTel')}</button>
|
||||||
|
<button type="button" onClick={onAddDate} className="px-2 py-1 border rounded">{t('builder.action.addDate')}</button>
|
||||||
|
<button type="button" onClick={onAddTextarea} className="px-2 py-1 border rounded">{t('builder.action.addTextarea')}</button>
|
||||||
|
<button type="button" onClick={onAddCheckbox} className="px-2 py-1 border rounded">{t('builder.action.addCheckbox')}</button>
|
||||||
|
<button type="button" onClick={onAddSelect} className="px-2 py-1 border rounded">{t('builder.action.addSelect')}</button>
|
||||||
|
<button type="button" onClick={onAddRadio} className="px-2 py-1 border rounded">{t('builder.action.addRadio')}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block" htmlFor="form-action-url">
|
||||||
|
<span className="block text-sm">{t('builder.form.actionUrl')}</span>
|
||||||
|
<input id="form-action-url" aria-label="Action URL" className="border px-2 py-1 w-full" value={state.actionUrl}
|
||||||
|
onChange={onChangeAction} />
|
||||||
|
</label>
|
||||||
|
<label className="block" htmlFor="form-honeypot">
|
||||||
|
<span className="block text-sm">{t('builder.form.honeypot')}</span>
|
||||||
|
<input id="form-honeypot" aria-label="Honeypot Field" className="border px-2 py-1 w-full" value={state.spam.honeypotFieldName}
|
||||||
|
onChange={onChangeHp} />
|
||||||
|
</label>
|
||||||
|
<label className="block" htmlFor="form-min-seconds">
|
||||||
|
<span className="block text-sm">{t('builder.form.minSeconds')}</span>
|
||||||
|
<input id="form-min-seconds" aria-label="Min Submit Seconds" type="number" className="border px-2 py-1 w-full" value={state.spam.minSubmitSeconds}
|
||||||
|
onChange={onChangeTs} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{state.fields.map((f: FormField, i: number) => (
|
||||||
|
<li key={`${f.name}-${i}`} data-testid="field-item" className="text-sm border rounded p-2 flex items-center gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-xs text-gray-500">{f.type} - {f.name}</div>
|
||||||
|
<label className="block" htmlFor={`field-label-${i}`}>
|
||||||
|
<span className="sr-only">Label {i}</span>
|
||||||
|
<input id={`field-label-${i}`} aria-label={`Label ${i}`} className="border px-2 py-1 w-full" value={f.label}
|
||||||
|
onChange={(e) => onChangeLabel(i, e)} />
|
||||||
|
</label>
|
||||||
|
<label className="inline-flex items-center gap-2 mt-1" htmlFor={`field-required-${i}`}>
|
||||||
|
<input id={`field-required-${i}`} aria-label={`Required ${i}`} type="checkbox" checked={!!f.required}
|
||||||
|
onChange={() => onToggleRequired(i)} />
|
||||||
|
<span className="text-xs">required</span>
|
||||||
|
</label>
|
||||||
|
{(f.type === 'text' || f.type === 'email' || f.type === 'tel' || f.type === 'date' || f.type === 'textarea') && (
|
||||||
|
<div className="mt-1 grid grid-cols-2 gap-2">
|
||||||
|
<label className="block" htmlFor={`field-pattern-${i}`}>
|
||||||
|
<span className="sr-only">Pattern {i}</span>
|
||||||
|
<input
|
||||||
|
id={`field-pattern-${i}`}
|
||||||
|
aria-label={`Pattern ${i}`}
|
||||||
|
className="border px-2 py-1 w-full"
|
||||||
|
value={f.pattern ?? ''}
|
||||||
|
onChange={(e) => store.getState().updateField(i, { pattern: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block" htmlFor={`field-maxlen-${i}`}>
|
||||||
|
<span className="sr-only">MaxLength {i}</span>
|
||||||
|
<input
|
||||||
|
id={`field-maxlen-${i}`}
|
||||||
|
aria-label={`MaxLength ${i}`}
|
||||||
|
type="number"
|
||||||
|
className="border px-2 py-1 w-full"
|
||||||
|
value={f.maxLength ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = parseInt(e.target.value || '0', 10)
|
||||||
|
store.getState().updateField(i, { maxLength: Number.isNaN(v) ? undefined : v })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(f.type === 'select' || f.type === 'radio') && (
|
||||||
|
<label className="block mt-1" htmlFor={`field-options-${i}`}>
|
||||||
|
<span className="sr-only">Options {i}</span>
|
||||||
|
<input id={`field-options-${i}`} aria-label={`Options ${i}`} className="border px-2 py-1 w-full"
|
||||||
|
value={(f.type === 'select' || f.type === 'radio') ? f.options.join(',') : ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const opts = e.target.value.split(',').map((s) => s.trim()).filter(Boolean)
|
||||||
|
if (f.type === 'select' || f.type === 'radio') {
|
||||||
|
store.getState().updateField(i, { options: opts } as Partial<FormField>)
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button type="button" aria-label={`Down ${i}`} onClick={() => onDown(i)} className="px-2 py-1 border rounded">Down {i}</button>
|
||||||
|
<button type="button" aria-label={`Remove ${i}`} onClick={() => onRemove(i)} className="px-2 py-1 border rounded text-red-600">Remove {i}</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export default function I18nProvider({
|
||||||
|
locale,
|
||||||
|
messages,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
locale: string
|
||||||
|
messages: Record<string, string>
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
// next-intl: Provide a default timeZone to avoid ENVIRONMENT_FALLBACK mismatches
|
||||||
|
<NextIntlClientProvider locale={locale} messages={messages} timeZone="Asia/Seoul">
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import '@testing-library/jest-dom'
|
||||||
|
import React from 'react'
|
||||||
|
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||||
|
import I18nProvider from '@/components/I18nProvider'
|
||||||
|
import en from '@/messages/en.json'
|
||||||
|
import { assertDescribedBy } from '@/components/testUtils/inspectorA11y'
|
||||||
|
|
||||||
|
describe('Inspector a11y sections template - Hero/CTA/FAQ describedby wiring', () => {
|
||||||
|
it('Hero, CTA, FAQ inspector inputs are wired to helptexts', () => {
|
||||||
|
render(
|
||||||
|
<I18nProvider locale="en" messages={en as unknown as Record<string, string>}>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</I18nProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add hero to enable inspector
|
||||||
|
const addHero = screen.getByRole('button', { name: 'Add Hero' })
|
||||||
|
fireEvent.click(addHero)
|
||||||
|
|
||||||
|
// Select first section (Hero)
|
||||||
|
const selectButtons = screen.getAllByRole('button', { name: /Select \d+/ })
|
||||||
|
fireEvent.click(selectButtons[0])
|
||||||
|
|
||||||
|
const heroHeading = screen.getByLabelText('Hero Heading')
|
||||||
|
assertDescribedBy(heroHeading, 'ins-hero-heading-help')
|
||||||
|
|
||||||
|
// Replace with CTA and test describedby
|
||||||
|
const addCta = screen.getByRole('button', { name: 'Add CTA' })
|
||||||
|
fireEvent.click(addCta)
|
||||||
|
const selectSecond = screen.getAllByRole('button', { name: /Select \d+/ })[1]
|
||||||
|
fireEvent.click(selectSecond)
|
||||||
|
|
||||||
|
const ctaText = screen.getByLabelText('CTA Text')
|
||||||
|
assertDescribedBy(ctaText, 'ins-cta-text-help')
|
||||||
|
const ctaHref = screen.getByLabelText('CTA Href')
|
||||||
|
assertDescribedBy(ctaHref, 'ins-cta-href-help')
|
||||||
|
|
||||||
|
// Add FAQ and test using shared help id
|
||||||
|
const addFaq = screen.getByRole('button', { name: 'Add FAQ' })
|
||||||
|
fireEvent.click(addFaq)
|
||||||
|
const selectThird = screen.getAllByRole('button', { name: /Select \d+/ })[2]
|
||||||
|
fireEvent.click(selectThird)
|
||||||
|
// Add one FAQ item to render inputs
|
||||||
|
const addFaqItem = screen.getByRole('button', { name: 'Add FAQ Item' })
|
||||||
|
fireEvent.click(addFaqItem)
|
||||||
|
|
||||||
|
const faqQ = screen.getByLabelText('FAQ Q 0')
|
||||||
|
const faqA = screen.getByLabelText('FAQ A 0')
|
||||||
|
assertDescribedBy(faqQ, 'ins-faq-help')
|
||||||
|
assertDescribedBy(faqA, 'ins-faq-help')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import '@testing-library/jest-dom'
|
||||||
|
import React from 'react'
|
||||||
|
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||||
|
import I18nProvider from '@/components/I18nProvider'
|
||||||
|
import en from '@/messages/en.json'
|
||||||
|
import { assertDescribedBy } from '@/components/testUtils/inspectorA11y'
|
||||||
|
|
||||||
|
describe('Inspector a11y template - describedby wiring', () => {
|
||||||
|
it('SEO/Analytics inputs are wired to their helptexts via aria-describedby', () => {
|
||||||
|
render(
|
||||||
|
<I18nProvider locale="en" messages={en as unknown as Record<string, string>}>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</I18nProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
const seoTitle = screen.getByLabelText('SEO Title')
|
||||||
|
const seoDesc = screen.getByLabelText('SEO Description')
|
||||||
|
const ogImage = screen.getByLabelText('OG Image URL')
|
||||||
|
const ga4 = screen.getByLabelText('GA4 Measurement ID')
|
||||||
|
const meta = screen.getByLabelText('Meta Pixel ID')
|
||||||
|
|
||||||
|
assertDescribedBy(seoTitle, 'ins-seo-title-help')
|
||||||
|
assertDescribedBy(seoDesc, 'ins-seo-desc-help')
|
||||||
|
assertDescribedBy(ogImage, 'ins-og-image-help')
|
||||||
|
assertDescribedBy(ga4, 'ins-ga4-help')
|
||||||
|
assertDescribedBy(meta, 'ins-meta-pixel-help')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { describe, test, expect } from 'vitest'
|
||||||
|
import SectionCanvas from './SectionCanvas'
|
||||||
|
import type { Section } from '@/lib/state/store'
|
||||||
|
|
||||||
|
describe('SectionCanvas a11y', () => {
|
||||||
|
test('renders dnd-kit live region and instructions', () => {
|
||||||
|
const sections: Section[] = []
|
||||||
|
render(
|
||||||
|
<SectionCanvas
|
||||||
|
sections={sections}
|
||||||
|
onMove={() => {}}
|
||||||
|
onRemove={() => {}}
|
||||||
|
onSelect={() => {}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
// dnd-kit screen reader instructions should be present in the DOM
|
||||||
|
expect(screen.getByText(/To pick up a draggable item/i)).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Live region for announcements should exist
|
||||||
|
const liveRegion = screen.getByRole('status')
|
||||||
|
expect(liveRegion).toHaveAttribute('aria-live', 'assertive')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import { render, screen, within } from '@testing-library/react'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import SectionCanvas from './SectionCanvas'
|
||||||
|
import type { Section } from '@/lib/state/store'
|
||||||
|
|
||||||
|
function Wrapper({ withSelect = true }: { withSelect?: boolean }) {
|
||||||
|
const [sections, setSections] = useState<Section[]>([
|
||||||
|
{ type: 'hero', props: { heading: 'H', subheading: '' } },
|
||||||
|
{ type: 'faq', props: { items: [] } },
|
||||||
|
{ type: 'cta', props: { text: 'Go', href: '#' } },
|
||||||
|
])
|
||||||
|
const onMove = (from: number, to: number) => {
|
||||||
|
setSections((prev) => {
|
||||||
|
const next = prev.slice()
|
||||||
|
const [sp] = next.splice(from, 1)
|
||||||
|
next.splice(to, 0, sp)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const onRemove = () => {}
|
||||||
|
const onSelect = withSelect ? () => {} : undefined
|
||||||
|
return <SectionCanvas sections={sections} onMove={onMove} onRemove={onRemove} onSelect={onSelect} />
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SectionCanvas aria-labels on control buttons', () => {
|
||||||
|
test('Select/Up/Down/Remove buttons expose index-based aria-labels', () => {
|
||||||
|
render(<Wrapper />)
|
||||||
|
|
||||||
|
const list = screen.getByTestId('section-canvas')
|
||||||
|
const items = within(list).getAllByTestId(/sec-item-/)
|
||||||
|
|
||||||
|
// check first item labels (index 0)
|
||||||
|
const first = items[0]
|
||||||
|
const sel0 = within(first).getByLabelText('Select 0') as HTMLButtonElement
|
||||||
|
const up0 = within(first).getByLabelText('Up 0') as HTMLButtonElement
|
||||||
|
const down0 = within(first).getByLabelText('Down 0') as HTMLButtonElement
|
||||||
|
const rm0 = within(first).getByLabelText('Remove 0') as HTMLButtonElement
|
||||||
|
expect(sel0).toBeInTheDocument()
|
||||||
|
expect(up0).toBeInTheDocument()
|
||||||
|
expect(down0).toBeInTheDocument()
|
||||||
|
expect(rm0).toBeInTheDocument()
|
||||||
|
expect(sel0.title).toBe(sel0.getAttribute('aria-label'))
|
||||||
|
expect(up0.title).toBe(up0.getAttribute('aria-label'))
|
||||||
|
expect(down0.title).toBe(down0.getAttribute('aria-label'))
|
||||||
|
expect(rm0.title).toBe(rm0.getAttribute('aria-label'))
|
||||||
|
|
||||||
|
// check last item labels (index 2)
|
||||||
|
const last = items[2]
|
||||||
|
const sel2 = within(last).getByLabelText('Select 2') as HTMLButtonElement
|
||||||
|
const up2 = within(last).getByLabelText('Up 2') as HTMLButtonElement
|
||||||
|
const down2 = within(last).getByLabelText('Down 2') as HTMLButtonElement
|
||||||
|
const rm2 = within(last).getByLabelText('Remove 2') as HTMLButtonElement
|
||||||
|
expect(sel2).toBeInTheDocument()
|
||||||
|
expect(up2).toBeInTheDocument()
|
||||||
|
expect(down2).toBeInTheDocument()
|
||||||
|
expect(rm2).toBeInTheDocument()
|
||||||
|
expect(sel2.title).toBe(sel2.getAttribute('aria-label'))
|
||||||
|
expect(up2.title).toBe(up2.getAttribute('aria-label'))
|
||||||
|
expect(down2.title).toBe(down2.getAttribute('aria-label'))
|
||||||
|
expect(rm2.title).toBe(rm2.getAttribute('aria-label'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Up/Down buttons still present when onSelect not provided', () => {
|
||||||
|
render(<Wrapper withSelect={false} />)
|
||||||
|
const list = screen.getByTestId('section-canvas')
|
||||||
|
const items = within(list).getAllByTestId(/sec-item-/)
|
||||||
|
|
||||||
|
// when onSelect is undefined, Select button should not exist
|
||||||
|
expect(within(items[0]).queryByLabelText('Select 0')).toBeNull()
|
||||||
|
|
||||||
|
// Up/Down/Remove must still be present
|
||||||
|
expect(within(items[0]).getByLabelText('Up 0')).toBeInTheDocument()
|
||||||
|
expect(within(items[0]).getByLabelText('Down 0')).toBeInTheDocument()
|
||||||
|
expect(within(items[0]).getByLabelText('Remove 0')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import SectionCanvas from '@/components/SectionCanvas'
|
||||||
|
import type { Section } from '@/lib/state/store'
|
||||||
|
|
||||||
|
type DragEvt = { active: { id: string }; over: { id: string } }
|
||||||
|
|
||||||
|
vi.mock('@dnd-kit/core', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@dnd-kit/core')>('@dnd-kit/core')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
DndContext: ({ onDragEnd, children }: { onDragEnd?: (e: DragEvt) => void; children: React.ReactNode }) => (
|
||||||
|
<div data-testid="mock-dnd" onClick={() => onDragEnd?.({ active: { id: 'sec-0' }, over: { id: 'sec-2' } })}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
closestCenter: vi.fn(),
|
||||||
|
PointerSensor: function () {},
|
||||||
|
useSensor: vi.fn(() => ({})),
|
||||||
|
useSensors: vi.fn(() => []),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@dnd-kit/sortable', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@dnd-kit/sortable')>('@dnd-kit/sortable')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
useSortable: () => ({
|
||||||
|
attributes: {} as Record<string, unknown>,
|
||||||
|
listeners: {} as Record<string, unknown>,
|
||||||
|
setNodeRef: () => {},
|
||||||
|
transform: null as unknown,
|
||||||
|
transition: null as unknown,
|
||||||
|
}),
|
||||||
|
verticalListSortingStrategy: vi.fn(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function Wrapper() {
|
||||||
|
const [sections, setSections] = useState<Section[]>([
|
||||||
|
{ type: 'hero', props: { heading: 'Welcome', subheading: '' } },
|
||||||
|
{ type: 'faq', props: { items: [] } },
|
||||||
|
{ type: 'cta', props: { text: 'Go', href: '#' } },
|
||||||
|
])
|
||||||
|
return (
|
||||||
|
<SectionCanvas
|
||||||
|
sections={sections}
|
||||||
|
onMove={(from, to) => {
|
||||||
|
const next = sections.slice()
|
||||||
|
const [sp] = next.splice(from, 1)
|
||||||
|
next.splice(to, 0, sp)
|
||||||
|
setSections(next)
|
||||||
|
}}
|
||||||
|
onRemove={(i) => setSections((prev) => prev.filter((_, idx) => idx !== i))}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SectionCanvas - dnd', () => {
|
||||||
|
it('드래그 종료 이벤트로 섹션 순서를 변경한다', () => {
|
||||||
|
render(<Wrapper />)
|
||||||
|
const list = screen.getByTestId('section-canvas')
|
||||||
|
let items = list.querySelectorAll('li')
|
||||||
|
expect(items[0].textContent).toMatch(/hero/)
|
||||||
|
expect(items[1].textContent).toMatch(/faq/)
|
||||||
|
expect(items[2].textContent).toMatch(/cta/)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('mock-dnd'))
|
||||||
|
|
||||||
|
items = list.querySelectorAll('li')
|
||||||
|
expect(items[0].textContent).toMatch(/faq/)
|
||||||
|
expect(items[1].textContent).toMatch(/cta/)
|
||||||
|
expect(items[2].textContent).toMatch(/hero/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import { render, screen, fireEvent, within } from '@testing-library/react'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import SectionCanvas from './SectionCanvas'
|
||||||
|
import type { Section } from '@/lib/state/store'
|
||||||
|
|
||||||
|
function Wrapper() {
|
||||||
|
const [sections, setSections] = useState<Section[]>([
|
||||||
|
{ type: 'hero', props: { heading: 'H', subheading: '' } },
|
||||||
|
{ type: 'faq', props: { items: [] } },
|
||||||
|
{ type: 'cta', props: { text: 'Go', href: '#' } },
|
||||||
|
])
|
||||||
|
|
||||||
|
const onMove = (from: number, to: number) => {
|
||||||
|
setSections((prev) => {
|
||||||
|
const next = prev.slice()
|
||||||
|
const [sp] = next.splice(from, 1)
|
||||||
|
next.splice(to, 0, sp)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCanvas
|
||||||
|
sections={sections}
|
||||||
|
onMove={onMove}
|
||||||
|
onRemove={() => {}}
|
||||||
|
onSelect={() => {}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SectionCanvas keyboard/fallback reorder', () => {
|
||||||
|
test('clicking Down and Up buttons reorders items', () => {
|
||||||
|
render(<Wrapper />)
|
||||||
|
|
||||||
|
// initial order: hero, faq, cta
|
||||||
|
const list = screen.getByTestId('section-canvas')
|
||||||
|
let items = within(list).getAllByTestId(/sec-item-/)
|
||||||
|
expect(items[0]).toHaveTextContent('hero')
|
||||||
|
expect(items[1]).toHaveTextContent('faq')
|
||||||
|
expect(items[2]).toHaveTextContent('cta')
|
||||||
|
|
||||||
|
// move first item (index 0) down
|
||||||
|
fireEvent.click(within(items[0]).getByLabelText('Down 0'))
|
||||||
|
|
||||||
|
items = within(list).getAllByTestId(/sec-item-/)
|
||||||
|
expect(items[0]).toHaveTextContent('faq')
|
||||||
|
expect(items[1]).toHaveTextContent('hero')
|
||||||
|
expect(items[2]).toHaveTextContent('cta')
|
||||||
|
|
||||||
|
// move last item up
|
||||||
|
fireEvent.click(within(items[2]).getByLabelText('Up 2'))
|
||||||
|
|
||||||
|
items = within(list).getAllByTestId(/sec-item-/)
|
||||||
|
expect(items[0]).toHaveTextContent('faq')
|
||||||
|
expect(items[1]).toHaveTextContent('cta')
|
||||||
|
expect(items[2]).toHaveTextContent('hero')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react'
|
||||||
|
import type { Section } from '@/lib/state/store'
|
||||||
|
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragEndEvent } from '@dnd-kit/core'
|
||||||
|
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'
|
||||||
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
|
|
||||||
|
function SortableItem({ id, children, testId }: { id: string; children: React.ReactNode; testId?: string }) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id })
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<li ref={setNodeRef} style={style} {...attributes} {...listeners} data-testid={testId} className="flex items-center justify-between border rounded px-3 py-2">
|
||||||
|
{children}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SectionCanvas({
|
||||||
|
sections,
|
||||||
|
onMove,
|
||||||
|
onRemove,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
sections: Section[]
|
||||||
|
onMove: (from: number, to: number) => void
|
||||||
|
onRemove: (index: number) => void
|
||||||
|
onSelect?: (index: number) => void
|
||||||
|
}) {
|
||||||
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }))
|
||||||
|
const ids = useMemo(() => sections.map((_, i) => `sec-${i}`), [sections])
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event
|
||||||
|
if (!over) return
|
||||||
|
if (active.id === over.id) return
|
||||||
|
const from = ids.indexOf(String(active.id))
|
||||||
|
const to = ids.indexOf(String(over.id))
|
||||||
|
if (from !== -1 && to !== -1) onMove(from, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
|
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||||
|
<ul className="space-y-1" data-testid="section-canvas">
|
||||||
|
{sections.map((s, i) => (
|
||||||
|
<SortableItem id={`sec-${i}`} key={`sec-${i}`} testId={`sec-item-${i}`}>
|
||||||
|
<span className="text-sm">{s.type}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{onSelect && (
|
||||||
|
<button aria-label={`Select ${i}`} title={`Select ${i}`} className="text-sm border rounded px-2 py-1" onClick={() => onSelect(i)}>
|
||||||
|
Select
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button aria-label={`Up ${i}`} title={`Up ${i}`} className="text-sm border rounded px-2 py-1" onClick={() => i > 0 && onMove(i, i - 1)}>
|
||||||
|
Up
|
||||||
|
</button>
|
||||||
|
<button aria-label={`Down ${i}`} title={`Down ${i}`} className="text-sm border rounded px-2 py-1" onClick={() => i < sections.length - 1 && onMove(i, i + 1)}>
|
||||||
|
Down
|
||||||
|
</button>
|
||||||
|
<button aria-label={`Remove ${i}`} title={`Remove ${i}`} className="text-sm text-red-600 border rounded px-2 py-1" onClick={() => onRemove(i)}>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</SortableItem>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export default function SidebarComponents({
|
||||||
|
onAddHero,
|
||||||
|
onAddFAQ,
|
||||||
|
onAddCTA,
|
||||||
|
onAddFeatures,
|
||||||
|
onAddTestimonials,
|
||||||
|
}: {
|
||||||
|
onAddHero: () => void
|
||||||
|
onAddFAQ: () => void
|
||||||
|
onAddCTA: () => void
|
||||||
|
onAddFeatures: () => void
|
||||||
|
onAddTestimonials: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2" data-testid="sidebar-components">
|
||||||
|
<h2 className="text-sm font-semibold">Components</h2>
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
<button data-testid="card-hero" className="border rounded p-2 text-left" onClick={onAddHero}>
|
||||||
|
<div className="text-sm font-medium">Hero</div>
|
||||||
|
<div className="text-xs text-gray-500">Large heading + image</div>
|
||||||
|
</button>
|
||||||
|
<button data-testid="card-faq" className="border rounded p-2 text-left" onClick={onAddFAQ}>
|
||||||
|
<div className="text-sm font-medium">FAQ</div>
|
||||||
|
<div className="text-xs text-gray-500">Questions & answers</div>
|
||||||
|
</button>
|
||||||
|
<button data-testid="card-cta" className="border rounded p-2 text-left" onClick={onAddCTA}>
|
||||||
|
<div className="text-sm font-medium">CTA</div>
|
||||||
|
<div className="text-xs text-gray-500">Action button</div>
|
||||||
|
</button>
|
||||||
|
<button data-testid="card-features" className="border rounded p-2 text-left" onClick={onAddFeatures}>
|
||||||
|
<div className="text-sm font-medium">Features</div>
|
||||||
|
<div className="text-xs text-gray-500">List of product features</div>
|
||||||
|
</button>
|
||||||
|
<button data-testid="card-testimonials" className="border rounded p-2 text-left" onClick={onAddTestimonials}>
|
||||||
|
<div className="text-sm font-medium">Testimonials</div>
|
||||||
|
<div className="text-xs text-gray-500">Customer quotes</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import BuilderClientPage from './BuilderClientPage'
|
||||||
|
import messages from '../messages/en.json'
|
||||||
|
|
||||||
|
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<NextIntlClientProvider messages={messages} locale="en">
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Buttons title/aria-label consistency', () => {
|
||||||
|
test('Inspector Remove buttons have title matching aria-label', async () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add Features section and select
|
||||||
|
fireEvent.click(screen.getByTestId('card-features'))
|
||||||
|
fireEvent.click(await screen.findByLabelText('Select 0'))
|
||||||
|
// Ensure at least one item
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Add Feature Item/i }))
|
||||||
|
|
||||||
|
// Validate Features remove buttons
|
||||||
|
const featureRemoveButtons = screen.getAllByRole('button', { name: /Remove Feature \d+/ })
|
||||||
|
for (const btn of featureRemoveButtons) {
|
||||||
|
const aria = btn.getAttribute('aria-label') || ''
|
||||||
|
const title = btn.getAttribute('title') || ''
|
||||||
|
expect(title).toBe(aria)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Testimonials section and select
|
||||||
|
fireEvent.click(screen.getByTestId('card-testimonials'))
|
||||||
|
fireEvent.click(await screen.findByLabelText('Select 1'))
|
||||||
|
// Ensure at least one item to expose remove button
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Add Testimonial/i }))
|
||||||
|
|
||||||
|
// Validate Testimonials remove buttons
|
||||||
|
const testimonialRemoveButtons = screen.getAllByRole('button', { name: /Remove Testimonial \d+/ })
|
||||||
|
for (const btn of testimonialRemoveButtons) {
|
||||||
|
const aria = btn.getAttribute('aria-label') || ''
|
||||||
|
const title = btn.getAttribute('title') || ''
|
||||||
|
expect(title).toBe(aria)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import '@testing-library/jest-dom'
|
||||||
|
import React from 'react'
|
||||||
|
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||||
|
import I18nProvider from '@/components/I18nProvider'
|
||||||
|
import en from '@/messages/en.json'
|
||||||
|
|
||||||
|
describe('Buttons naming/aria-label consistency (smoke)', () => {
|
||||||
|
it('renders primary action buttons with stable accessible names', () => {
|
||||||
|
render(
|
||||||
|
<I18nProvider locale="en" messages={en as unknown as Record<string, string>}>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</I18nProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sidebar/toolbar actions
|
||||||
|
expect(screen.getByRole('button', { name: 'Add Hero' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Add FAQ' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Add CTA' })).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Export button
|
||||||
|
expect(screen.getByRole('button', { name: 'Export ZIP' })).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Preview viewport toggle buttons have explicit aria-labels as names
|
||||||
|
expect(screen.getByRole('button', { name: 'Desktop' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Mobile' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import '@testing-library/jest-dom'
|
||||||
|
import React from 'react'
|
||||||
|
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||||
|
import I18nProvider from '@/components/I18nProvider'
|
||||||
|
import en from '@/messages/en.json'
|
||||||
|
import ko from '@/messages/ko.json'
|
||||||
|
|
||||||
|
describe('i18n regression - form add actions (en/ko)', () => {
|
||||||
|
function renderWith(locale: 'en' | 'ko') {
|
||||||
|
const messages = locale === 'en' ? en : ko
|
||||||
|
return render(
|
||||||
|
<I18nProvider locale={locale} messages={messages as unknown as Record<string, string>}>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</I18nProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('en labels exist for form field add buttons', () => {
|
||||||
|
renderWith('en')
|
||||||
|
expect(screen.getByRole('button', { name: 'Add Text' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Add Email' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Add Tel' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Add Date' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Add Textarea' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Add Checkbox' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Add Select' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Add Radio' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ko labels exist for form field add buttons', () => {
|
||||||
|
renderWith('ko')
|
||||||
|
expect(screen.getByRole('button', { name: '텍스트 필드 추가' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: '이메일 필드 추가' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: '전화번호 필드 추가' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: '날짜 필드 추가' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: '텍스트영역 추가' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: '체크박스 추가' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: '셀렉트 추가' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: '라디오 추가' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import '@testing-library/jest-dom'
|
||||||
|
import React from 'react'
|
||||||
|
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||||
|
import I18nProvider from '@/components/I18nProvider'
|
||||||
|
import en from '@/messages/en.json'
|
||||||
|
import ko from '@/messages/ko.json'
|
||||||
|
|
||||||
|
describe('i18n regression - misc labels (title/preview/sections)', () => {
|
||||||
|
function renderWith(locale: 'en' | 'ko') {
|
||||||
|
const messages = locale === 'en' ? en : ko
|
||||||
|
return render(
|
||||||
|
<I18nProvider locale={locale} messages={messages as unknown as Record<string, string>}>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</I18nProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('en: builder title, preview text, sections heading present', () => {
|
||||||
|
renderWith('en')
|
||||||
|
expect(screen.getByRole('heading', { name: 'Landing Page Builder' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Preview (static)')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Sections')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ko: builder title, preview text, sections heading present', () => {
|
||||||
|
renderWith('ko')
|
||||||
|
expect(screen.getByRole('heading', { name: '랜딩 페이지 빌더' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('미리보기(정적)')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('섹션')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import '@testing-library/jest-dom'
|
||||||
|
import React from 'react'
|
||||||
|
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||||
|
import I18nProvider from '@/components/I18nProvider'
|
||||||
|
import en from '@/messages/en.json'
|
||||||
|
import ko from '@/messages/ko.json'
|
||||||
|
|
||||||
|
function renderWith(locale: 'en' | 'ko') {
|
||||||
|
const messages = locale === 'en' ? en : ko
|
||||||
|
return render(
|
||||||
|
<I18nProvider locale={locale} messages={messages as unknown as Record<string, string>}>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</I18nProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('i18n regression - key presence on main UI', () => {
|
||||||
|
it('en: shows expected labels and buttons', () => {
|
||||||
|
renderWith('en')
|
||||||
|
expect(screen.getByRole('heading', { name: 'Landing Page Builder' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Title')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Locale')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Primary Color')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Font Family')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Export ZIP' })).toBeInTheDocument()
|
||||||
|
// form actions
|
||||||
|
expect(screen.getByRole('button', { name: 'Add Text' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Add Email' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Add Tel' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Add Date' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Add Textarea' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Add Checkbox' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Add Select' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Add Radio' })).toBeInTheDocument()
|
||||||
|
// preview
|
||||||
|
expect(screen.getByRole('button', { name: 'Desktop' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Mobile' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ko: shows expected labels and buttons', () => {
|
||||||
|
renderWith('ko')
|
||||||
|
expect(screen.getByRole('heading', { name: '랜딩 페이지 빌더' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('제목')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('로케일')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('대표 색상')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('폰트')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'ZIP 내보내기' })).toBeInTheDocument()
|
||||||
|
// form actions
|
||||||
|
expect(screen.getByRole('button', { name: '텍스트 필드 추가' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: '이메일 필드 추가' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: '전화번호 필드 추가' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: '날짜 필드 추가' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: '텍스트영역 추가' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: '체크박스 추가' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: '셀렉트 추가' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: '라디오 추가' })).toBeInTheDocument()
|
||||||
|
// preview
|
||||||
|
expect(screen.getByRole('button', { name: 'Desktop' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Mobile' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import '@testing-library/jest-dom'
|
||||||
|
import React from 'react'
|
||||||
|
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||||
|
import I18nProvider from '@/components/I18nProvider'
|
||||||
|
import en from '@/messages/en.json'
|
||||||
|
import ko from '@/messages/ko.json'
|
||||||
|
|
||||||
|
describe('i18n regression - sidebar add actions (en/ko)', () => {
|
||||||
|
function renderWith(locale: 'en' | 'ko') {
|
||||||
|
const messages = locale === 'en' ? en : ko
|
||||||
|
return render(
|
||||||
|
<I18nProvider locale={locale} messages={messages as unknown as Record<string, string>}>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</I18nProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('en labels exist', () => {
|
||||||
|
renderWith('en')
|
||||||
|
expect(screen.getByRole('button', { name: 'Add Hero' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Add FAQ' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Add CTA' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ko labels exist', () => {
|
||||||
|
renderWith('ko')
|
||||||
|
expect(screen.getByRole('button', { name: '히어로 추가' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'FAQ 추가' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'CTA 추가' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import '@testing-library/jest-dom'
|
||||||
|
import React from 'react'
|
||||||
|
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||||
|
import I18nProvider from '@/components/I18nProvider'
|
||||||
|
import en from '@/messages/en.json'
|
||||||
|
|
||||||
|
describe('Preview viewport toggle ARIA regression', () => {
|
||||||
|
it('Desktop/Mobile buttons expose stable ARIA attributes', () => {
|
||||||
|
render(
|
||||||
|
<I18nProvider locale="en" messages={en as unknown as Record<string, string>}>
|
||||||
|
<BuilderClientPage />
|
||||||
|
</I18nProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
const desktop = screen.getByRole('button', { name: 'Desktop' })
|
||||||
|
const mobile = screen.getByRole('button', { name: 'Mobile' })
|
||||||
|
|
||||||
|
// describedby shared help
|
||||||
|
expect(desktop).toHaveAttribute('aria-describedby', 'preview-toggle-help')
|
||||||
|
expect(mobile).toHaveAttribute('aria-describedby', 'preview-toggle-help')
|
||||||
|
|
||||||
|
// controls preview container
|
||||||
|
expect(desktop).toHaveAttribute('aria-controls', 'preview-container')
|
||||||
|
expect(mobile).toHaveAttribute('aria-controls', 'preview-container')
|
||||||
|
|
||||||
|
// default pressed state: Desktop true, Mobile false
|
||||||
|
expect(desktop).toHaveAttribute('aria-pressed', 'true')
|
||||||
|
expect(mobile).toHaveAttribute('aria-pressed', 'false')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Inspector a11y helper: verify input ↔ helptext wiring via aria-describedby
|
||||||
|
import { expect } from 'vitest'
|
||||||
|
|
||||||
|
export function assertDescribedBy(input: HTMLElement, helpId: string) {
|
||||||
|
expect(input).toHaveAttribute('aria-describedby', helpId)
|
||||||
|
const help = (input.ownerDocument || document).getElementById(helpId)
|
||||||
|
expect(help).not.toBeNull()
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { AssetManager } from '@/lib/assets/assetManager'
|
||||||
|
|
||||||
|
function buf(bytes: number[]): Uint8Array {
|
||||||
|
return new Uint8Array(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AssetManager', () => {
|
||||||
|
it('업로드 이미지를 해시하여 assets/<hash>.<ext>로 관리하고 중복을 제거한다', async () => {
|
||||||
|
const am = new AssetManager({
|
||||||
|
maxBytes: 10 * 1024 * 1024,
|
||||||
|
allowedExts: ['png', 'jpg', 'jpeg', 'webp', 'svg'],
|
||||||
|
})
|
||||||
|
|
||||||
|
const a1 = await am.add({ name: 'hero.png', type: 'image/png', bytes: buf([1,2,3,4]) })
|
||||||
|
const a2 = await am.add({ name: 'another.png', type: 'image/png', bytes: buf([1,2,3,4]) })
|
||||||
|
const a3 = await am.add({ name: 'logo.svg', type: 'image/svg+xml', bytes: buf([60,115,118,103,62]) })
|
||||||
|
|
||||||
|
expect(a1.path).toMatch(/^assets\/[a-f0-9]{8}\.png$/)
|
||||||
|
expect(a2.path).toBe(a1.path) // 동일 바이트 -> 동일 해시 -> 중복 제거
|
||||||
|
expect(a3.path).toMatch(/^assets\/[a-f0-9]{8}\.svg$/)
|
||||||
|
|
||||||
|
const list = am.list()
|
||||||
|
expect(list.length).toBe(2)
|
||||||
|
|
||||||
|
const zipStruct = am.toZipStructure()
|
||||||
|
const keys = Object.keys(zipStruct)
|
||||||
|
expect(keys).toContain(a1.path)
|
||||||
|
expect(keys).toContain(a3.path)
|
||||||
|
expect(zipStruct[a1.path]).toBeInstanceOf(Uint8Array)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('허용되지 않은 확장자/크기는 거절한다', async () => {
|
||||||
|
const am = new AssetManager({ maxBytes: 5, allowedExts: ['png'] })
|
||||||
|
await expect(
|
||||||
|
am.add({ name: 'bad.gif', type: 'image/gif', bytes: buf([1]) })
|
||||||
|
).rejects.toThrow(/extension/i)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
am.add({ name: 'big.png', type: 'image/png', bytes: buf([1,2,3,4,5,6]) })
|
||||||
|
).rejects.toThrow(/size/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rewriteUrl: http/https는 그대로, local:<name>은 assets 경로로 치환한다', async () => {
|
||||||
|
const am = new AssetManager({ maxBytes: 1024, allowedExts: ['png'] })
|
||||||
|
const external = am.rewriteUrl('https://cdn.example.com/x.png')
|
||||||
|
expect(external).toBe('https://cdn.example.com/x.png')
|
||||||
|
|
||||||
|
const ref = await am.add({ name: 'pic.png', type: 'image/png', bytes: buf([9,9,9]) })
|
||||||
|
const rewritten = am.rewriteUrl(`local:${ref.originalName}`)
|
||||||
|
expect(rewritten).toBe(ref.path)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
export type AssetInput = {
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
bytes: Uint8Array
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AssetRef = {
|
||||||
|
originalName: string
|
||||||
|
mimeType: string
|
||||||
|
bytes: Uint8Array
|
||||||
|
hash8: string
|
||||||
|
ext: string
|
||||||
|
path: string // assets/<hash8>.<ext>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AssetManagerOptions = {
|
||||||
|
maxBytes: number
|
||||||
|
allowedExts: string[] // e.g., ['png','jpg','jpeg','webp','svg']
|
||||||
|
}
|
||||||
|
|
||||||
|
function extOf(name: string): string {
|
||||||
|
const i = name.lastIndexOf('.')
|
||||||
|
return i >= 0 ? name.slice(i + 1).toLowerCase() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple FNV-1a 32-bit hash -> 8-hex chars
|
||||||
|
function hash8(bytes: Uint8Array): string {
|
||||||
|
let h = 0x811c9dc5 >>> 0
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
h ^= bytes[i]
|
||||||
|
h = Math.imul(h, 0x01000193) >>> 0
|
||||||
|
}
|
||||||
|
return ('00000000' + h.toString(16)).slice(-8)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AssetManager {
|
||||||
|
private readonly maxBytes: number
|
||||||
|
private readonly allowed: Set<string>
|
||||||
|
private byHash = new Map<string, AssetRef>()
|
||||||
|
private byOriginal = new Map<string, string>() // originalName -> path
|
||||||
|
|
||||||
|
constructor(opts: AssetManagerOptions) {
|
||||||
|
this.maxBytes = opts.maxBytes
|
||||||
|
this.allowed = new Set(opts.allowedExts.map((e) => e.toLowerCase()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async add(file: AssetInput): Promise<AssetRef> {
|
||||||
|
const ext = extOf(file.name)
|
||||||
|
if (!this.allowed.has(ext)) {
|
||||||
|
throw new Error('Invalid extension')
|
||||||
|
}
|
||||||
|
if (file.bytes.length > this.maxBytes) {
|
||||||
|
throw new Error('File size exceeded')
|
||||||
|
}
|
||||||
|
const h = hash8(file.bytes)
|
||||||
|
const existing = this.byHash.get(h)
|
||||||
|
if (existing) {
|
||||||
|
// map original name for rewrite convenience
|
||||||
|
if (!this.byOriginal.has(file.name)) this.byOriginal.set(file.name, existing.path)
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = `assets/${h}.${ext}`
|
||||||
|
const ref: AssetRef = {
|
||||||
|
originalName: file.name,
|
||||||
|
mimeType: file.type,
|
||||||
|
bytes: file.bytes,
|
||||||
|
hash8: h,
|
||||||
|
ext,
|
||||||
|
path,
|
||||||
|
}
|
||||||
|
this.byHash.set(h, ref)
|
||||||
|
if (!this.byOriginal.has(file.name)) this.byOriginal.set(file.name, path)
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): AssetRef[] {
|
||||||
|
return Array.from(this.byHash.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
toZipStructure(): Record<string, Uint8Array> {
|
||||||
|
const out: Record<string, Uint8Array> = {}
|
||||||
|
for (const ref of this.byHash.values()) {
|
||||||
|
out[ref.path] = ref.bytes
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
rewriteUrl(urlOrLocalRef: string): string {
|
||||||
|
if (/^https?:\/\//i.test(urlOrLocalRef)) return urlOrLocalRef
|
||||||
|
if (urlOrLocalRef.startsWith('local:')) {
|
||||||
|
const key = urlOrLocalRef.slice('local:'.length)
|
||||||
|
const p = this.byOriginal.get(key)
|
||||||
|
return p ?? urlOrLocalRef
|
||||||
|
}
|
||||||
|
return urlOrLocalRef
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import { exportPage } from './html'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
|
||||||
|
function makePage(): Page {
|
||||||
|
return pageSchema.parse({
|
||||||
|
title: 'T',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#000000', fontFamily: 'Inter' },
|
||||||
|
sections: [
|
||||||
|
{ type: 'hero', props: { heading: 'Welcome', subheading: '', imageUrl: 'https://example.com/hero.png' } },
|
||||||
|
{ type: 'faq', props: { items: [{ q: 'Q1', a: 'A1' }] } },
|
||||||
|
{ type: 'cta', props: { text: 'Go', href: '#' } },
|
||||||
|
] as Page['sections'],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
|
||||||
|
seo: { title: 'SEO', description: 'Desc' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Exporter a11y/structure - main contains sections and form', () => {
|
||||||
|
test('main landmark wraps sections and form markup', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
expect(out.html).toContain('<main id="main" role="main">')
|
||||||
|
// sections inside main
|
||||||
|
expect(out.html).toMatch(/<main[\s\S]*<section class="hero"[\s\S]*<section class="faq"[\s\S]*<section class="cta"[\s\S]*<\/main>/)
|
||||||
|
// form inside main
|
||||||
|
expect(out.html).toMatch(/<main[\s\S]*<form method="post"[\s\S]*<\/main>/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import { exportPage } from './html'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
|
||||||
|
function makePage(overrides: Partial<Page> = {}): Page {
|
||||||
|
const base: Page = {
|
||||||
|
title: 'T',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#000000', fontFamily: 'Inter' },
|
||||||
|
sections: [
|
||||||
|
{ type: 'hero', props: { heading: 'Welcome', subheading: 'Sub', imageUrl: 'https://example.com/img.png' } },
|
||||||
|
{ type: 'faq', props: { items: [{ q: 'Q1', a: 'A1' }] } },
|
||||||
|
{ type: 'features', props: { items: ['One', 'Two'] } },
|
||||||
|
{ type: 'testimonials', props: { items: [{ author: 'Jane', quote: 'Great' }] } },
|
||||||
|
{ type: 'cta', props: { text: 'Go', href: '#' } },
|
||||||
|
] as Page['sections'],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
|
||||||
|
seo: { title: 'SEO', description: 'Desc' },
|
||||||
|
}
|
||||||
|
return pageSchema.parse({ ...base, ...overrides })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Exporter a11y/responsive enhancements', () => {
|
||||||
|
test('includes skip link and main landmark', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
expect(out.html).toContain('<a href="#main" class="skip-link">')
|
||||||
|
expect(out.html).toContain('<main id="main" role="main">')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sections use aria-labelledby with h2 ids', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
expect(out.html).toContain('<section class="faq" aria-labelledby="faq-heading">')
|
||||||
|
expect(out.html).toContain('<h2 id="faq-heading">FAQ</h2>')
|
||||||
|
expect(out.html).toContain('<section class="features" aria-labelledby="features-heading">')
|
||||||
|
expect(out.html).toContain('<h2 id="features-heading">Features</h2>')
|
||||||
|
expect(out.html).toContain('<section class="testimonials" aria-labelledby="testimonials-heading">')
|
||||||
|
expect(out.html).toContain('<h2 id="testimonials-heading">Testimonials</h2>')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('hero image alt derives from heading', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
expect(out.html).toContain('<h1 id="hero-heading">Welcome</h1>')
|
||||||
|
expect(out.html).toContain('<img src="https://example.com/img.png" alt="Welcome" />')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('responsive and a11y CSS tokens exist', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
expect(out.css).toMatch(/@media \(max-width: 640px\)/)
|
||||||
|
expect(out.css).toMatch(/@media \(prefers-reduced-motion: reduce\)/)
|
||||||
|
expect(out.css).toMatch(/a\.skip-link:focus/)
|
||||||
|
expect(out.css).toMatch(/outline: 3px solid/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
import { exportPage } from '@/lib/exporter/html'
|
||||||
|
|
||||||
|
describe('Analytics snippet injection', () => {
|
||||||
|
const base = {
|
||||||
|
title: 'Analytics Test',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
seo: { title: 'Analytics Test', description: 'desc' },
|
||||||
|
}
|
||||||
|
|
||||||
|
it('GA4 measurement ID가 있으면 GA4 스니펫을 삽입한다', () => {
|
||||||
|
const page: Page = pageSchema.parse({
|
||||||
|
...base,
|
||||||
|
analytics: { ga4MeasurementId: 'G-ABC123', metaPixelId: '' },
|
||||||
|
})
|
||||||
|
const out = exportPage(page)
|
||||||
|
expect(out.html).toContain('https://www.googletagmanager.com/gtag/js?id=G-ABC123')
|
||||||
|
expect(out.html).toContain("gtag('config', 'G-ABC123')")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Meta Pixel ID가 있으면 Pixel 스니펫을 삽입한다', () => {
|
||||||
|
const page: Page = pageSchema.parse({
|
||||||
|
...base,
|
||||||
|
analytics: { ga4MeasurementId: '', metaPixelId: '1234567890' },
|
||||||
|
})
|
||||||
|
const out = exportPage(page)
|
||||||
|
expect(out.html).toContain('fbq(')
|
||||||
|
expect(out.html).toContain("fbq('init', '1234567890')")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('customHeadHtml이 있으면 head에 그대로 삽입한다', () => {
|
||||||
|
const page: Page = pageSchema.parse({
|
||||||
|
...base,
|
||||||
|
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '<meta name="robots" content="noindex">' },
|
||||||
|
})
|
||||||
|
const out = exportPage(page)
|
||||||
|
expect(out.html).toContain('<meta name="robots" content="noindex">')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import { exportPage } from './html'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
|
||||||
|
const makePage = (): Page =>
|
||||||
|
pageSchema.parse({
|
||||||
|
title: 'T',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
seo: { title: 'T', description: 'D' },
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Exporter CSS contrast/font tokens', () => {
|
||||||
|
test('contains base text color and responsive font adjustments', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
// base body text color token present
|
||||||
|
expect(out.css).toMatch(/body\{[^}]*color:\s*#0f172a/i)
|
||||||
|
// focus outline thickness and offset present
|
||||||
|
expect(out.css).toMatch(/outline:\s*3px\s*solid/i)
|
||||||
|
expect(out.css).toMatch(/outline-offset:\s*2px/i)
|
||||||
|
// responsive font size change for h1 in mobile media query
|
||||||
|
expect(out.css).toMatch(/@media \(max-width: 640px\)[\s\S]*h1\{[^}]*font-size:\s*1\.5rem/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { exportPage } from '@/lib/exporter/html'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
|
||||||
|
function makePage(): Page {
|
||||||
|
return pageSchema.parse({
|
||||||
|
title: 'CSS Tokens Expand',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
seo: { title: 'SEO', description: 'Desc' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Exporter CSS tokens - expanded regression', () => {
|
||||||
|
it('includes root var, skip-link base/focus, focus outline, reduced-motion, body font', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
const css = out.css
|
||||||
|
|
||||||
|
// :root primary color var
|
||||||
|
expect(css).toMatch(/:root\{\s*--primary:\s*#0ea5e9\s*;\s*\}/)
|
||||||
|
|
||||||
|
// body font family includes configured font first
|
||||||
|
expect(css).toMatch(/body\{[^}]*font-family:\s*Inter,\s*system-ui[\s\S]*\}/)
|
||||||
|
|
||||||
|
// skip-link base and focus tokens
|
||||||
|
expect(css).toMatch(/a\.skip-link\{[\s\S]*left:-9999px[\s\S]*\}/)
|
||||||
|
expect(css).toMatch(/a\.skip-link:focus\{[\s\S]*left:1rem[\s\S]*background:[^;]+;[\s\S]*\}/)
|
||||||
|
|
||||||
|
// focus-visible/outline tokens (any focusable)
|
||||||
|
expect(css).toMatch(/button:focus, a:focus, input:focus, select:focus, textarea:focus\{[\s\S]*outline:\s*3px solid\s*#2563eb[\s\S]*outline-offset:\s*2px[\s\S]*\}/)
|
||||||
|
|
||||||
|
// reduced-motion detailed rule
|
||||||
|
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)\{\s*\*\{\s*transition:\s*none !important;\s*animation:\s*none !important\s*\}\s*\}/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import { exportPage } from './html'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
|
||||||
|
function makePage(): Page {
|
||||||
|
return pageSchema.parse({
|
||||||
|
title: 'T',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#000000', fontFamily: 'Inter' },
|
||||||
|
sections: [],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
|
||||||
|
seo: { title: 'SEO', description: 'Desc' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Exporter CSS tokens', () => {
|
||||||
|
test('includes focus outline, skip-link, and responsive MQ tokens', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
expect(out.css).toMatch(/a\.skip-link:focus/)
|
||||||
|
expect(out.css).toMatch(/focus-visible|outline: 3px solid|outline-offset/)
|
||||||
|
expect(out.css).toMatch(/@media \(max-width: 640px\)/)
|
||||||
|
expect(out.css).toMatch(/@media \(prefers-reduced-motion: reduce\)/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import { exportPage } from './html'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
|
||||||
|
function makePage(): Page {
|
||||||
|
return pageSchema.parse({
|
||||||
|
title: 'T',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#000000', fontFamily: 'Inter' },
|
||||||
|
sections: [
|
||||||
|
{ type: 'features', props: { items: ['One', 'Two'] } },
|
||||||
|
{ type: 'testimonials', props: { items: [{ author: 'Jane', quote: 'Great' }] } },
|
||||||
|
] as Page['sections'],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
|
||||||
|
seo: { title: 'SEO', description: 'Desc' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Exporter - Features/Testimonials aria-labelledby', () => {
|
||||||
|
test('has aria-labelledby and matching heading ids', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
expect(out.html).toContain('<section class="features" aria-labelledby="features-heading">')
|
||||||
|
expect(out.html).toContain('<h2 id="features-heading">Features</h2>')
|
||||||
|
expect(out.html).toContain('<section class="testimonials" aria-labelledby="testimonials-heading">')
|
||||||
|
expect(out.html).toContain('<h2 id="testimonials-heading">Testimonials</h2>')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import { exportPage } from './html'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
|
||||||
|
const makePage = (): Page =>
|
||||||
|
pageSchema.parse({
|
||||||
|
title: 'Focus Tokens',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
seo: { title: 'SEO', description: 'Desc' },
|
||||||
|
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CSS focus-visible tokens smoke', () => {
|
||||||
|
test('outline uses 3px solid and hex color token', () => {
|
||||||
|
const { css } = exportPage(makePage())
|
||||||
|
expect(css).toMatch(/outline:\s*3px\s*solid/i)
|
||||||
|
// check presence of our hex color used for outline (example: #2563eb)
|
||||||
|
expect(css).toMatch(/outline:\s*3px\s*solid\s*#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})/)
|
||||||
|
// ensure focus-visible styles exist on common interactive elements
|
||||||
|
expect(css).toMatch(/button:focus|:focus-visible|a:focus|input:focus|textarea:focus|select:focus/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
import { exportPage } from '@/lib/exporter/html'
|
||||||
|
|
||||||
|
const makePage = (): Page =>
|
||||||
|
pageSchema.parse({
|
||||||
|
title: 'Form Help A11y',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [],
|
||||||
|
form: {
|
||||||
|
actionUrl: '',
|
||||||
|
fields: [
|
||||||
|
{ type: 'text', name: 'name', label: 'Name', required: true, help: 'Your full name' },
|
||||||
|
{ type: 'email', name: 'email', label: 'Email', required: true },
|
||||||
|
{ type: 'select', name: 'plan', label: 'Plan', required: false, options: ['A','B'], help: 'Choose your plan' },
|
||||||
|
{ type: 'radio', name: 'size', label: 'Size', required: false, options: ['S','M','L'], help: 'Pick one size' },
|
||||||
|
{ type: 'checkbox', name: 'agree', label: 'Agree', required: true, help: 'You must agree to proceed' },
|
||||||
|
{ type: 'textarea', name: 'msg', label: 'Message', required: false },
|
||||||
|
],
|
||||||
|
spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
|
||||||
|
},
|
||||||
|
seo: { title: 'Form Help A11y', description: 'desc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requirements (a11y):
|
||||||
|
* - Each control has an id `field-<name>` and its label has htmlFor pointing to it.
|
||||||
|
* - If field.help exists:
|
||||||
|
* - Render help element with id `field-<name>-help` containing help text.
|
||||||
|
* - Control has `aria-describedby` referencing the help id.
|
||||||
|
* - For radio group, set aria-describedby on the fieldset instead of individual radios.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('Exporter - form helptext and aria-describedby', () => {
|
||||||
|
it('renders id/htmlFor and help + aria-describedby wiring', () => {
|
||||||
|
const page = makePage()
|
||||||
|
const out = exportPage(page)
|
||||||
|
const html = out.html
|
||||||
|
|
||||||
|
// text with help
|
||||||
|
expect(html).toContain('id="field-name"')
|
||||||
|
expect(html).toContain('<label for="field-name">Name')
|
||||||
|
expect(html).toContain('id="field-name-help"')
|
||||||
|
expect(html).toContain('Your full name')
|
||||||
|
expect(html).toContain('aria-describedby="field-name-help"')
|
||||||
|
|
||||||
|
// email without help → no aria-describedby
|
||||||
|
expect(html).toContain('id="field-email"')
|
||||||
|
expect(html).toContain('<label for="field-email">Email')
|
||||||
|
expect(html).not.toContain('field-email-help')
|
||||||
|
|
||||||
|
// select with help
|
||||||
|
expect(html).toContain('id="field-plan"')
|
||||||
|
expect(html).toContain('<label for="field-plan">Plan')
|
||||||
|
expect(html).toContain('id="field-plan-help"')
|
||||||
|
expect(html).toContain('Choose your plan')
|
||||||
|
|
||||||
|
// radio group help → fieldset has aria-describedby
|
||||||
|
expect(html).toContain('<fieldset')
|
||||||
|
expect(html).toContain('aria-describedby="field-size-help"')
|
||||||
|
expect(html).toContain('id="field-size-help"')
|
||||||
|
expect(html).toContain('Pick one size')
|
||||||
|
|
||||||
|
// checkbox with help
|
||||||
|
expect(html).toContain('id="field-agree"')
|
||||||
|
expect(html).toContain('<label for="field-agree">')
|
||||||
|
expect(html).toContain('id="field-agree-help"')
|
||||||
|
expect(html).toContain('You must agree to proceed')
|
||||||
|
|
||||||
|
// textarea without help
|
||||||
|
expect(html).toContain('id="field-msg"')
|
||||||
|
expect(html).toContain('<label for="field-msg">Message')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
import { exportPage } from '@/lib/exporter/html'
|
||||||
|
|
||||||
|
function makePage(fields: Page['form']['fields']): Page {
|
||||||
|
return pageSchema.parse({
|
||||||
|
title: 'Matrix',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [],
|
||||||
|
form: {
|
||||||
|
actionUrl: '',
|
||||||
|
fields,
|
||||||
|
spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
|
||||||
|
},
|
||||||
|
seo: { title: 'SEO', description: 'Desc' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Exporter - form helptext matrix (required/maxLength/pattern/help)', () => {
|
||||||
|
it('text: required + maxLength + help → has required, maxlength, help id and aria-describedby', () => {
|
||||||
|
const page = makePage([
|
||||||
|
{ type: 'text', name: 'nick', label: 'Nickname', required: true, maxLength: 10, help: 'Public name' },
|
||||||
|
])
|
||||||
|
const html = exportPage(page).html
|
||||||
|
expect(html).toContain('id="field-nick"')
|
||||||
|
expect(html).toMatch(/name="nick"[^"]*[^>]*required/)
|
||||||
|
expect(html).toMatch(/name="nick"[^"]*[^>]*maxlength="10"/)
|
||||||
|
expect(html).toContain('id="field-nick-help"')
|
||||||
|
expect(html).toContain('aria-describedby="field-nick-help"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tel: pattern only, no help → has pattern, no aria-describedby', () => {
|
||||||
|
const page = makePage([
|
||||||
|
{ type: 'tel', name: 'phone', label: 'Phone', required: false, pattern: '^\\+?[0-9\\- ]{7,}$' },
|
||||||
|
])
|
||||||
|
const html = exportPage(page).html
|
||||||
|
expect(html).toMatch(/name="phone"[^"]*[^>]*pattern="\^\\\+\?\[0-9\\\- \]\{7,\}\$"/)
|
||||||
|
expect(html).not.toContain('field-phone-help')
|
||||||
|
// ensure no false aria-describedby
|
||||||
|
expect(html).not.toMatch(/name="phone"[^"]*[^>]*aria-describedby=/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('select: help present → select has aria-describedby and help element exists', () => {
|
||||||
|
const page = makePage([
|
||||||
|
{ type: 'select', name: 'plan', label: 'Plan', required: false, options: ['Free','Pro'], help: 'Choose one' },
|
||||||
|
])
|
||||||
|
const html = exportPage(page).html
|
||||||
|
expect(html).toContain('id="field-plan"')
|
||||||
|
expect(html).toContain('aria-describedby="field-plan-help"')
|
||||||
|
expect(html).toContain('id="field-plan-help"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('radio: help present → fieldset has aria-describedby; radios do not individually', () => {
|
||||||
|
const page = makePage([
|
||||||
|
{ type: 'radio', name: 'size', label: 'Size', required: false, options: ['S','M'], help: 'Pick one' },
|
||||||
|
])
|
||||||
|
const html = exportPage(page).html
|
||||||
|
// fieldset wiring
|
||||||
|
expect(html).toContain('<fieldset')
|
||||||
|
expect(html).toContain('aria-describedby="field-size-help"')
|
||||||
|
expect(html).toContain('id="field-size-help"')
|
||||||
|
// radios present
|
||||||
|
expect(html).toContain('type="radio"')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
import { exportPage } from '@/lib/exporter/html'
|
||||||
|
|
||||||
|
const makePage = (): Page =>
|
||||||
|
pageSchema.parse({
|
||||||
|
title: 'Form Help Snapshot',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [],
|
||||||
|
form: {
|
||||||
|
actionUrl: '',
|
||||||
|
fields: [
|
||||||
|
{ type: 'text', name: 'name', label: 'Name', required: true, help: 'Your full name' },
|
||||||
|
{ type: 'email', name: 'email', label: 'Email', required: true },
|
||||||
|
{ type: 'radio', name: 'size', label: 'Size', options: ['S','M'], help: 'Pick one size' },
|
||||||
|
{ type: 'select', name: 'plan', label: 'Plan', options: ['Free','Pro'], help: 'Choose plan' },
|
||||||
|
{ type: 'checkbox', name: 'agree', label: 'Agree', required: true, help: 'Must agree' },
|
||||||
|
],
|
||||||
|
spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
|
||||||
|
},
|
||||||
|
seo: { title: 'SEO', description: 'Desc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
function extractForm(html: string): string {
|
||||||
|
const m = html.match(/<form[\s\S]*?<\/form>/)
|
||||||
|
return m ? m[0]
|
||||||
|
// normalize whitespace for snapshot stability
|
||||||
|
.replace(/\s+\n/g, '\n')
|
||||||
|
.replace(/\n\s+/g, '\n')
|
||||||
|
.trim()
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Exporter - form helptext snapshot', () => {
|
||||||
|
it('contains stable wiring for ids/labels/help/aria', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
const form = extractForm(out.html)
|
||||||
|
|
||||||
|
// name with help
|
||||||
|
expect(form).toContain('id="field-name"')
|
||||||
|
expect(form).toContain('<label for="field-name">Name')
|
||||||
|
expect(form).toContain('id="field-name-help"')
|
||||||
|
expect(form).toContain('aria-describedby="field-name-help"')
|
||||||
|
|
||||||
|
// email present (no help)
|
||||||
|
expect(form).toContain('id="field-email"')
|
||||||
|
expect(form).toContain('<label for="field-email">Email')
|
||||||
|
expect(form).not.toContain('field-email-help')
|
||||||
|
|
||||||
|
// radio group help on fieldset
|
||||||
|
expect(form).toContain('<fieldset')
|
||||||
|
expect(form).toContain('aria-describedby="field-size-help"')
|
||||||
|
expect(form).toContain('id="field-size-help"')
|
||||||
|
expect(form).toContain('type="radio"')
|
||||||
|
|
||||||
|
// select with help
|
||||||
|
expect(form).toContain('id="field-plan"')
|
||||||
|
expect(form).toContain('aria-describedby="field-plan-help"')
|
||||||
|
expect(form).toContain('id="field-plan-help"')
|
||||||
|
|
||||||
|
// checkbox with help
|
||||||
|
expect(form).toContain('id="field-agree"')
|
||||||
|
expect(form).toContain('aria-describedby="field-agree-help"')
|
||||||
|
expect(form).toContain('id="field-agree-help"')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
import { exportPage } from '@/lib/exporter/html'
|
||||||
|
|
||||||
|
const makePage = (withAction = false): Page =>
|
||||||
|
pageSchema.parse({
|
||||||
|
title: 'Form Test',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [],
|
||||||
|
form: {
|
||||||
|
actionUrl: withAction ? 'https://example.com/submit' : '',
|
||||||
|
fields: [
|
||||||
|
{ type: 'text', name: 'name', label: 'Name', required: true },
|
||||||
|
{ type: 'email', name: 'email', label: 'Email', required: true },
|
||||||
|
{ type: 'select', name: 'plan', label: 'Plan', required: false, options: ['A','B'] },
|
||||||
|
{ type: 'radio', name: 'size', label: 'Size', required: false, options: ['S','M','L'] },
|
||||||
|
{ type: 'checkbox', name: 'agree', label: 'Agree', required: true },
|
||||||
|
{ type: 'textarea', name: 'msg', label: 'Message', required: false },
|
||||||
|
],
|
||||||
|
spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
|
||||||
|
},
|
||||||
|
seo: { title: 'Form Test', description: 'desc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
describe('Exporter - form rendering', () => {
|
||||||
|
it('필드 라벨/required/옵션 및 스팸 방지 요소를 렌더링한다 (action 미지정)', () => {
|
||||||
|
const page = makePage(false)
|
||||||
|
const out = exportPage(page)
|
||||||
|
|
||||||
|
// 필수 required
|
||||||
|
expect(out.html).toContain('name="name"')
|
||||||
|
expect(out.html).toContain('required')
|
||||||
|
expect(out.html).toContain('name="email"')
|
||||||
|
|
||||||
|
// select/radio 옵션 존재
|
||||||
|
expect(out.html).toContain('<select')
|
||||||
|
expect(out.html).toContain('<option value="A">A</option>')
|
||||||
|
// 라디오 인풋은 id 속성이 먼저 오므로 type만으로 확인
|
||||||
|
expect(out.html).toContain('type="radio"')
|
||||||
|
|
||||||
|
// checkbox/textarea
|
||||||
|
expect(out.html).toContain('type="checkbox"')
|
||||||
|
expect(out.html).toContain('<textarea')
|
||||||
|
|
||||||
|
// 스팸 방지: honeypot + timestamp
|
||||||
|
expect(out.html).toContain('type="hidden"')
|
||||||
|
expect(out.html).toContain('name="_hp"')
|
||||||
|
expect(out.html).toContain('name="_ts"')
|
||||||
|
|
||||||
|
// action 미지정 → Sheets fallback 힌트
|
||||||
|
expect(out.html).toContain('data-sheets-fallback="true"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('action URL이 있으면 form에 action 속성이 포함된다', () => {
|
||||||
|
const page = makePage(true)
|
||||||
|
const out = exportPage(page)
|
||||||
|
expect(out.html).toContain('action="https://example.com/submit"')
|
||||||
|
expect(out.html).not.toContain('data-sheets-fallback="true"')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, test, expect } from 'vitest'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
import { exportPage } from '@/lib/exporter/html'
|
||||||
|
|
||||||
|
const makePage = (): Page =>
|
||||||
|
pageSchema.parse({
|
||||||
|
title: 'Form Tokens Regression',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [],
|
||||||
|
form: {
|
||||||
|
actionUrl: '',
|
||||||
|
fields: [
|
||||||
|
{ type: 'text', name: 'fullName', label: 'Full Name', required: true, maxLength: 50 },
|
||||||
|
{ type: 'email', name: 'email', label: 'Email', required: true },
|
||||||
|
{ type: 'tel', name: 'phone', label: 'Phone', pattern: '^\\+?[0-9\\- ]{7,}$' },
|
||||||
|
{ type: 'radio', name: 'size', label: 'Size', options: ['S','M','L'] },
|
||||||
|
{ type: 'select', name: 'plan', label: 'Plan', options: ['Free','Pro'] },
|
||||||
|
{ type: 'checkbox', name: 'agree', label: 'Agree to terms', required: true },
|
||||||
|
{ type: 'textarea', name: 'message', label: 'Message' },
|
||||||
|
],
|
||||||
|
spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
|
||||||
|
},
|
||||||
|
seo: { title: 'SEO', description: 'Desc' },
|
||||||
|
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Exporter - form tokens regression', () => {
|
||||||
|
test('renders inputs with required/pattern/maxLength and spam-protection markers', () => {
|
||||||
|
const { html, js } = exportPage(makePage())
|
||||||
|
|
||||||
|
// required + maxlength on text
|
||||||
|
expect(html).toMatch(/name="fullName"[^"]*[^>]*required/)
|
||||||
|
expect(html).toMatch(/name="fullName"[^"]*[^>]*maxlength="50"/)
|
||||||
|
|
||||||
|
// email required
|
||||||
|
expect(html).toMatch(/name="email"[^"]*[^>]*required/)
|
||||||
|
|
||||||
|
// tel pattern
|
||||||
|
expect(html).toMatch(/name="phone"[^"]*[^>]*pattern="\^\\\+\?\[0-9\\\- \]\{7,\}\$"/)
|
||||||
|
|
||||||
|
// radio/select options present
|
||||||
|
// 라디오 인풋은 id 속성이 먼저 오므로 type만으로 확인
|
||||||
|
expect(html).toContain('type="radio"')
|
||||||
|
expect(html).toContain('<select')
|
||||||
|
expect(html).toContain('<option value="Free">Free</option>')
|
||||||
|
|
||||||
|
// checkbox/textarea
|
||||||
|
expect(html).toContain('type="checkbox"')
|
||||||
|
expect(html).toContain('<textarea')
|
||||||
|
|
||||||
|
// spam-protection hidden inputs
|
||||||
|
expect(html).toContain('type="hidden"')
|
||||||
|
expect(html).toContain('name="_hp"')
|
||||||
|
expect(html).toContain('name="_ts"')
|
||||||
|
|
||||||
|
// js fills timestamp
|
||||||
|
expect(js).toMatch(/querySelector\('input\[name="_ts"\]'\)/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
import { exportPage } from '@/lib/exporter/html'
|
||||||
|
|
||||||
|
const makePage = (overrides: Partial<Page> = {}): Page =>
|
||||||
|
pageSchema.parse({
|
||||||
|
title: 'Validate',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [],
|
||||||
|
form: {
|
||||||
|
actionUrl: '',
|
||||||
|
fields: [
|
||||||
|
{ type: 'text', name: 'name', label: 'Name', required: true, pattern: '^[A-Za-z ]+$', maxLength: 30 },
|
||||||
|
{ type: 'email', name: 'email', label: 'Email', required: true },
|
||||||
|
{ type: 'textarea', name: 'msg', label: 'Message', required: false, maxLength: 200 },
|
||||||
|
],
|
||||||
|
spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
|
||||||
|
},
|
||||||
|
seo: { title: 'Validate', description: 'desc' },
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
describe('Exporter - field validation attributes', () => {
|
||||||
|
it('text에 pattern/maxLength, textarea에 maxLength를 렌더링한다', () => {
|
||||||
|
const page = makePage()
|
||||||
|
const out = exportPage(page)
|
||||||
|
|
||||||
|
// text: pattern + maxlength
|
||||||
|
expect(out.html).toContain('name="name"')
|
||||||
|
expect(out.html).toContain('pattern="^[A-Za-z ]+$"')
|
||||||
|
expect(out.html).toContain('maxlength="30"')
|
||||||
|
|
||||||
|
// textarea: maxlength
|
||||||
|
expect(out.html).toContain('<textarea')
|
||||||
|
expect(out.html).toContain('name="msg"')
|
||||||
|
expect(out.html).toContain('maxlength="200"')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import { exportPage } from './html'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
|
||||||
|
const makeFullPage = (): Page =>
|
||||||
|
pageSchema.parse({
|
||||||
|
title: 'Heading Levels All',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [
|
||||||
|
{ type: 'hero', props: { heading: 'Hero Title', subheading: 'Sub', imageUrl: 'https://example.com/hero.png' } },
|
||||||
|
{ type: 'features', props: { items: ['Feat A', 'Feat B'] } },
|
||||||
|
{ type: 'testimonials', props: { items: [{ author: 'Ada', quote: 'Nice' }] } },
|
||||||
|
{ type: 'faq', props: { items: [{ q: 'Q1', a: 'A1' }, { q: 'Q2', a: 'A2' }] } },
|
||||||
|
{ type: 'cta', props: { text: 'Try', href: '#' } },
|
||||||
|
] as Page['sections'],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
seo: { title: 'SEO', description: 'Desc' },
|
||||||
|
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Exporter heading level consistency (full)', () => {
|
||||||
|
test('hero=h1, others=h2, faq items=h3', () => {
|
||||||
|
const { html } = exportPage(makeFullPage())
|
||||||
|
|
||||||
|
// hero
|
||||||
|
expect(html).toContain('<h1 id="hero-heading">Hero Title</h1>')
|
||||||
|
|
||||||
|
// features/testimonials/faq/cta sections labeled by h2
|
||||||
|
expect(html).toContain('<h2 id="features-heading">')
|
||||||
|
expect(html).toContain('<section class="features" aria-labelledby="features-heading">')
|
||||||
|
|
||||||
|
expect(html).toContain('<h2 id="testimonials-heading">')
|
||||||
|
expect(html).toContain('<section class="testimonials" aria-labelledby="testimonials-heading">')
|
||||||
|
|
||||||
|
expect(html).toContain('<h2 id="faq-heading">')
|
||||||
|
expect(html).toContain('<section class="faq" aria-labelledby="faq-heading">')
|
||||||
|
|
||||||
|
expect(html).toContain('<h2 id="cta-heading">')
|
||||||
|
expect(html).toContain('<section class="cta" aria-labelledby="cta-heading">')
|
||||||
|
|
||||||
|
// faq items use h3
|
||||||
|
expect(html).toMatch(/<h3[^>]*>Q1<\/h3>/)
|
||||||
|
expect(html).toMatch(/<h3[^>]*>Q2<\/h3>/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import { exportPage } from './html'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
|
||||||
|
function makePage(): Page {
|
||||||
|
return pageSchema.parse({
|
||||||
|
title: 'My Landing',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#222222', fontFamily: 'Inter' },
|
||||||
|
sections: [
|
||||||
|
{ type: 'hero', props: { heading: 'Welcome', subheading: 'Sub', imageUrl: 'https://example.com/hero.png' } },
|
||||||
|
{ type: 'features', props: { items: ['Fast', 'Reliable'] } },
|
||||||
|
{ type: 'testimonials', props: { items: [{ author: 'Jane', quote: 'Great' }] } },
|
||||||
|
{ type: 'faq', props: { items: [{ q: 'Q1', a: 'A1' }] } },
|
||||||
|
{ type: 'cta', props: { text: 'Start', href: '#' } },
|
||||||
|
] as Page['sections'],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
|
||||||
|
seo: { title: 'SEO', description: 'Desc' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Exporter heading levels', () => {
|
||||||
|
test('hero uses h1, sections use h2, faq questions use h3', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
// hero h1
|
||||||
|
expect(out.html).toMatch(/<section class="hero"[\s\S]*<h1[\s\S]*>Welcome<\/h1>/)
|
||||||
|
// features/testimonials/faq/cta use h2 for section titles
|
||||||
|
expect(out.html).toContain('<h2 id="features-heading">')
|
||||||
|
expect(out.html).toContain('<h2 id="testimonials-heading">')
|
||||||
|
expect(out.html).toContain('<h2 id="faq-heading">')
|
||||||
|
expect(out.html).toContain('<h2 id="cta-heading">')
|
||||||
|
// faq item question headings h3
|
||||||
|
expect(out.html).toMatch(/<section class="faq"[\s\S]*<h3[\s\S]*>Q1<\/h3>/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import type { Page, FormField } from '@/lib/schema/page'
|
||||||
|
import type { AssetManager } from '@/lib/assets/assetManager'
|
||||||
|
|
||||||
|
export type Exported = { html: string; css: string; js: string }
|
||||||
|
export type ExportOptions = { assetManager?: AssetManager }
|
||||||
|
|
||||||
|
function renderFaq(section: { props: { items: Array<{ q: string; a: string }> } }) {
|
||||||
|
const items: Array<{ q: string; a: string }> = Array.isArray(section.props.items) ? section.props.items : []
|
||||||
|
return `
|
||||||
|
<section class="faq" aria-labelledby="faq-heading">
|
||||||
|
<div class="container">
|
||||||
|
<h2 id="faq-heading">FAQ</h2>
|
||||||
|
<ul class="faq-list">
|
||||||
|
${items
|
||||||
|
.map(
|
||||||
|
(it) => `
|
||||||
|
<li class="faq-item">
|
||||||
|
<h3 class="faq-q">${escapeHtml(it.q)}</h3>
|
||||||
|
<p class="faq-a">${escapeHtml(it.a)}</p>
|
||||||
|
</li>`
|
||||||
|
)
|
||||||
|
.join('')}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFeatures(section: { props: { items: string[] } }) {
|
||||||
|
const items: string[] = Array.isArray(section.props?.items) ? section.props.items : []
|
||||||
|
return `
|
||||||
|
<section class="features" aria-labelledby="features-heading">
|
||||||
|
<div class="container">
|
||||||
|
<h2 id="features-heading">Features</h2>
|
||||||
|
<ul class="features-list">
|
||||||
|
${items.map((it) => `<li class="feature-item">${escapeHtml(String(it))}</li>`).join('')}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBenefits(section: { props: { items: string[] } }) {
|
||||||
|
const items: string[] = Array.isArray(section.props?.items) ? section.props.items : []
|
||||||
|
return `
|
||||||
|
<section class="benefits" aria-labelledby="benefits-heading">
|
||||||
|
<div class="container">
|
||||||
|
<h2 id="benefits-heading">Benefits</h2>
|
||||||
|
<ul class="benefits-list">
|
||||||
|
${items.map((it) => `<li class="benefit-item">${escapeHtml(String(it))}</li>`).join('')}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTestimonials(section: { props: { items: Array<{ author: string; quote: string }> } }) {
|
||||||
|
const items: Array<{ author: string; quote: string }> = Array.isArray(section.props?.items)
|
||||||
|
? section.props.items
|
||||||
|
: []
|
||||||
|
return `
|
||||||
|
<section class="testimonials" aria-labelledby="testimonials-heading">
|
||||||
|
<div class="container">
|
||||||
|
<h2 id="testimonials-heading">Testimonials</h2>
|
||||||
|
${items
|
||||||
|
.map(
|
||||||
|
(it) => `
|
||||||
|
<figure class="testimonial">
|
||||||
|
<blockquote>“${escapeHtml(it.quote || '')}”</blockquote>
|
||||||
|
<figcaption>— ${escapeHtml(it.author || '')}</figcaption>
|
||||||
|
</figure>`
|
||||||
|
)
|
||||||
|
.join('')}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCta(section: { props: { text: string; href?: string } }) {
|
||||||
|
const { text, href } = section.props
|
||||||
|
return `
|
||||||
|
<section class="cta" aria-labelledby="cta-heading">
|
||||||
|
<div class="container">
|
||||||
|
<h2 id="cta-heading">CTA</h2>
|
||||||
|
<a class="btn-cta" href="${escapeHtml(href || '#')}">${escapeHtml(text)}</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapeHtml = (s: string) =>
|
||||||
|
s
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
|
||||||
|
function renderHero(section: { props: { heading: string; subheading?: string; imageUrl: string } }, opts?: ExportOptions) {
|
||||||
|
const { heading, subheading } = section.props
|
||||||
|
const am = opts?.assetManager
|
||||||
|
const imageUrlRaw: string = section.props.imageUrl
|
||||||
|
const imageUrl = am ? am.rewriteUrl(imageUrlRaw) : imageUrlRaw
|
||||||
|
return `
|
||||||
|
<section class="hero" aria-labelledby="hero-heading">
|
||||||
|
<div class="container">
|
||||||
|
<div class="text">
|
||||||
|
<h1 id="hero-heading">${escapeHtml(heading)}</h1>
|
||||||
|
${subheading ? `<p>${escapeHtml(subheading)}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="media">
|
||||||
|
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(heading)}" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderField(field: FormField) {
|
||||||
|
const id = `field-${escapeHtml(field.name)}`
|
||||||
|
const helpId = `field-${escapeHtml(field.name)}-help`
|
||||||
|
const hasHelp = !!field.help
|
||||||
|
const helpHtml = hasHelp ? `<div id="${helpId}" class="form-help">${escapeHtml(field.help!)}</div>` : ''
|
||||||
|
|
||||||
|
const pat = 'pattern' in field && field.pattern ? ` pattern="${escapeHtml(field.pattern)}"` : ''
|
||||||
|
const max = 'maxLength' in field && field.maxLength ? ` maxlength="${field.maxLength}"` : ''
|
||||||
|
const describedBy = hasHelp ? ` aria-describedby="${helpId}"` : ''
|
||||||
|
const common = `id="${id}" name="${escapeHtml(field.name)}" aria-label="${escapeHtml(field.label)}" ${field.required ? 'required' : ''}${pat}${max}${describedBy}`
|
||||||
|
|
||||||
|
switch (field.type) {
|
||||||
|
case 'text':
|
||||||
|
case 'email':
|
||||||
|
case 'tel':
|
||||||
|
case 'date':
|
||||||
|
return `
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="${id}">${escapeHtml(field.label)}</label>
|
||||||
|
<input type="${field.type}" ${common} />
|
||||||
|
${helpHtml}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
case 'textarea':
|
||||||
|
return `
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="${id}">${escapeHtml(field.label)}</label>
|
||||||
|
<textarea ${common}></textarea>
|
||||||
|
${helpHtml}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
case 'checkbox':
|
||||||
|
return `
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="${id}">${escapeHtml(field.label)}</label>
|
||||||
|
<input type="checkbox" ${common} />
|
||||||
|
${helpHtml}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
case 'radio': {
|
||||||
|
const { options } = field as Extract<FormField, { type: 'radio' }>
|
||||||
|
const fsDescribed = hasHelp ? ` aria-describedby="${helpId}"` : ''
|
||||||
|
return `
|
||||||
|
<fieldset${fsDescribed}>
|
||||||
|
<legend>${escapeHtml(field.label)}</legend>
|
||||||
|
${options
|
||||||
|
.map(
|
||||||
|
(opt: string, i: number) => {
|
||||||
|
const optId = `${id}-opt-${i}`
|
||||||
|
return `<div><input id="${optId}" type="radio" name="${escapeHtml(field.name)}" aria-label="${escapeHtml(field.label)}" value="${escapeHtml(opt)}" ${field.required ? 'required' : ''}${pat}${max} /><label for="${optId}">${escapeHtml(opt)}</label></div>`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.join('')}
|
||||||
|
${helpHtml}
|
||||||
|
</fieldset>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
case 'select': {
|
||||||
|
const { options } = field as Extract<FormField, { type: 'select' }>
|
||||||
|
return `
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="${id}">${escapeHtml(field.label)}</label>
|
||||||
|
<select ${common}>
|
||||||
|
${options
|
||||||
|
.map((opt: string) => `<option value="${escapeHtml(opt)}">${escapeHtml(opt)}</option>`)
|
||||||
|
.join('')}
|
||||||
|
</select>
|
||||||
|
${helpHtml}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderForm(page: Page) {
|
||||||
|
const { form } = page
|
||||||
|
const hasAction = !!(form.actionUrl && form.actionUrl.trim().length > 0)
|
||||||
|
const hp = form.spamProtection.honeypotFieldName
|
||||||
|
const dataFallback = hasAction ? '' : ' data-sheets-fallback="true"'
|
||||||
|
return `
|
||||||
|
<form method="post"${hasAction ? ` action="${escapeHtml(form.actionUrl!)}"` : ''}${dataFallback}>
|
||||||
|
<input type="hidden" name="${escapeHtml(hp)}" />
|
||||||
|
<input type="hidden" name="_ts" value="" />
|
||||||
|
${form.fields.map(renderField).join('\n')}
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</form>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHead(page: Page) {
|
||||||
|
const ogImage = page.seo.ogImage
|
||||||
|
const analyticsParts: string[] = []
|
||||||
|
const ga = page.analytics?.ga4MeasurementId
|
||||||
|
if (ga && ga.trim()) {
|
||||||
|
analyticsParts.push(`
|
||||||
|
<script async src="https://www.googletagmanager.com/gtag/js?id=${ga}"></script>
|
||||||
|
<script>
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
gtag('js', new Date());
|
||||||
|
gtag('config', '${ga}');
|
||||||
|
</script>`)
|
||||||
|
}
|
||||||
|
const pixel = page.analytics?.metaPixelId
|
||||||
|
if (pixel && pixel.trim()) {
|
||||||
|
analyticsParts.push(`
|
||||||
|
<script>
|
||||||
|
!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?
|
||||||
|
n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;
|
||||||
|
n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;
|
||||||
|
t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window, document,'script',
|
||||||
|
'https://connect.facebook.net/en_US/fbevents.js');
|
||||||
|
fbq('init', '${pixel}');
|
||||||
|
fbq('track', 'PageView');
|
||||||
|
</script>`)
|
||||||
|
}
|
||||||
|
const custom = page.analytics?.customHeadHtml || ''
|
||||||
|
return `
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>${escapeHtml(page.seo.title)}</title>
|
||||||
|
<meta name="description" content="${escapeHtml(page.seo.description)}" />
|
||||||
|
<meta property="og:title" content="${escapeHtml(page.seo.title)}" />
|
||||||
|
<meta property="og:description" content="${escapeHtml(page.seo.description)}" />
|
||||||
|
${ogImage ? `<meta property="og:image" content="${escapeHtml(ogImage)}" />` : ''}
|
||||||
|
${analyticsParts.join('\n')}
|
||||||
|
${custom}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCss(page: Page) {
|
||||||
|
return `
|
||||||
|
:root{ --primary:${page.theme.primaryColor}; }
|
||||||
|
body{ font-family: ${page.theme.fontFamily}, system-ui, -apple-system, Segoe UI, Roboto, Noto Sans, Ubuntu, Cantarell, Helvetica Neue, Arial, "Apple Color Emoji","Segoe UI Emoji"; margin:0; color:#0f172a }
|
||||||
|
a.skip-link{ position:absolute; left:-9999px; top:auto; width:1px; height:1px; overflow:hidden }
|
||||||
|
a.skip-link:focus{ position:absolute; left:1rem; top:1rem; width:auto; height:auto; padding:.5rem 1rem; background:#111827; color:#fff; border-radius:.375rem; z-index:1000 }
|
||||||
|
.container{ max-width: 960px; margin: 0 auto; padding: 2rem }
|
||||||
|
.hero .text{ margin-bottom: 1rem }
|
||||||
|
img{ max-width: 100%; height: auto }
|
||||||
|
label{ display:block; margin: .5rem 0 }
|
||||||
|
button{ background: var(--primary); color:#fff; border:none; padding:.75rem 1rem; border-radius:.5rem }
|
||||||
|
button:focus, a:focus, input:focus, select:focus, textarea:focus{ outline: 3px solid #2563eb; outline-offset:2px }
|
||||||
|
.faq-list{ list-style:none; padding:0; margin:0 }
|
||||||
|
.faq-item{ margin: .75rem 0 }
|
||||||
|
.faq-q{ font-size: 1rem; font-weight:600; }
|
||||||
|
.faq-a{ color:#334155 }
|
||||||
|
.btn-cta{ background: var(--primary); color:#fff; padding:.75rem 1rem; border-radius:.5rem; display:inline-block; text-decoration:none }
|
||||||
|
.features{ background:#f8fafc }
|
||||||
|
.features-list{ list-style:none; padding:0; margin:0; display:grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: .75rem }
|
||||||
|
.feature-item{ background:#fff; border:1px solid #e2e8f0; border-radius:.5rem; padding:.75rem }
|
||||||
|
.testimonials{ background:#ffffff }
|
||||||
|
.testimonial{ border-left:4px solid var(--primary); padding:.5rem 1rem; margin: .75rem 0; background:#fefefe }
|
||||||
|
.testimonial blockquote{ margin:0; font-style:italic; color:#1f2937 }
|
||||||
|
.testimonial figcaption{ color:#475569; margin-top:.25rem }
|
||||||
|
@media (max-width: 640px){ .container{ padding: 1rem } h1{ font-size: 1.5rem } }
|
||||||
|
@media (prefers-reduced-motion: reduce){ *{ transition: none !important; animation: none !important } }
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildJs() {
|
||||||
|
// timestamp 채우기(스팸 방지용 제출 지연 체크용 데이터)
|
||||||
|
return `
|
||||||
|
(function(){
|
||||||
|
var ts = document.querySelector('input[name="_ts"]');
|
||||||
|
if(ts){ ts.value = String(Date.now()); }
|
||||||
|
})();
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportPage(page: Page, opts?: ExportOptions): Exported {
|
||||||
|
const sections = page.sections
|
||||||
|
.map((s: Page['sections'][number]) => {
|
||||||
|
if (s.type === 'hero') return renderHero(s, opts)
|
||||||
|
if (s.type === 'faq') return renderFaq(s)
|
||||||
|
if (s.type === 'cta') return renderCta(s)
|
||||||
|
if (s.type === 'features') return renderFeatures(s)
|
||||||
|
if (s.type === 'benefits') return renderBenefits(s)
|
||||||
|
if (s.type === 'testimonials') return renderTestimonials(s)
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
const formHtml = renderForm(page)
|
||||||
|
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html lang="${escapeHtml(page.locale)}">
|
||||||
|
<head>
|
||||||
|
${buildHead(page)}
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a href="#main" class="skip-link">Skip to content</a>
|
||||||
|
<main id="main" role="main">
|
||||||
|
${sections}
|
||||||
|
${formHtml}
|
||||||
|
</main>
|
||||||
|
<script>${buildJs()}</script>
|
||||||
|
<style>${buildCss(page)}</style>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
return { html, css: buildCss(page), js: buildJs() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
import { exportPage } from '@/lib/exporter/html'
|
||||||
|
import { AssetManager } from '@/lib/assets/assetManager'
|
||||||
|
|
||||||
|
const makeLocalImagePage = async () => {
|
||||||
|
const am = new AssetManager({ maxBytes: 1024 * 1024, allowedExts: ['png'] })
|
||||||
|
const hero = await am.add({ name: 'hero.png', type: 'image/png', bytes: new Uint8Array([1,2,3]) })
|
||||||
|
const page: Page = pageSchema.parse({
|
||||||
|
title: 'Local Image',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
type: 'hero',
|
||||||
|
props: { heading: 'Hi', subheading: '', imageUrl: `local:${hero.originalName}` },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
seo: { title: 'Local Image', description: 'd' },
|
||||||
|
})
|
||||||
|
return { am, page }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HTML Exporter - asset rewrite', () => {
|
||||||
|
it('local: 프리픽스 이미지는 assets/<hash>.<ext>로 치환된다', async () => {
|
||||||
|
const { am, page } = await makeLocalImagePage()
|
||||||
|
const out = exportPage(page, { assetManager: am })
|
||||||
|
|
||||||
|
// assets 경로가 HTML 내 포함되어야 함
|
||||||
|
expect(out.html).toMatch(/assets\/[a-f0-9]{8}\.png/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
import { exportPage } from '@/lib/exporter/html'
|
||||||
|
|
||||||
|
const makeSamplePage = (): Page =>
|
||||||
|
pageSchema.parse({
|
||||||
|
title: 'Sample Landing',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
type: 'hero',
|
||||||
|
props: {
|
||||||
|
heading: 'Welcome',
|
||||||
|
subheading: 'Build fast',
|
||||||
|
imageUrl: 'https://cdn.example.com/hero.png',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
form: {
|
||||||
|
actionUrl: '',
|
||||||
|
fields: [
|
||||||
|
{ type: 'text', name: 'name', label: 'Name', required: true },
|
||||||
|
{ type: 'email', name: 'email', label: 'Email', required: true },
|
||||||
|
{ type: 'checkbox', name: 'agree', label: 'Agree', required: true },
|
||||||
|
],
|
||||||
|
spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
|
||||||
|
},
|
||||||
|
analytics: { ga4MeasurementId: '', metaPixelId: '' },
|
||||||
|
seo: { title: 'Sample Landing', description: 'desc', ogImage: 'https://cdn.example.com/og.png' },
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
describe('HTML Exporter', () => {
|
||||||
|
it('페이지를 정적 HTML/CSS/JS로 내보낸다', () => {
|
||||||
|
const page = makeSamplePage()
|
||||||
|
const out = exportPage(page)
|
||||||
|
|
||||||
|
expect(typeof out.html).toBe('string')
|
||||||
|
expect(typeof out.css).toBe('string')
|
||||||
|
expect(typeof out.js).toBe('string')
|
||||||
|
|
||||||
|
// 기본 요소 포함
|
||||||
|
expect(out.html).toContain('<!DOCTYPE html>')
|
||||||
|
expect(out.html).toContain('<html lang="en">')
|
||||||
|
expect(out.html).toContain('<title>Sample Landing</title>')
|
||||||
|
|
||||||
|
// OG/SEO
|
||||||
|
expect(out.html).toContain('meta property="og:title"')
|
||||||
|
expect(out.html).toContain('meta property="og:image"')
|
||||||
|
|
||||||
|
// Hero 섹션 렌더링
|
||||||
|
expect(out.html).toContain('Welcome')
|
||||||
|
expect(out.html).toContain('Build fast')
|
||||||
|
expect(out.html).toContain('https://cdn.example.com/hero.png')
|
||||||
|
|
||||||
|
// Form 렌더링 및 스팸방지 요소
|
||||||
|
expect(out.html).toContain('name="name"')
|
||||||
|
expect(out.html).toContain('name="email"')
|
||||||
|
expect(out.html).toContain('name="agree"')
|
||||||
|
expect(out.html).toContain('type="hidden"') // honeypot 또는 타임스탬프
|
||||||
|
|
||||||
|
// actionUrl 미지정이면 data-hint 포함(향후 Sheets 연결 힌트)
|
||||||
|
expect(out.html).toContain('data-sheets-fallback="true"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('파일 input은 생성하지 않는다', () => {
|
||||||
|
const page = makeSamplePage()
|
||||||
|
const out = exportPage(page)
|
||||||
|
expect(out.html).not.toMatch(/type=\"file\"/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import { exportPage } from './html'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
|
||||||
|
describe('Exporter - OG image meta', () => {
|
||||||
|
test('includes og:image meta when seo.ogImage is provided', () => {
|
||||||
|
const page: Page = pageSchema.parse({
|
||||||
|
title: 'T',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#000000', fontFamily: 'Inter' },
|
||||||
|
sections: [],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
|
||||||
|
seo: { title: 'SEO', description: 'Desc', ogImage: 'https://example.com/og.png' },
|
||||||
|
})
|
||||||
|
const out = exportPage(page)
|
||||||
|
expect(out.html).toContain('<meta property="og:image" content="https://example.com/og.png" />')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('omits og:image meta when seo.ogImage is empty', () => {
|
||||||
|
const page: Page = pageSchema.parse({
|
||||||
|
title: 'T',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#000000', fontFamily: 'Inter' },
|
||||||
|
sections: [],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
|
||||||
|
seo: { title: 'SEO', description: 'Desc' },
|
||||||
|
})
|
||||||
|
const out = exportPage(page)
|
||||||
|
expect(out.html).not.toContain('og:image')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import { exportPage } from './html'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
|
||||||
|
const makePage = (): Page =>
|
||||||
|
pageSchema.parse({
|
||||||
|
title: 'Snapshot',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [
|
||||||
|
{ type: 'hero', props: { heading: 'Welcome', subheading: 'Sub', imageUrl: 'https://example.com/hero.png' } },
|
||||||
|
{ type: 'features', props: { items: ['One', 'Two'] } },
|
||||||
|
{ type: 'testimonials', props: { items: [{ author: 'Jane', quote: 'Great' }] } },
|
||||||
|
{ type: 'faq', props: { items: [{ q: 'Q1', a: 'A1' }] } },
|
||||||
|
{ type: 'cta', props: { text: 'Go', href: '#' } },
|
||||||
|
] as Page['sections'],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
seo: { title: 'SEO', description: 'Desc' },
|
||||||
|
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Exporter regression snapshot (markers only)', () => {
|
||||||
|
test('combined markers for headings/aria/responsive exist', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
const { html, css } = out
|
||||||
|
// headings/aria markers
|
||||||
|
expect(html).toContain('<h1 id="hero-heading">Welcome</h1>')
|
||||||
|
expect(html).toContain('<section class="features" aria-labelledby="features-heading">')
|
||||||
|
expect(html).toContain('<h2 id="features-heading">')
|
||||||
|
expect(html).toContain('<section class="testimonials" aria-labelledby="testimonials-heading">')
|
||||||
|
expect(html).toContain('<h2 id="testimonials-heading">')
|
||||||
|
expect(html).toContain('<section class="faq" aria-labelledby="faq-heading">')
|
||||||
|
expect(html).toContain('<h2 id="faq-heading">')
|
||||||
|
expect(html).toContain('<section class="cta" aria-labelledby="cta-heading">')
|
||||||
|
expect(html).toContain('<h2 id="cta-heading">')
|
||||||
|
// responsive/a11y CSS markers
|
||||||
|
expect(css).toMatch(/@media \(max-width: 640px\)/)
|
||||||
|
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)/)
|
||||||
|
expect(css).toMatch(/a\.skip-link:focus/)
|
||||||
|
expect(css).toMatch(/outline:\s*3px\s*solid/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, it } from 'vitest'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
import { exportPage } from '@/lib/exporter/html'
|
||||||
|
import { assertSectionA11y } from '@/lib/exporter/testUtils/sectionA11y'
|
||||||
|
|
||||||
|
function makeFullPage(): Page {
|
||||||
|
return pageSchema.parse({
|
||||||
|
title: 'A11y Template',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [
|
||||||
|
{ type: 'hero', props: { heading: 'Welcome', subheading: '', imageUrl: 'https://via.placeholder.com/800x400' } },
|
||||||
|
{ type: 'features', props: { items: ['A','B','C'] } },
|
||||||
|
{ type: 'testimonials', props: { items: [{ author: 'Jane', quote: 'Great!' }] } },
|
||||||
|
{ type: 'faq', props: { items: [{ q: 'Q1', a: 'A1' }] } },
|
||||||
|
{ type: 'cta', props: { text: 'Get Started', href: '#' } },
|
||||||
|
],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
seo: { title: 'SEO', description: 'Desc' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Exporter - section a11y template', () => {
|
||||||
|
it('validates basic a11y landmarks, headings and tokens', () => {
|
||||||
|
const out = exportPage(makeFullPage())
|
||||||
|
assertSectionA11y(out.html)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
import { exportPage } from '@/lib/exporter/html'
|
||||||
|
|
||||||
|
const makePage = (): Page =>
|
||||||
|
pageSchema.parse({
|
||||||
|
title: 'Benefits Test',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [
|
||||||
|
{ type: 'benefits', props: { items: ['Fast setup', 'No code', 'Accessible by default'] } },
|
||||||
|
],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
seo: { title: 'SEO', description: 'Desc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Exporter - benefits section', () => {
|
||||||
|
it('renders benefits as a section with h2 and aria-labelledby + list items', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
const html = out.html
|
||||||
|
expect(html).toContain('<section class="benefits" aria-labelledby="benefits-heading">')
|
||||||
|
expect(html).toContain('<h2 id="benefits-heading">Benefits</h2>')
|
||||||
|
expect(html).toContain('<ul class="benefits-list">')
|
||||||
|
expect(html).toContain('<li class="benefit-item">Fast setup</li>')
|
||||||
|
expect(html).toContain('<li class="benefit-item">No code</li>')
|
||||||
|
expect(html).toContain('<li class="benefit-item">Accessible by default</li>')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { exportPage } from '@/lib/exporter/html'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
|
||||||
|
function makePage(): Page {
|
||||||
|
return pageSchema.parse({
|
||||||
|
title: 'Escape Regression',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [
|
||||||
|
{ type: 'features', props: { items: ['F<&>"', 'Safe'] } },
|
||||||
|
{ type: 'faq', props: { items: [{ q: 'Q<&>"', a: 'A<&>"' }] } },
|
||||||
|
],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
seo: { title: 'SEO', description: 'Desc' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Exporter - HTML escape regression (Features/FAQ)', () => {
|
||||||
|
it('escapes HTML special characters in features items', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
const html = out.html
|
||||||
|
// feature item should contain escaped characters < & > "
|
||||||
|
expect(html).toContain('<li class="feature-item">F<&>"</li>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('escapes HTML special characters in faq q/a', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
const html = out.html
|
||||||
|
expect(html).toContain('<h3 class="faq-q">Q<&>"</h3>')
|
||||||
|
expect(html).toContain('<p class="faq-a">A<&>"</p>')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import { exportPage } from './html'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
|
||||||
|
function makeBase(overrides: Partial<Page>): Page {
|
||||||
|
const base: Page = {
|
||||||
|
title: 'T',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#000000', fontFamily: 'Inter' },
|
||||||
|
sections: [],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
|
||||||
|
seo: { title: 'SEO', description: 'Desc' },
|
||||||
|
}
|
||||||
|
return pageSchema.parse({ ...base, ...overrides })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Exporter - Features and Testimonials sections', () => {
|
||||||
|
test('renders features list with items and classes', () => {
|
||||||
|
const sections = ([
|
||||||
|
{ type: 'features', props: { items: ['One', 'Two', 'Three'] } },
|
||||||
|
] as unknown) as Page['sections']
|
||||||
|
const page = makeBase({ sections })
|
||||||
|
const out = exportPage(page)
|
||||||
|
expect(out.html).toContain('<section class="features" aria-labelledby="features-heading">')
|
||||||
|
expect(out.html).toContain('<ul class="features-list">')
|
||||||
|
expect(out.html).toContain('<li class="feature-item">One</li>')
|
||||||
|
expect(out.html).toContain('<li class="feature-item">Two</li>')
|
||||||
|
expect(out.html).toContain('<li class="feature-item">Three</li>')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renders testimonials with figure/blockquote/figcaption structure', () => {
|
||||||
|
const sections = ([
|
||||||
|
{ type: 'testimonials', props: { items: [
|
||||||
|
{ author: 'Jane', quote: 'Great!' },
|
||||||
|
{ author: 'John', quote: 'Nice.' },
|
||||||
|
] } },
|
||||||
|
] as unknown) as Page['sections']
|
||||||
|
const page = makeBase({ sections })
|
||||||
|
const out = exportPage(page)
|
||||||
|
expect(out.html).toContain('<section class="testimonials" aria-labelledby="testimonials-heading">')
|
||||||
|
expect(out.html).toContain('<figure class="testimonial">')
|
||||||
|
expect(out.html).toContain('<blockquote>“Great!”</blockquote>')
|
||||||
|
expect(out.html).toContain('<figcaption>— Jane</figcaption>')
|
||||||
|
expect(out.html).toContain('<blockquote>“Nice.”</blockquote>')
|
||||||
|
expect(out.html).toContain('<figcaption>— John</figcaption>')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
import { exportPage } from '@/lib/exporter/html'
|
||||||
|
|
||||||
|
function makePage(): Page {
|
||||||
|
return pageSchema.parse({
|
||||||
|
title: 'Sections Regression',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [
|
||||||
|
{ type: 'hero', props: { heading: 'Welcome', subheading: '', imageUrl: 'https://via.placeholder.com/800x400' } },
|
||||||
|
{ type: 'features', props: { items: ['F1','F2'] } },
|
||||||
|
{ type: 'benefits', props: { items: ['B1','B2','B3'] } },
|
||||||
|
{ type: 'testimonials', props: { items: [{ author: 'Ada', quote: 'Great' }] } },
|
||||||
|
{ type: 'faq', props: { items: [{ q: 'Q1', a: 'A1' }] } },
|
||||||
|
{ type: 'cta', props: { text: 'Start', href: '#' } },
|
||||||
|
],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
seo: { title: 'SEO', description: 'Desc' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Exporter - sections regression (includes benefits)', () => {
|
||||||
|
it('renders sections with correct ARIA/heading/list structures', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
const html = out.html
|
||||||
|
|
||||||
|
// hero h1
|
||||||
|
expect(html).toMatch(/<section class="hero" aria-labelledby="hero-heading">/)
|
||||||
|
expect(html).toMatch(/<h1 id="hero-heading">Welcome<\/h1>/)
|
||||||
|
|
||||||
|
// features
|
||||||
|
expect(html).toMatch(/<section class="features" aria-labelledby="features-heading">/)
|
||||||
|
expect(html).toMatch(/<h2 id="features-heading">Features<\/h2>/)
|
||||||
|
expect(html).toMatch(/<ul class="features-list">[\s\S]*<li class="feature-item">F1<\/li>[\s\S]*<li class="feature-item">F2<\/li>[\s\S]*<\/ul>/)
|
||||||
|
|
||||||
|
// benefits
|
||||||
|
expect(html).toMatch(/<section class="benefits" aria-labelledby="benefits-heading">/)
|
||||||
|
expect(html).toMatch(/<h2 id="benefits-heading">Benefits<\/h2>/)
|
||||||
|
expect(html).toMatch(/<ul class="benefits-list">[\s\S]*<li class="benefit-item">B1<\/li>[\s\S]*<li class="benefit-item">B2<\/li>[\s\S]*<li class="benefit-item">B3<\/li>[\s\S]*<\/ul>/)
|
||||||
|
|
||||||
|
// testimonials
|
||||||
|
expect(html).toMatch(/<section class="testimonials" aria-labelledby="testimonials-heading">/)
|
||||||
|
expect(html).toMatch(/<h2 id="testimonials-heading">Testimonials<\/h2>/)
|
||||||
|
|
||||||
|
// faq with h3
|
||||||
|
expect(html).toMatch(/<section class="faq" aria-labelledby="faq-heading">/)
|
||||||
|
expect(html).toMatch(/<h2 id="faq-heading">FAQ<\/h2>/)
|
||||||
|
expect(html).toMatch(/<h3 class="faq-q">Q1<\/h3>/)
|
||||||
|
|
||||||
|
// cta
|
||||||
|
expect(html).toMatch(/<section class="cta" aria-labelledby="cta-heading">/)
|
||||||
|
expect(html).toMatch(/<h2 id="cta-heading">CTA<\/h2>/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { exportPage } from '@/lib/exporter/html'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
|
||||||
|
function makePage(): Page {
|
||||||
|
return pageSchema.parse({
|
||||||
|
title: 'Sections Regression CTA/Testimonials',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [
|
||||||
|
{ type: 'testimonials', props: { items: [{ author: 'Linus', quote: 'Simple and fast' }] } },
|
||||||
|
{ type: 'cta', props: { text: 'Get Started', href: '/start' } },
|
||||||
|
],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
seo: { title: 'SEO', description: 'Desc' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Exporter - CTA and Testimonials regression', () => {
|
||||||
|
it('renders testimonials figure/blockquote/figcaption structure', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
const html = out.html
|
||||||
|
expect(html).toMatch(/<section class="testimonials" aria-labelledby="testimonials-heading">/)
|
||||||
|
expect(html).toMatch(/<h2 id="testimonials-heading">Testimonials<\/h2>/)
|
||||||
|
expect(html).toMatch(/<figure class="testimonial">[\s\S]*<blockquote>“Simple and fast”<\/blockquote>[\s\S]*<figcaption>— Linus<\/figcaption>[\s\S]*<\/figure>/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders CTA link with correct class and href', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
const html = out.html
|
||||||
|
expect(html).toMatch(/<section class="cta" aria-labelledby="cta-heading">/)
|
||||||
|
expect(html).toMatch(/<h2 id="cta-heading">CTA<\/h2>/)
|
||||||
|
expect(html).toMatch(/<a class="btn-cta" href="\/start">Get Started<\/a>/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { exportPage } from '@/lib/exporter/html'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
|
||||||
|
function makePage(): Page {
|
||||||
|
return pageSchema.parse({
|
||||||
|
title: 'Sections Regression Features/FAQ',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [
|
||||||
|
{ type: 'features', props: { items: ['F1', 'F2', 'F3'] } },
|
||||||
|
{ type: 'faq', props: { items: [{ q: 'Q1', a: 'A1' }, { q: 'Q2', a: 'A2' }] } },
|
||||||
|
],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
seo: { title: 'SEO', description: 'Desc' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Exporter - Features/FAQ regression (structure/classes/count)', () => {
|
||||||
|
it('renders features with list, class names, and correct item count', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
const html = out.html
|
||||||
|
expect(html).toMatch(/<section class="features" aria-labelledby="features-heading">/)
|
||||||
|
expect(html).toMatch(/<h2 id="features-heading">Features<\/h2>/)
|
||||||
|
expect(html).toMatch(/<ul class="features-list">/)
|
||||||
|
const featureItems = html.match(/<li class="feature-item">/g) || []
|
||||||
|
expect(featureItems.length).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders faq with list, h3 question class, and correct item count', () => {
|
||||||
|
const out = exportPage(makePage())
|
||||||
|
const html = out.html
|
||||||
|
expect(html).toMatch(/<section class="faq" aria-labelledby="faq-heading">/)
|
||||||
|
expect(html).toMatch(/<h2 id="faq-heading">FAQ<\/h2>/)
|
||||||
|
expect(html).toMatch(/<ul class="faq-list">/)
|
||||||
|
const qCount = html.match(/<h3 class="faq-q">/g) || []
|
||||||
|
const aCount = html.match(/<p class="faq-a">/g) || []
|
||||||
|
expect(qCount.length).toBe(2)
|
||||||
|
expect(aCount.length).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
import { exportPage } from '@/lib/exporter/html'
|
||||||
|
|
||||||
|
const makePage = (): Page =>
|
||||||
|
pageSchema.parse({
|
||||||
|
title: 'Sections',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [
|
||||||
|
{ type: 'faq', props: { items: [ { q: 'Q1?', a: 'A1.' }, { q: 'Q2?', a: 'A2.' } ] } },
|
||||||
|
{ type: 'cta', props: { text: 'Get Started', href: '#start' } },
|
||||||
|
],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
seo: { title: 'Sections', description: 'desc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
describe('Exporter - FAQ/CTA sections', () => {
|
||||||
|
it('FAQ와 CTA 섹션을 렌더링한다', () => {
|
||||||
|
const page = makePage()
|
||||||
|
const out = exportPage(page)
|
||||||
|
|
||||||
|
// FAQ
|
||||||
|
expect(out.html).toContain('Q1?')
|
||||||
|
expect(out.html).toContain('A1.')
|
||||||
|
expect(out.html).toContain('Q2?')
|
||||||
|
expect(out.html).toContain('A2.')
|
||||||
|
|
||||||
|
// CTA
|
||||||
|
expect(out.html).toContain('Get Started')
|
||||||
|
expect(out.html).toContain('#start')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { expect } from 'vitest'
|
||||||
|
|
||||||
|
export function assertSectionA11y(html: string) {
|
||||||
|
// basic landmarks
|
||||||
|
expect(html).toContain('<main id="main" role="main">')
|
||||||
|
expect(html).toContain('class="skip-link"')
|
||||||
|
|
||||||
|
// hero h1
|
||||||
|
expect(html).toMatch(/<h1 id="hero-heading">[\s\S]*<\/h1>/)
|
||||||
|
|
||||||
|
// features h2 and aria-labelledby
|
||||||
|
expect(html).toMatch(/<h2 id="features-heading">Features<\/h2>/)
|
||||||
|
expect(html).toMatch(/<section class="features" aria-labelledby="features-heading">/)
|
||||||
|
|
||||||
|
// testimonials h2 and aria-labelledby
|
||||||
|
expect(html).toMatch(/<h2 id="testimonials-heading">Testimonials<\/h2>/)
|
||||||
|
expect(html).toMatch(/<section class="testimonials" aria-labelledby="testimonials-heading">/)
|
||||||
|
|
||||||
|
// FAQ heading levels and structure
|
||||||
|
expect(html).toMatch(/<h2 id="faq-heading">FAQ<\/h2>/)
|
||||||
|
// at least one h3 under FAQ
|
||||||
|
expect(html).toMatch(/<h3 class="faq-q">[\s\S]*<\/h3>/)
|
||||||
|
|
||||||
|
// CTA h2 & aria-labelledby
|
||||||
|
expect(html).toMatch(/<h2 id="cta-heading">CTA<\/h2>/)
|
||||||
|
expect(html).toMatch(/<section class="cta" aria-labelledby="cta-heading">/)
|
||||||
|
|
||||||
|
// focus-visible tokens (CSS)
|
||||||
|
expect(html).toContain('outline: 3px solid')
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import { exportPage } from './html'
|
||||||
|
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||||
|
|
||||||
|
const makePage = (): Page =>
|
||||||
|
pageSchema.parse({
|
||||||
|
title: 'A11y Tokens',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||||
|
sections: [],
|
||||||
|
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||||
|
seo: { title: 'SEO', description: 'Desc' },
|
||||||
|
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('A11y color/typography token smoke', () => {
|
||||||
|
test('base text color, font-family and mobile h1 size markers exist', () => {
|
||||||
|
const { css } = exportPage(makePage())
|
||||||
|
|
||||||
|
// base text color token presence
|
||||||
|
expect(css.replace(/\s+/g, ' ')).toMatch(/body\{[^}]*color:\s*#0f172a/i)
|
||||||
|
|
||||||
|
// font-family token (contains "Inter" and common fallbacks)
|
||||||
|
expect(css).toMatch(/body\{[^}]*font-family:\s*Inter/i)
|
||||||
|
|
||||||
|
// mobile h1 size within max-width:640px media query
|
||||||
|
expect(css).toMatch(/@media \(max-width: 640px\)[\s\S]*h1\{[^}]*font-size:\s*1\.5rem/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import JSZip from 'jszip'
|
||||||
|
import { buildZip } from '@/lib/exporter/zip'
|
||||||
|
|
||||||
|
const textEncoder = new TextEncoder()
|
||||||
|
|
||||||
|
describe('ZIP 유틸', () => {
|
||||||
|
it('index.html / styles.css / app.js 와 assets/* 를 포함한 ZIP을 생성한다', async () => {
|
||||||
|
const html = '<!DOCTYPE html><html><head><title>x</title></head><body>hi</body></html>'
|
||||||
|
const css = 'body{color:#000}'
|
||||||
|
const js = 'console.log(1)'
|
||||||
|
const assets: Record<string, Uint8Array> = {
|
||||||
|
'assets/a1.png': new Uint8Array([1,2,3]),
|
||||||
|
'assets/b2.svg': textEncoder.encode('<svg></svg>'),
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipBytes = await buildZip({ html, css, js, assets })
|
||||||
|
|
||||||
|
const zip = await JSZip.loadAsync(zipBytes)
|
||||||
|
const names = Object.keys(zip.files)
|
||||||
|
|
||||||
|
expect(names).toContain('index.html')
|
||||||
|
expect(names).toContain('styles.css')
|
||||||
|
expect(names).toContain('app.js')
|
||||||
|
expect(names).toContain('assets/a1.png')
|
||||||
|
expect(names).toContain('assets/b2.svg')
|
||||||
|
|
||||||
|
const htmlContent = await zip.files['index.html'].async('string')
|
||||||
|
expect(htmlContent).toContain('<title>x</title>')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import JSZip from 'jszip'
|
||||||
|
|
||||||
|
export type BuildZipInput = {
|
||||||
|
html: string
|
||||||
|
css: string
|
||||||
|
js: string
|
||||||
|
assets?: Record<string, Uint8Array>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildZip(input: BuildZipInput): Promise<Uint8Array> {
|
||||||
|
const zip = new JSZip()
|
||||||
|
zip.file('index.html', input.html)
|
||||||
|
zip.file('styles.css', input.css)
|
||||||
|
zip.file('app.js', input.js)
|
||||||
|
if (input.assets) {
|
||||||
|
for (const [path, bytes] of Object.entries(input.assets)) {
|
||||||
|
// In Node/Vitest, use Buffer for robust binary handling
|
||||||
|
const buf = Buffer.from(bytes)
|
||||||
|
zip.file(path, buf as unknown as Buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const content = await zip.generateAsync({ type: 'uint8array' })
|
||||||
|
return content
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import 'server-only'
|
||||||
|
import { locales, type AppLocale } from '@/lib/i18n/locales'
|
||||||
|
|
||||||
|
export async function getMessages(locale: AppLocale) {
|
||||||
|
const loc = locales.includes(locale) ? locale : 'en'
|
||||||
|
const messages = (await import(`@/messages/${loc}.json`)).default
|
||||||
|
return messages as Record<string, string>
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { createI18n } from '@/lib/i18n'
|
||||||
|
|
||||||
|
describe('i18n util', () => {
|
||||||
|
const i18n = createI18n({
|
||||||
|
defaultLocale: 'en',
|
||||||
|
messages: {
|
||||||
|
en: { hello: 'Hello {name}', onlyEn: 'Only EN' },
|
||||||
|
ko: { hello: '안녕하세요 {name}' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
it('로케일별 번역과 변수 치환을 지원한다', () => {
|
||||||
|
expect(i18n.t('en', 'hello', { name: 'Jay' })).toBe('Hello Jay')
|
||||||
|
expect(i18n.t('ko', 'hello', { name: '제이' })).toBe('안녕하세요 제이')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('키가 없으면 기본 로케일로 폴백한다', () => {
|
||||||
|
expect(i18n.t('ko', 'onlyEn')).toBe('Only EN')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('완전히 없는 키는 키 그대로 반환한다', () => {
|
||||||
|
expect(i18n.t('en', 'missing.key')).toBe('missing.key')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
type Messages = Record<string, Record<string, string>>
|
||||||
|
|
||||||
|
export function createI18n({ defaultLocale, messages }: { defaultLocale: string; messages: Messages }) {
|
||||||
|
function format(template: string, params?: Record<string, string | number>) {
|
||||||
|
if (!params) return template
|
||||||
|
return template.replace(/\{(\w+)\}/g, (_, k) => (params[k] !== undefined ? String(params[k]) : `{${k}}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessage(locale: string, key: string): string | undefined {
|
||||||
|
const m = messages[locale] || {}
|
||||||
|
if (m[key] !== undefined) return m[key]
|
||||||
|
const base = messages[defaultLocale] || {}
|
||||||
|
return base[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
function t(locale: string, key: string, params?: Record<string, string | number>): string {
|
||||||
|
const msg = getMessage(locale, key)
|
||||||
|
if (typeof msg === 'string') return format(msg, params)
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
return { t }
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const locales = ['en', 'ko'] as const
|
||||||
|
export type AppLocale = typeof locales[number]
|
||||||
|
export const defaultLocale: AppLocale = 'en'
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { generateAppsScriptTemplate } from '@/lib/integrations/sheets'
|
||||||
|
|
||||||
|
describe('Google Sheets Apps Script 템플릿', () => {
|
||||||
|
it('시트 ID와 허용 오리진으로 Apps Script 코드와 가이드를 생성한다', () => {
|
||||||
|
const out = generateAppsScriptTemplate({
|
||||||
|
sheetId: '1AbcDEFghiJKL_sheet_id',
|
||||||
|
allowedOrigins: ['https://example.com', 'http://localhost:3000'],
|
||||||
|
minSubmitSeconds: 2,
|
||||||
|
honeypotFieldName: '_hp',
|
||||||
|
sheetName: 'Responses',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(typeof out.code).toBe('string')
|
||||||
|
expect(typeof out.readme).toBe('string')
|
||||||
|
|
||||||
|
// 필수 키워드 포함 검증
|
||||||
|
expect(out.code).toContain('function doPost(e)')
|
||||||
|
expect(out.code).toContain('SpreadsheetApp.openById')
|
||||||
|
expect(out.code).toContain('e.postData')
|
||||||
|
expect(out.code).toContain('ContentService.createTextOutput')
|
||||||
|
expect(out.code).toContain('Access-Control-Allow-Origin')
|
||||||
|
|
||||||
|
// 환경설정 치환값 반영
|
||||||
|
expect(out.code).toContain('1AbcDEFghiJKL_sheet_id')
|
||||||
|
expect(out.code).toContain('Responses')
|
||||||
|
expect(out.code).toContain('_hp')
|
||||||
|
expect(out.code).toContain('minSubmitSeconds')
|
||||||
|
|
||||||
|
// 배포 가이드에 웹 앱 배포 안내 포함
|
||||||
|
expect(out.readme).toMatch(/Deploy.*as Web app/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
type TemplateParams = {
|
||||||
|
sheetId: string
|
||||||
|
allowedOrigins: string[]
|
||||||
|
minSubmitSeconds: number
|
||||||
|
honeypotFieldName: string
|
||||||
|
sheetName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateAppsScriptTemplate(params: TemplateParams) {
|
||||||
|
const { sheetId, allowedOrigins, minSubmitSeconds, honeypotFieldName, sheetName = 'Responses' } = params
|
||||||
|
|
||||||
|
const code = `/**
|
||||||
|
* Apps Script Web App for accepting HTML form submissions and writing to Google Sheets.
|
||||||
|
* Deployed as Web App with Anyone (even anonymous) or Anyone within domain access, as needed.
|
||||||
|
*/
|
||||||
|
const CONFIG = {
|
||||||
|
SHEET_ID: '${sheetId}',
|
||||||
|
SHEET_NAME: '${sheetName}',
|
||||||
|
ALLOWED_ORIGINS: ${JSON.stringify(allowedOrigins)},
|
||||||
|
minSubmitSeconds: ${minSubmitSeconds},
|
||||||
|
HONEYPOT: '${honeypotFieldName}',
|
||||||
|
};
|
||||||
|
|
||||||
|
function doOptions(e) {
|
||||||
|
return corsResponse('', 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
function doPost(e) {
|
||||||
|
try {
|
||||||
|
const origin = (e?.parameter?.origin) || (e?.headers && e.headers['Origin']) || '';
|
||||||
|
const now = Date.now();
|
||||||
|
const params = parseBody(e);
|
||||||
|
|
||||||
|
// CORS check
|
||||||
|
enforceCors(origin);
|
||||||
|
|
||||||
|
// Spam protection: honeypot must be empty
|
||||||
|
if (params[CONFIG.HONEYPOT]) {
|
||||||
|
return corsResponse(JSON.stringify({ ok: true }), 200, origin); // pretend success
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spam protection: minimum submit time
|
||||||
|
const ts = Number(params['_ts'] || 0);
|
||||||
|
if (!isFinite(ts) || now - ts < CONFIG.minSubmitSeconds * 1000) {
|
||||||
|
return corsResponse(JSON.stringify({ ok: true }), 200, origin); // pretend success
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to sheet
|
||||||
|
const ss = SpreadsheetApp.openById(CONFIG.SHEET_ID);
|
||||||
|
const sh = ss.getSheetByName(CONFIG.SHEET_NAME) || ss.getSheets()[0];
|
||||||
|
const entries = Object.keys(params).filter(k => k !== CONFIG.HONEYPOT);
|
||||||
|
const values = entries.map(k => String(params[k]));
|
||||||
|
sh.appendRow([new Date()].concat(values));
|
||||||
|
|
||||||
|
return corsResponse(JSON.stringify({ ok: true }), 200, origin);
|
||||||
|
} catch (err) {
|
||||||
|
return corsResponse(JSON.stringify({ ok: false, error: String(err) }), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBody(e) {
|
||||||
|
// Accept form-urlencoded or JSON
|
||||||
|
if (e?.postData?.type === 'application/json') {
|
||||||
|
try { return JSON.parse(e.postData.contents || '{}'); } catch (_) { return {}; }
|
||||||
|
}
|
||||||
|
// Parse form-urlencoded
|
||||||
|
const raw = (e?.postData?.contents || '');
|
||||||
|
const out = {};
|
||||||
|
raw.split('&').forEach(p => {
|
||||||
|
const [k, v] = p.split('=');
|
||||||
|
if (k) out[decodeURIComponent(k)] = decodeURIComponent((v || '').replace(/\+/g, ' '));
|
||||||
|
});
|
||||||
|
// Also include e.parameter for safety
|
||||||
|
if (e?.parameter) {
|
||||||
|
Object.keys(e.parameter).forEach(k => { if (!(k in out)) out[k] = e.parameter[k]; });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enforceCors(origin) {
|
||||||
|
if (!CONFIG.ALLOWED_ORIGINS || CONFIG.ALLOWED_ORIGINS.length === 0) return;
|
||||||
|
if (CONFIG.ALLOWED_ORIGINS.indexOf(origin) === -1) {
|
||||||
|
throw new Error('CORS: Origin not allowed: ' + origin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function corsResponse(body, status, origin) {
|
||||||
|
const output = ContentService.createTextOutput(body).setMimeType(ContentService.MimeType.JSON);
|
||||||
|
const resp = HtmlService.createHtmlOutput().getResponse();
|
||||||
|
resp.setContent(output.getContent());
|
||||||
|
resp.setResponseCode(status || 200);
|
||||||
|
resp.setHeader('Access-Control-Allow-Origin', origin || '*');
|
||||||
|
resp.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
||||||
|
resp.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const readme = `# Google Apps Script Web App (Google Sheets 저장)
|
||||||
|
|
||||||
|
이 스크립트는 HTML 폼 제출을 받아 Google 스프레드시트에 행을 추가합니다.
|
||||||
|
|
||||||
|
## 준비물
|
||||||
|
- Google 스프레드시트 생성 후 시트 ID 확인(주소창의 /d/ 와 /edit 사이)
|
||||||
|
- Apps Script 에디터 열기(스프레드시트 -> 확장 프로그램 -> Apps Script)
|
||||||
|
|
||||||
|
## 사용 방법
|
||||||
|
1. 새 프로젝트 생성 후, Code.gs 에 아래 코드 붙여넣기
|
||||||
|
2. CONFIG 내 값을 편집
|
||||||
|
- SHEET_ID: ${sheetId}
|
||||||
|
- SHEET_NAME: ${sheetName}
|
||||||
|
- ALLOWED_ORIGINS: ${JSON.stringify(allowedOrigins)}
|
||||||
|
- MIN_SUBMIT_SECONDS: ${minSubmitSeconds}
|
||||||
|
- HONEYPOT: ${honeypotFieldName}
|
||||||
|
3. 배포
|
||||||
|
- 상단 메뉴: Deploy -> Deploy as Web app
|
||||||
|
- 새 배포 -> Entry point: doPost
|
||||||
|
- Access: Anyone (권장) 또는 필요 정책에 맞게 설정
|
||||||
|
- Deploy를 눌러 Web app URL 획득
|
||||||
|
4. 빌더에서 폼 action URL 에 위 Web app URL 입력
|
||||||
|
|
||||||
|
## CORS 및 스팸 방지
|
||||||
|
- Access-Control-Allow-Origin 헤더가 설정됩니다.
|
||||||
|
- 허용 오리진만 통과하도록 enforceCors를 사용합니다.
|
||||||
|
- honeypot 필드와 제출 타임스탬프(_ts)로 기본 스팸 방지 동작을 합니다.
|
||||||
|
|
||||||
|
## 테스트
|
||||||
|
- doPost, SpreadsheetApp 관련 호출은 Apps Script 런타임에서 동작합니다.
|
||||||
|
`
|
||||||
|
|
||||||
|
return { code, readme }
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
|
||||||
|
// TDD: 아직 구현되지 않은 스키마를 가정
|
||||||
|
import { pageSchema } from '@/lib/schema/page'
|
||||||
|
|
||||||
|
describe('pageSchema', () => {
|
||||||
|
it('유효한 랜딩 페이지 스키마를 통과시킨다', () => {
|
||||||
|
const valid = {
|
||||||
|
title: 'My Landing',
|
||||||
|
locale: 'en',
|
||||||
|
theme: {
|
||||||
|
primaryColor: '#0ea5e9',
|
||||||
|
fontFamily: 'Inter',
|
||||||
|
},
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
type: 'hero',
|
||||||
|
props: {
|
||||||
|
heading: 'Hello',
|
||||||
|
subheading: 'World',
|
||||||
|
imageUrl: 'https://cdn.example.com/hero.png',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
form: {
|
||||||
|
actionUrl: '', // 빈 문자열이면 Sheets로 제출(향후 로직)
|
||||||
|
fields: [
|
||||||
|
{ type: 'text', name: 'name', label: 'Name', required: true },
|
||||||
|
{ type: 'email', name: 'email', label: 'Email', required: true },
|
||||||
|
{ type: 'checkbox', name: 'agree', label: 'Agree', required: true },
|
||||||
|
],
|
||||||
|
spamProtection: {
|
||||||
|
honeypotFieldName: '_hp',
|
||||||
|
minSubmitSeconds: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
analytics: {
|
||||||
|
ga4MeasurementId: '',
|
||||||
|
metaPixelId: '',
|
||||||
|
customHeadHtml: '',
|
||||||
|
},
|
||||||
|
seo: {
|
||||||
|
title: 'My Landing',
|
||||||
|
description: 'Best product',
|
||||||
|
ogImage: 'https://cdn.example.com/og.png',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = pageSchema.parse(valid)
|
||||||
|
expect(parsed.title).toBe('My Landing')
|
||||||
|
expect(parsed.sections.length).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('파일 업로드 필드는 허용하지 않는다', () => {
|
||||||
|
const invalid = {
|
||||||
|
title: 'No File Please',
|
||||||
|
locale: 'en',
|
||||||
|
theme: { primaryColor: '#000000', fontFamily: 'Inter' },
|
||||||
|
sections: [],
|
||||||
|
form: {
|
||||||
|
actionUrl: '',
|
||||||
|
fields: [
|
||||||
|
// 파일 타입은 금지
|
||||||
|
{ type: 'file', name: 'attachment', label: 'Upload' },
|
||||||
|
],
|
||||||
|
spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
|
||||||
|
},
|
||||||
|
analytics: {},
|
||||||
|
seo: { title: 'x', description: '', ogImage: '' },
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => pageSchema.parse(invalid)).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const hexColor = z
|
||||||
|
.string()
|
||||||
|
.regex(/^#([0-9a-fA-F]{3}){1,2}$/i, 'invalid hex color')
|
||||||
|
|
||||||
|
const url = z.string().url()
|
||||||
|
|
||||||
|
const sectionHeroSchema = z.object({
|
||||||
|
type: z.literal('hero'),
|
||||||
|
props: z.object({
|
||||||
|
heading: z.string().min(1),
|
||||||
|
subheading: z.string().optional().default(''),
|
||||||
|
imageUrl: url,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const sectionFaqSchema = z.object({
|
||||||
|
type: z.literal('faq'),
|
||||||
|
props: z.object({
|
||||||
|
items: z.array(z.object({ q: z.string().min(1), a: z.string().min(1) })).min(1),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const sectionCtaSchema = z.object({
|
||||||
|
type: z.literal('cta'),
|
||||||
|
props: z.object({ text: z.string().min(1), href: z.string().optional().default('#') }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const sectionFeaturesSchema = z.object({
|
||||||
|
type: z.literal('features'),
|
||||||
|
props: z.object({ items: z.array(z.string().min(1)).min(1) }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const sectionTestimonialsSchema = z.object({
|
||||||
|
type: z.literal('testimonials'),
|
||||||
|
props: z.object({ items: z.array(z.object({ author: z.string().min(1), quote: z.string().min(1) })).min(1) }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const sectionBenefitsSchema = z.object({
|
||||||
|
type: z.literal('benefits'),
|
||||||
|
props: z.object({ items: z.array(z.string().min(1)).min(1) }),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 확장: hero, faq, cta, features, testimonials 허용
|
||||||
|
const sectionSchema = z.union([
|
||||||
|
sectionHeroSchema,
|
||||||
|
sectionFaqSchema,
|
||||||
|
sectionCtaSchema,
|
||||||
|
sectionFeaturesSchema,
|
||||||
|
sectionTestimonialsSchema,
|
||||||
|
sectionBenefitsSchema,
|
||||||
|
])
|
||||||
|
|
||||||
|
const baseField = {
|
||||||
|
name: z.string().min(1),
|
||||||
|
label: z.string().min(1),
|
||||||
|
required: z.boolean().optional().default(false),
|
||||||
|
pattern: z.string().optional(),
|
||||||
|
maxLength: z.number().int().positive().optional(),
|
||||||
|
help: z.string().optional(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const textField = z.object({ type: z.literal('text'), ...baseField })
|
||||||
|
const emailField = z.object({ type: z.literal('email'), ...baseField })
|
||||||
|
const telField = z.object({ type: z.literal('tel'), ...baseField })
|
||||||
|
const textareaField = z.object({ type: z.literal('textarea'), ...baseField })
|
||||||
|
const dateField = z.object({ type: z.literal('date'), ...baseField })
|
||||||
|
const checkboxField = z.object({ type: z.literal('checkbox'), ...baseField })
|
||||||
|
const radioField = z.object({
|
||||||
|
type: z.literal('radio'),
|
||||||
|
...baseField,
|
||||||
|
options: z.array(z.string().min(1)).min(1),
|
||||||
|
})
|
||||||
|
const selectField = z.object({
|
||||||
|
type: z.literal('select'),
|
||||||
|
...baseField,
|
||||||
|
options: z.array(z.string().min(1)).min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 파일 업로드는 제외
|
||||||
|
export const formFieldSchema = z.union([
|
||||||
|
textField,
|
||||||
|
emailField,
|
||||||
|
telField,
|
||||||
|
textareaField,
|
||||||
|
dateField,
|
||||||
|
checkboxField,
|
||||||
|
radioField,
|
||||||
|
selectField,
|
||||||
|
])
|
||||||
|
|
||||||
|
export const pageSchema = z.object({
|
||||||
|
title: z.string().min(1),
|
||||||
|
locale: z.string().min(2),
|
||||||
|
theme: z.object({
|
||||||
|
primaryColor: hexColor,
|
||||||
|
fontFamily: z.string().min(1),
|
||||||
|
}),
|
||||||
|
sections: z.array(sectionSchema),
|
||||||
|
form: z.object({
|
||||||
|
actionUrl: z.string().optional().default(''),
|
||||||
|
fields: z.array(formFieldSchema).min(0),
|
||||||
|
spamProtection: z.object({
|
||||||
|
honeypotFieldName: z.string().min(1).default('_hp'),
|
||||||
|
minSubmitSeconds: z.number().min(0).default(2),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
analytics: z
|
||||||
|
.object({
|
||||||
|
ga4MeasurementId: z.string().optional().default(''),
|
||||||
|
metaPixelId: z.string().optional().default(''),
|
||||||
|
customHeadHtml: z.string().optional().default(''),
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.default({ ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' }),
|
||||||
|
seo: z.object({
|
||||||
|
title: z.string().min(1),
|
||||||
|
description: z.string().min(1),
|
||||||
|
ogImage: url.optional(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type Page = z.infer<typeof pageSchema>
|
||||||
|
export type FormField = z.infer<typeof formFieldSchema>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { createFormStore, type FormFieldInput, type FormState, type FormField } from '@/lib/state/formStore'
|
||||||
|
|
||||||
|
const text = (name: string, label = name): FormFieldInput => ({ type: 'text', name, label, required: false })
|
||||||
|
|
||||||
|
describe('form store', () => {
|
||||||
|
it('필드 추가/삭제/이동/업데이트와 action URL, spam 옵션을 관리한다', () => {
|
||||||
|
const useForm = createFormStore()
|
||||||
|
|
||||||
|
// 초기
|
||||||
|
let s: FormState = useForm.getState()
|
||||||
|
expect(s.fields.length).toBe(0)
|
||||||
|
expect(s.actionUrl).toBe('')
|
||||||
|
expect(s.spam.honeypotFieldName).toBe('_hp')
|
||||||
|
|
||||||
|
// 추가
|
||||||
|
useForm.getState().addField(text('name', 'Name'))
|
||||||
|
useForm.getState().addField(text('email', 'Email'))
|
||||||
|
useForm.getState().addField({ type: 'select', name: 'plan', label: 'Plan', required: true, options: ['A','B'] })
|
||||||
|
s = useForm.getState()
|
||||||
|
expect(s.fields.map((f: FormField) => f.name)).toEqual(['name','email','plan'])
|
||||||
|
|
||||||
|
// 이동 (0 -> 2)
|
||||||
|
useForm.getState().moveField(0, 2)
|
||||||
|
s = useForm.getState()
|
||||||
|
expect(s.fields.map((f: FormField) => f.name)).toEqual(['email','plan','name'])
|
||||||
|
|
||||||
|
// 업데이트
|
||||||
|
useForm.getState().updateField(1, { label: 'Pricing Plan', required: false })
|
||||||
|
s = useForm.getState()
|
||||||
|
expect(s.fields[1].label).toBe('Pricing Plan')
|
||||||
|
expect(s.fields[1].required).toBe(false)
|
||||||
|
|
||||||
|
// 삭제
|
||||||
|
useForm.getState().removeField(0)
|
||||||
|
s = useForm.getState()
|
||||||
|
expect(s.fields.map((f: FormField) => f.name)).toEqual(['plan','name'])
|
||||||
|
|
||||||
|
// action & spam
|
||||||
|
useForm.getState().setActionUrl('https://example.com/form')
|
||||||
|
useForm.getState().setSpam({ honeypotFieldName: '_trap', minSubmitSeconds: 3 })
|
||||||
|
s = useForm.getState()
|
||||||
|
expect(s.actionUrl).toBe('https://example.com/form')
|
||||||
|
expect(s.spam.honeypotFieldName).toBe('_trap')
|
||||||
|
expect(s.spam.minSubmitSeconds).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { createStore } from 'zustand/vanilla'
|
||||||
|
|
||||||
|
export type BaseField = {
|
||||||
|
name: string
|
||||||
|
label: string
|
||||||
|
required?: boolean
|
||||||
|
pattern?: string
|
||||||
|
maxLength?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TextField = BaseField & { type: 'text' | 'email' | 'tel' | 'date' | 'textarea' }
|
||||||
|
export type CheckboxField = BaseField & { type: 'checkbox' }
|
||||||
|
export type RadioField = BaseField & { type: 'radio'; options: string[] }
|
||||||
|
export type SelectField = BaseField & { type: 'select'; options: string[] }
|
||||||
|
|
||||||
|
export type FormField = TextField | CheckboxField | RadioField | SelectField
|
||||||
|
|
||||||
|
export type FormFieldInput = FormField
|
||||||
|
|
||||||
|
export type FormState = {
|
||||||
|
fields: FormField[]
|
||||||
|
actionUrl: string
|
||||||
|
spam: { honeypotFieldName: string; minSubmitSeconds: number }
|
||||||
|
addField: (f: FormFieldInput) => void
|
||||||
|
moveField: (from: number, to: number) => void
|
||||||
|
removeField: (index: number) => void
|
||||||
|
updateField: (index: number, patch: Partial<FormField>) => void
|
||||||
|
setActionUrl: (url: string) => void
|
||||||
|
setSpam: (s: { honeypotFieldName?: string; minSubmitSeconds?: number }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFormStore() {
|
||||||
|
return createStore<FormState>((set) => ({
|
||||||
|
fields: [],
|
||||||
|
actionUrl: '',
|
||||||
|
spam: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
|
||||||
|
addField: (f) => set((st) => ({ fields: [...st.fields, f] })),
|
||||||
|
moveField: (from, to) =>
|
||||||
|
set((st) => {
|
||||||
|
const arr = [...st.fields]
|
||||||
|
if (from < 0 || from >= arr.length || to < 0 || to >= arr.length) return st
|
||||||
|
const [spliced] = arr.splice(from, 1)
|
||||||
|
arr.splice(to, 0, spliced)
|
||||||
|
return { fields: arr }
|
||||||
|
}),
|
||||||
|
removeField: (index) =>
|
||||||
|
set((st) => {
|
||||||
|
if (index < 0 || index >= st.fields.length) return st
|
||||||
|
const arr = st.fields.slice()
|
||||||
|
arr.splice(index, 1)
|
||||||
|
return { fields: arr }
|
||||||
|
}),
|
||||||
|
updateField: (index, patch) =>
|
||||||
|
set((st) => {
|
||||||
|
if (index < 0 || index >= st.fields.length) return st
|
||||||
|
const next = st.fields.slice()
|
||||||
|
next[index] = { ...next[index], ...patch } as FormField
|
||||||
|
return { fields: next }
|
||||||
|
}),
|
||||||
|
setActionUrl: (url) => set({ actionUrl: url }),
|
||||||
|
setSpam: (s) =>
|
||||||
|
set((st) => ({
|
||||||
|
spam: {
|
||||||
|
honeypotFieldName: s.honeypotFieldName ?? st.spam.honeypotFieldName,
|
||||||
|
minSubmitSeconds: s.minSubmitSeconds ?? st.spam.minSubmitSeconds,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { createBuilderStore, type Section } from '@/lib/state/store'
|
||||||
|
|
||||||
|
describe('builder store (zustand)', () => {
|
||||||
|
it('섹션 추가/이동/삭제와 로케일 변경을 지원한다', () => {
|
||||||
|
const useStore = createBuilderStore()
|
||||||
|
const s1: Section = { type: 'hero', props: { heading: 'H1' } }
|
||||||
|
const s2: Section = { type: 'faq', props: { items: [] } }
|
||||||
|
const s3: Section = { type: 'cta', props: { text: 'Go' } }
|
||||||
|
|
||||||
|
// 초기 상태
|
||||||
|
expect(useStore.getState().locale).toBe('en')
|
||||||
|
expect(useStore.getState().sections.length).toBe(0)
|
||||||
|
|
||||||
|
// 추가
|
||||||
|
useStore.getState().addSection(s1)
|
||||||
|
useStore.getState().addSection(s2)
|
||||||
|
useStore.getState().addSection(s3)
|
||||||
|
expect(useStore.getState().sections.map(s => s.type)).toEqual(['hero','faq','cta'])
|
||||||
|
|
||||||
|
// 이동 (index 0 -> 2)
|
||||||
|
useStore.getState().moveSection(0, 2)
|
||||||
|
expect(useStore.getState().sections.map(s => s.type)).toEqual(['faq','cta','hero'])
|
||||||
|
|
||||||
|
// 삭제 (index 1: 'cta')
|
||||||
|
useStore.getState().removeSection(1)
|
||||||
|
expect(useStore.getState().sections.map(s => s.type)).toEqual(['faq','hero'])
|
||||||
|
|
||||||
|
// 로케일 변경
|
||||||
|
useStore.getState().setLocale('ko')
|
||||||
|
expect(useStore.getState().locale).toBe('ko')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { createStore } from 'zustand/vanilla'
|
||||||
|
|
||||||
|
export type HeroSection = { type: 'hero'; props: { heading?: string; subheading?: string } }
|
||||||
|
export type FaqSection = { type: 'faq'; props: { items: Array<{ q: string; a: string }> } }
|
||||||
|
export type CtaSection = { type: 'cta'; props: { text: string; href?: string } }
|
||||||
|
export type FeaturesSection = { type: 'features'; props: { items: string[] } }
|
||||||
|
export type TestimonialsSection = { type: 'testimonials'; props: { items: Array<{ author: string; quote: string }> } }
|
||||||
|
|
||||||
|
export type Section = HeroSection | FaqSection | CtaSection | FeaturesSection | TestimonialsSection
|
||||||
|
|
||||||
|
export type BuilderState = {
|
||||||
|
locale: string
|
||||||
|
sections: Section[]
|
||||||
|
addSection: (s: Section) => void
|
||||||
|
moveSection: (from: number, to: number) => void
|
||||||
|
removeSection: (index: number) => void
|
||||||
|
setLocale: (loc: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBuilderStore() {
|
||||||
|
return createStore<BuilderState>((set, get) => ({
|
||||||
|
locale: 'en',
|
||||||
|
sections: [],
|
||||||
|
addSection: (s) => set((st) => ({ sections: [...st.sections, s] })),
|
||||||
|
moveSection: (from, to) =>
|
||||||
|
set((st) => {
|
||||||
|
const arr = [...st.sections]
|
||||||
|
if (from < 0 || from >= arr.length || to < 0 || to >= arr.length) return st
|
||||||
|
const [spliced] = arr.splice(from, 1)
|
||||||
|
arr.splice(to, 0, spliced)
|
||||||
|
return { sections: arr }
|
||||||
|
}),
|
||||||
|
removeSection: (index) =>
|
||||||
|
set((st) => {
|
||||||
|
if (index < 0 || index >= st.sections.length) return st
|
||||||
|
const arr = st.sections.slice()
|
||||||
|
arr.splice(index, 1)
|
||||||
|
return { sections: arr }
|
||||||
|
}),
|
||||||
|
setLocale: (loc) => set({ locale: loc }),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { move } from './reorder'
|
||||||
|
|
||||||
|
describe('reorder.move', () => {
|
||||||
|
it('같은 배열 인스턴스를 변형하지 않고 from→to로 아이템을 이동한다', () => {
|
||||||
|
const src = ['a', 'b', 'c', 'd']
|
||||||
|
const out = move(src, 1, 3)
|
||||||
|
expect(src).toEqual(['a', 'b', 'c', 'd'])
|
||||||
|
expect(out).toEqual(['a', 'c', 'd', 'b'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('경계 밖 인덱스는 원본을 그대로 반환한다', () => {
|
||||||
|
const src = ['x', 'y']
|
||||||
|
expect(move(src, -1, 1)).toBe(src)
|
||||||
|
expect(move(src, 0, 5)).toBe(src)
|
||||||
|
expect(move(src, 2, 0)).toBe(src)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('from과 to가 같으면 원본을 그대로 반환한다', () => {
|
||||||
|
const src = [1, 2, 3]
|
||||||
|
expect(move(src, 1, 1)).toBe(src)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export function move<T>(arr: T[], from: number, to: number): T[] {
|
||||||
|
if (
|
||||||
|
from === to ||
|
||||||
|
from < 0 ||
|
||||||
|
to < 0 ||
|
||||||
|
from >= arr.length ||
|
||||||
|
to >= arr.length
|
||||||
|
) {
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
const next = arr.slice()
|
||||||
|
const [sp] = next.splice(from, 1)
|
||||||
|
next.splice(to, 0, sp)
|
||||||
|
return next
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"title": "Landing Page Builder"
|
||||||
|
},
|
||||||
|
"builder": {
|
||||||
|
"title": "Landing Page Builder",
|
||||||
|
"preview": "Preview (static)",
|
||||||
|
"field": {
|
||||||
|
"title": "Title",
|
||||||
|
"locale": "Locale",
|
||||||
|
"primaryColor": "Primary Color",
|
||||||
|
"fontFamily": "Font Family"
|
||||||
|
},
|
||||||
|
"upload": {
|
||||||
|
"hero": "Hero Image Upload"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"addHero": "Add Hero",
|
||||||
|
"addFAQ": "Add FAQ",
|
||||||
|
"addCTA": "Add CTA",
|
||||||
|
"export": "Export ZIP",
|
||||||
|
"addText": "Add Text",
|
||||||
|
"addEmail": "Add Email",
|
||||||
|
"addTel": "Add Tel",
|
||||||
|
"addDate": "Add Date",
|
||||||
|
"addTextarea": "Add Textarea",
|
||||||
|
"addCheckbox": "Add Checkbox",
|
||||||
|
"addSelect": "Add Select",
|
||||||
|
"addRadio": "Add Radio"
|
||||||
|
},
|
||||||
|
"sections": "Sections",
|
||||||
|
"form": {
|
||||||
|
"fields": "Form Fields",
|
||||||
|
"actionUrl": "Action URL",
|
||||||
|
"honeypot": "Honeypot Field",
|
||||||
|
"minSeconds": "Min Submit Seconds"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"title": "랜딩 페이지 빌더"
|
||||||
|
},
|
||||||
|
"builder": {
|
||||||
|
"title": "랜딩 페이지 빌더",
|
||||||
|
"preview": "미리보기(정적)",
|
||||||
|
"field": {
|
||||||
|
"title": "제목",
|
||||||
|
"locale": "로케일",
|
||||||
|
"primaryColor": "대표 색상",
|
||||||
|
"fontFamily": "폰트"
|
||||||
|
},
|
||||||
|
"upload": {
|
||||||
|
"hero": "히어로 이미지 업로드"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"addHero": "히어로 추가",
|
||||||
|
"addFAQ": "FAQ 추가",
|
||||||
|
"addCTA": "CTA 추가",
|
||||||
|
"export": "ZIP 내보내기",
|
||||||
|
"addText": "텍스트 필드 추가",
|
||||||
|
"addEmail": "이메일 필드 추가",
|
||||||
|
"addTel": "전화번호 필드 추가",
|
||||||
|
"addDate": "날짜 필드 추가",
|
||||||
|
"addTextarea": "텍스트영역 추가",
|
||||||
|
"addCheckbox": "체크박스 추가",
|
||||||
|
"addSelect": "셀렉트 추가",
|
||||||
|
"addRadio": "라디오 추가"
|
||||||
|
},
|
||||||
|
"sections": "섹션",
|
||||||
|
"form": {
|
||||||
|
"fields": "폼 필드",
|
||||||
|
"actionUrl": "액션 URL",
|
||||||
|
"honeypot": "허니팟 필드",
|
||||||
|
"minSeconds": "최소 제출 대기(초)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+3635
-6
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user