feat: Page Editor v1.0 - canonical schema, SSR parity, and migration

Major improvements to WooNooW Page Editor system:

Schema & Architecture:
- Canonical section schema with unified sectionSchema.ts
- Normalized feature-grid to use items (not features)
- Standardized default values across all section types
- Schema versioning with automatic migration on read

Backend (PHP):
- Enhanced PlaceholderRenderer with typed output contracts
- Added fallback behavior for empty/invalid dynamic sources
- Added caching support for post data resolution
- New SchemaMigration class for backward compatibility
- New Features class for feature flags
- Enhanced PageSSR with full style support
- Removed controller-level special-casing for related_posts

Frontend (Admin SPA):
- Updated CanvasRenderer with schema-aware transformation
- Enhanced InspectorPanel with canonical schema metadata
- Added new section renderers

Frontend (Customer SPA):
- New section components: BentoCategoryGrid, MarqueeBanner, ProductCarousel, ShoppableImage
- Updated FeatureGridSection for items prop contract

Testing:
- Add PHP tests: SchemaMigrationTest, PlaceholderRendererTest, PageSSRTest
- Add TypeScript tests: schema-integration, feature-grid-regression
- Add parity tests for React vs SSR content matching
- Add CI script: check-schema-drift.mjs
- Add VERIFICATION_CHECKLIST.md

Documentation:
- RELEASE_NOTES-v1.0.md with full release notes
- docs/PAGE_EDITOR_SECTION_SCHEMA_V1.md
- docs/PAGE_EDITOR_SSR_COVERAGE_AUDIT.md
This commit is contained in:
Dwindi Ramadhana
2026-05-30 13:02:08 +07:00
parent e70aa1f554
commit 396ca25be4
118 changed files with 10162 additions and 3726 deletions

382
tests/PageSSRTest.php Normal file
View File

@@ -0,0 +1,382 @@
<?php
/**
* PageSSR Tests
* Tests for server-side rendering of page sections
*/
namespace WooNooW\Tests;
use PHPUnit\Framework\TestCase;
use WooNooW\Frontend\PageSSR;
class PageSSRTest extends TestCase
{
/**
* Test render returns empty string for empty structure
*/
public function test_render_returns_empty_for_empty_structure()
{
$this->assertEquals('', PageSSR::render([]));
$this->assertEquals('', PageSSR::render(['sections' => []]));
}
/**
* Test render_section with unknown type falls back to generic
*/
public function test_render_section_fallback_to_generic()
{
$section = [
'id' => 'test-section',
'type' => 'unknown-type',
'props' => [
'title' => 'Test Title',
'description' => 'Test Description',
],
];
$html = PageSSR::render_section($section);
$this->assertStringContainsString('wn-unknown-type', $html);
$this->assertStringContainsString('Test Title', $html);
$this->assertStringContainsString('Test Description', $html);
}
/**
* Test render_hero section
*/
public function test_render_hero()
{
$props = [
'title' => 'Welcome',
'subtitle' => 'To our site',
'image' => 'https://example.com/hero.jpg',
'cta_text' => 'Get Started',
'cta_url' => '/start',
];
$html = PageSSR::render_hero($props, 'default', 'default', 'hero-section');
$this->assertStringContainsString('wn-hero', $html);
$this->assertStringContainsString('Welcome', $html);
$this->assertStringContainsString('To our site', $html);
$this->assertStringContainsString('Get Started', $html);
$this->assertStringContainsString('/start', $html);
}
/**
* Test render_hero with section styles
*/
public function test_render_hero_with_section_styles()
{
$props = [
'title' => 'Styled Hero',
];
$section_styles = [
'backgroundType' => 'gradient',
'gradientFrom' => '#ff0000',
'gradientTo' => '#0000ff',
'gradientAngle' => 90,
'paddingTop' => '3rem',
'paddingBottom' => '3rem',
'contentWidth' => 'contained',
];
$html = PageSSR::render_hero($props, 'default', 'default', 'hero-styled', [], $section_styles);
$this->assertStringContainsString('background:', $html);
$this->assertStringContainsString('linear-gradient', $html);
$this->assertStringContainsString('padding-top', $html);
}
/**
* Test resolve_props handles static values
*/
public function test_resolve_props_static_values()
{
$props = [
'title' => ['type' => 'static', 'value' => 'Static Title'],
'subtitle' => ['type' => 'static', 'value' => 'Static Subtitle'],
];
$resolved = PageSSR::resolve_props($props);
$this->assertEquals('Static Title', $resolved['title']);
$this->assertEquals('Static Subtitle', $resolved['subtitle']);
}
/**
* Test resolve_props handles dynamic values
*/
public function test_resolve_props_dynamic_values()
{
$props = [
'title' => ['type' => 'dynamic', 'source' => 'post_title'],
'subtitle' => ['type' => 'dynamic', 'source' => 'post_excerpt'],
];
$post_data = [
'title' => 'Dynamic Title',
'excerpt' => 'Dynamic Excerpt',
];
$resolved = PageSSR::resolve_props($props, $post_data);
$this->assertEquals('Dynamic Title', $resolved['title']);
$this->assertEquals('Dynamic Excerpt', $resolved['subtitle']);
}
/**
* Test resolve_props with options
*/
public function test_resolve_props_with_options()
{
$props = [
'title' => ['type' => 'dynamic', 'source' => 'post_title'],
];
$post_data = [
'title' => '',
];
// With fallbacks
$resolved = PageSSR::resolve_props($props, $post_data, ['use_fallbacks' => true]);
$this->assertEquals('(Untitled)', $resolved['title']);
// Without fallbacks
$resolved = PageSSR::resolve_props($props, $post_data, ['use_fallbacks' => false]);
$this->assertEquals('', $resolved['title']);
}
/**
* Test resolve_props handles non-array props
*/
public function test_resolve_props_non_array_props()
{
$props = [
'title' => 'Plain String Title',
'count' => 42,
];
$resolved = PageSSR::resolve_props($props);
$this->assertEquals('Plain String Title', $resolved['title']);
$this->assertEquals(42, $resolved['count']);
}
/**
* Test render_feature_grid section
*/
public function test_render_feature_grid()
{
$props = [
'heading' => 'Our Features',
'items' => [
['title' => 'Feature 1', 'description' => 'Desc 1', 'icon' => 'Star'],
['title' => 'Feature 2', 'description' => 'Desc 2', 'icon' => 'Heart'],
],
];
$html = PageSSR::render_feature_grid($props, 'grid-3', 'default', 'features-section');
$this->assertStringContainsString('wn-feature-grid', $html);
$this->assertStringContainsString('Our Features', $html);
$this->assertStringContainsString('Feature 1', $html);
$this->assertStringContainsString('Feature 2', $html);
}
/**
* Test render_feature_grid falls back to features prop
*/
public function test_render_feature_grid_falls_back_to_features()
{
$props = [
'heading' => 'Features',
'features' => [
['title' => 'Legacy Feature', 'description' => 'From features prop'],
],
];
$html = PageSSR::render_feature_grid($props, 'grid-2', 'default', 'features-legacy');
$this->assertStringContainsString('Legacy Feature', $html);
}
/**
* Test render_feature_grid with section styles
*/
public function test_render_feature_grid_with_styles()
{
$props = [
'heading' => 'Styled Features',
'items' => [
['title' => 'Styled Item'],
],
];
$section_styles = [
'backgroundType' => 'gradient',
'gradientFrom' => '#9333ea',
'gradientTo' => '#3b82f6',
'gradientAngle' => 135,
'heightPreset' => 'medium',
];
$html = PageSSR::render_feature_grid($props, 'grid-3', 'default', 'features-styled', [], $section_styles);
$this->assertStringContainsString('linear-gradient', $html);
$this->assertStringContainsString('py-16', $html);
}
/**
* Test render_cta_banner section
*/
public function test_render_cta_banner()
{
$props = [
'title' => 'Ready to Start?',
'text' => 'Join thousands of users today.',
'button_text' => 'Get Started',
'button_url' => '/signup',
];
$html = PageSSR::render_cta_banner($props, 'default', 'primary', 'cta-section');
$this->assertStringContainsString('wn-cta-banner', $html);
$this->assertStringContainsString('Ready to Start?', $html);
$this->assertStringContainsString('Get Started', $html);
$this->assertStringContainsString('/signup', $html);
}
/**
* Test render_cta_banner with section styles
*/
public function test_render_cta_banner_with_styles()
{
$props = [
'title' => 'Styled CTA',
];
$section_styles = [
'backgroundType' => 'solid',
'backgroundColor' => '#1a1a1a',
'contentWidth' => 'full',
];
$html = PageSSR::render_cta_banner($props, 'default', 'default', 'cta-styled', [], $section_styles);
$this->assertStringContainsString('background-color', $html);
$this->assertStringContainsString('w-full', $html);
}
/**
* Test render_contact_form section
*/
public function test_render_contact_form()
{
$props = [
'title' => 'Contact Us',
'fields' => ['name', 'email', 'message'],
];
$html = PageSSR::render_contact_form($props, 'default', 'default', 'contact-section');
$this->assertStringContainsString('wn-contact-form', $html);
$this->assertStringContainsString('Contact Us', $html);
$this->assertStringContainsString('name', $html);
$this->assertStringContainsString('email', $html);
$this->assertStringContainsString('message', $html);
}
/**
* Test render_bento_category_grid section
*/
public function test_render_bento_category_grid()
{
$props = [
'title' => 'Shop by Category',
'items' => [
['label' => 'Electronics', 'url' => '/category/electronics', 'image' => 'https://example.com/elec.jpg'],
['label' => 'Clothing', 'url' => '/category/clothing'],
],
];
$html = PageSSR::render_bento_category_grid($props, 'default', 'default', 'bento-section');
$this->assertStringContainsString('wn-bento-grid', $html);
$this->assertStringContainsString('Shop by Category', $html);
$this->assertStringContainsString('Electronics', $html);
$this->assertStringContainsString('Clothing', $html);
}
/**
* Test render_marquee_banner section
*/
public function test_render_marquee_banner()
{
$props = [
'text' => 'Free Shipping * Easy Returns * 24/7 Support',
'separator' => '*',
];
$html = PageSSR::render_marquee_banner($props, 'default', 'default', 'marquee-section');
$this->assertStringContainsString('wn-marquee', $html);
$this->assertStringContainsString('Free Shipping', $html);
$this->assertStringContainsString('Easy Returns', $html);
}
/**
* Test render with full structure
*/
public function test_render_full_structure()
{
$structure = [
'sections' => [
[
'id' => 'hero-1',
'type' => 'hero',
'props' => [
'title' => ['type' => 'static', 'value' => 'Full Render Test'],
'subtitle' => ['type' => 'static', 'value' => 'Testing complete render'],
],
],
[
'id' => 'features-1',
'type' => 'feature-grid',
'props' => [
'heading' => ['type' => 'static', 'value' => 'Features'],
'items' => [
['title' => 'Feature A'],
],
],
],
],
];
$html = PageSSR::render($structure);
$this->assertStringContainsString('Full Render Test', $html);
$this->assertStringContainsString('Testing complete render', $html);
$this->assertStringContainsString('Features', $html);
$this->assertStringContainsString('Feature A', $html);
}
/**
* Test get_icon_svg returns SVG for known icons
*/
public function test_get_icon_svg_known()
{
$reflection = new \ReflectionClass(PageSSR::class);
$method = $reflection->getMethod('get_icon_svg');
$method->setAccessible(true);
$starSvg = $method->invoke(null, 'Star');
$this->assertStringContainsString('<svg', $starSvg);
$this->assertStringContainsString('polygon', $starSvg);
$heartSvg = $method->invoke(null, 'Heart');
$this->assertStringContainsString('<svg', $heartSvg);
$this->assertStringContainsString('path', $heartSvg);
}
}

View File

@@ -0,0 +1,224 @@
<?php
/**
* PlaceholderRenderer Tests
* Tests for dynamic placeholder resolution with typed output contracts
*/
namespace WooNooW\Tests;
use PHPUnit\Framework\TestCase;
use WooNooW\Frontend\PlaceholderRenderer;
class PlaceholderRendererTest extends TestCase
{
/**
* Test get_source_type returns correct types
*/
public function test_get_source_type()
{
$this->assertEquals('scalar', PlaceholderRenderer::get_source_type('post_title'));
$this->assertEquals('html', PlaceholderRenderer::get_source_type('post_content'));
$this->assertEquals('url', PlaceholderRenderer::get_source_type('post_featured_image'));
$this->assertEquals('array', PlaceholderRenderer::get_source_type('post_categories'));
$this->assertEquals('array', PlaceholderRenderer::get_source_type('related_posts'));
$this->assertEquals('url', PlaceholderRenderer::get_source_type('post_url'));
}
/**
* Test validate_value_type for scalar values
*/
public function test_validate_value_type_scalar()
{
$this->assertTrue(PlaceholderRenderer::validate_value_type('Hello', 'scalar'));
$this->assertTrue(PlaceholderRenderer::validate_value_type(123, 'scalar'));
$this->assertTrue(PlaceholderRenderer::validate_value_type('', 'scalar'));
$this->assertFalse(PlaceholderRenderer::validate_value_type(['array'], 'scalar'));
}
/**
* Test validate_value_type for URL values
*/
public function test_validate_value_type_url()
{
$this->assertTrue(PlaceholderRenderer::validate_value_type('https://example.com/image.jpg', 'url'));
$this->assertFalse(PlaceholderRenderer::validate_value_type('', 'url'));
$this->assertFalse(PlaceholderRenderer::validate_value_type(['array'], 'url'));
}
/**
* Test validate_value_type for array values
*/
public function test_validate_value_type_array()
{
$this->assertTrue(PlaceholderRenderer::validate_value_type(['a', 'b', 'c'], 'array'));
$this->assertTrue(PlaceholderRenderer::validate_value_type([], 'array'));
$this->assertFalse(PlaceholderRenderer::validate_value_type('string', 'array'));
}
/**
* Test get_fallback returns correct defaults
*/
public function test_get_fallback()
{
$this->assertEquals('(Untitled)', PlaceholderRenderer::get_fallback('post_title'));
$this->assertEquals([], PlaceholderRenderer::get_fallback('post_categories', 'array'));
$this->assertEquals('', PlaceholderRenderer::get_fallback('unknown_source'));
$this->assertEquals([], PlaceholderRenderer::get_fallback('unknown_source', 'array'));
}
/**
* Test is_array_source identifies array-based sources
*/
public function test_is_array_source()
{
$this->assertTrue(PlaceholderRenderer::is_array_source('post_categories'));
$this->assertTrue(PlaceholderRenderer::is_array_source('post_tags'));
$this->assertTrue(PlaceholderRenderer::is_array_source('related_posts'));
$this->assertFalse(PlaceholderRenderer::is_array_source('post_title'));
$this->assertFalse(PlaceholderRenderer::is_array_source('post_featured_image'));
}
/**
* Test get_value resolves scalar values
*/
public function test_get_value_resolves_scalar()
{
$post_data = [
'title' => 'Test Post Title',
'content' => '<p>Post content here</p>',
'author' => 'John Doe',
];
$this->assertEquals('Test Post Title', PlaceholderRenderer::get_value('post_title', $post_data));
$this->assertEquals('<p>Post content here</p>', PlaceholderRenderer::get_value('post_content', $post_data));
$this->assertEquals('John Doe', PlaceholderRenderer::get_value('post_author', $post_data));
}
/**
* Test get_value resolves URL values
*/
public function test_get_value_resolves_url()
{
$post_data = [
'featured_image' => 'https://example.com/featured.jpg',
'url' => 'https://example.com/post',
];
$this->assertEquals('https://example.com/featured.jpg', PlaceholderRenderer::get_value('post_featured_image', $post_data));
$this->assertEquals('https://example.com/post', PlaceholderRenderer::get_value('post_url', $post_data));
}
/**
* Test get_value with fallback for empty values
*/
public function test_get_value_with_fallback()
{
$post_data = [
'title' => '',
'featured_image' => '',
];
$this->assertEquals('(Untitled)', PlaceholderRenderer::get_value('post_title', $post_data));
$this->assertEquals('', PlaceholderRenderer::get_value('post_featured_image', $post_data));
}
/**
* Test get_value with empty post_data
*/
public function test_get_value_with_empty_post_data()
{
$this->assertEquals('(Untitled)', PlaceholderRenderer::get_value('post_title', []));
$this->assertEquals('', PlaceholderRenderer::get_value('post_featured_image', []));
$this->assertEquals([], PlaceholderRenderer::get_value('post_categories', []));
}
/**
* Test get_value with options
*/
public function test_get_value_with_options()
{
$post_data = [
'title' => '',
];
// Without fallback
$result = PlaceholderRenderer::get_value('post_title', $post_data, ['use_fallback' => false]);
$this->assertEquals('', $result);
// Without type validation
$result = PlaceholderRenderer::get_value('post_title', $post_data, ['validate_type' => false]);
$this->assertEquals('(Untitled)', $result);
}
/**
* Test custom meta field resolution
*/
public function test_get_value_custom_meta_field()
{
$post_data = [
'meta' => [
'custom_field' => 'Custom Value',
],
];
$this->assertEquals('Custom Value', PlaceholderRenderer::get_value('post_field_custom_field', $post_data));
}
/**
* Test build_post_data returns required fields
*/
public function test_build_post_data_structure()
{
global $wpdb;
// Create a mock WP_Post
$post = new \stdClass();
$post->ID = 1;
$post->post_title = 'Test Title';
$post->post_content = 'Test Content';
$post->post_excerpt = 'Test Excerpt';
$post->post_author = 1;
$post->post_type = 'post';
$post->post_name = 'test-title';
$post->post_date = '2024-01-01 12:00:00';
// Mock WordPress functions
if (!function_exists('get_the_date')) {
function get_the_date() { return 'January 1, 2024'; }
}
if (!function_exists('get_permalink')) {
function get_permalink() { return 'https://example.com/test-title'; }
}
if (!function_exists('get_the_author_meta')) {
function get_the_author_meta() { return 'Admin'; }
}
if (!function_exists('get_post_thumbnail_id')) {
function get_post_thumbnail_id() { return 0; }
}
if (!function_exists('get_the_post_thumbnail_url')) {
function get_the_post_thumbnail_url() { return ''; }
}
if (!function_exists('get_object_taxonomies')) {
function get_object_taxonomies() { return []; }
}
if (!function_exists('get_the_terms')) {
function get_the_terms() { return []; }
}
if (!function_exists('get_post_meta')) {
function get_post_meta() { return []; }
}
if (!function_exists('apply_filters')) {
function apply_filters($tag, $value) { return $value; }
}
if (!function_exists('wp_trim_words')) {
function wp_trim_words($text, $num_words, $more) { return $text; }
}
$data = PlaceholderRenderer::build_post_data($post);
$this->assertArrayHasKey('title', $data);
$this->assertArrayHasKey('content', $data);
$this->assertArrayHasKey('url', $data);
$this->assertArrayHasKey('type', $data);
}
}

View File

@@ -0,0 +1,207 @@
<?php
/**
* Schema Migration Tests
* Tests for backward compatibility and migration of legacy structures
*/
namespace WooNooW\Tests;
use PHPUnit\Framework\TestCase;
use WooNooW\Frontend\SchemaMigration;
class SchemaMigrationTest extends TestCase
{
/**
* Test that v1 structures don't need migration
*/
public function test_v1_structures_dont_need_migration()
{
$structure = [
'type' => 'page',
'schemaVersion' => 1,
'sections' => [],
];
$this->assertFalse(SchemaMigration::needs_migration($structure));
}
/**
* Test that structures without version need migration
*/
public function test_structures_without_version_need_migration()
{
$structure = [
'type' => 'page',
'sections' => [],
];
$this->assertTrue(SchemaMigration::needs_migration($structure));
}
/**
* Test migration of feature-grid with legacy features key
*/
public function test_migrate_feature_grid_features_to_items()
{
$structure = [
'schemaVersion' => 0,
'sections' => [
[
'type' => 'feature-grid',
'props' => [
'heading' => ['type' => 'static', 'value' => 'Test'],
'features' => [
['title' => 'Feature 1', 'description' => 'Desc 1'],
['title' => 'Feature 2', 'description' => 'Desc 2'],
],
],
],
],
];
$migrated = SchemaMigration::migrate($structure);
$this->assertEquals(1, $migrated['schemaVersion']);
$this->assertArrayHasKey('items', $migrated['sections'][0]['props']);
$this->assertEquals($structure['sections'][0]['props']['features'], $migrated['sections'][0]['props']['items']);
}
/**
* Test migration of feature-grid with empty features string
*/
public function test_migrate_feature_grid_empty_features_to_items()
{
$structure = [
'schemaVersion' => 0,
'sections' => [
[
'type' => 'feature-grid',
'props' => [
'heading' => ['type' => 'static', 'value' => 'Test'],
'features' => '',
],
],
],
];
$migrated = SchemaMigration::migrate($structure);
$this->assertEquals([], $migrated['sections'][0]['props']['items']);
}
/**
* Test migration of container_width to contentWidth
*/
public function test_migrate_container_width_to_content_width()
{
$structure = [
'schemaVersion' => 0,
'sections' => [
[
'type' => 'hero',
'styles' => [
'container_width' => 'full',
],
],
],
];
$migrated = SchemaMigration::migrate($structure);
$this->assertArrayHasKey('contentWidth', $migrated['sections'][0]['styles']);
$this->assertEquals('full', $migrated['sections'][0]['styles']['contentWidth']);
$this->assertArrayNotHasKey('container_width', $migrated['sections'][0]['styles']);
}
/**
* Test migration of background image without type
*/
public function test_migrate_background_image_adds_type()
{
$structure = [
'schemaVersion' => 0,
'sections' => [
[
'type' => 'hero',
'styles' => [
'backgroundImage' => 'https://example.com/image.jpg',
],
],
],
];
$migrated = SchemaMigration::migrate($structure);
$this->assertEquals('image', $migrated['sections'][0]['styles']['backgroundType']);
}
/**
* Test migration of height to heightPreset
*/
public function test_migrate_height_to_height_preset()
{
$structure = [
'schemaVersion' => 0,
'sections' => [
[
'type' => 'hero',
'styles' => [
'height' => 'screen',
],
],
],
];
$migrated = SchemaMigration::migrate($structure);
$this->assertArrayHasKey('heightPreset', $migrated['sections'][0]['styles']);
$this->assertEquals('fullscreen', $migrated['sections'][0]['styles']['heightPreset']);
$this->assertArrayNotHasKey('height', $migrated['sections'][0]['styles']);
}
/**
* Test migration adds default backgroundType
*/
public function test_migrate_adds_default_background_type()
{
$structure = [
'schemaVersion' => 0,
'sections' => [
[
'type' => 'hero',
'styles' => [],
],
],
];
$migrated = SchemaMigration::migrate($structure);
$this->assertEquals('solid', $migrated['sections'][0]['styles']['backgroundType']);
}
/**
* Test batch migration
*/
public function test_migrate_all()
{
$structures = [
['schemaVersion' => 0, 'sections' => []],
['schemaVersion' => 1, 'sections' => []],
['schemaVersion' => 0, 'sections' => []],
];
$migrated = SchemaMigration::migrate_all($structures);
$this->assertEquals(1, $migrated[0]['schemaVersion']);
$this->assertEquals(1, $migrated[1]['schemaVersion']);
$this->assertEquals(1, $migrated[2]['schemaVersion']);
}
/**
* Test current version is returned
*/
public function test_get_current_version()
{
$this->assertEquals(1, SchemaMigration::get_current_version());
}
}

View File

@@ -0,0 +1,205 @@
# WooNooW v1.0 - Breaking Behavior Verification Checklist
This checklist should be completed before full rollout to verify no breaking behavior on existing pages and templates.
---
## Pre-Flight Checks
- [ ] **Database backup completed** - Full backup including `wp_options` and `wp_postmeta` tables
- [ ] **Staging environment verified** - All tests pass on staging
- [ ] **Error logging enabled** - PHP error logging configured to catch any issues
---
## Schema Migration Verification
### Feature-Grid Items Normalization
- [ ] **Legacy `features` → `items` migration works**
- Find a page with `feature-grid` using `features` prop
- Load the page in admin editor
- Verify `features` is normalized to `items` in the response
- Save the page and verify `items` is persisted
- Frontend still renders correctly
### Style Key Normalization
- [ ] **`container_width``contentWidth` migration works**
- Find pages with old style keys
- Verify migration normalizes them on load
- Frontend renders with correct width
- [ ] **`height``heightPreset` migration works**
- Find pages with `height: 'screen'`
- Verify migration converts to `heightPreset: 'fullscreen'`
- Section renders with correct height
- [ ] **`backgroundType` defaults added**
- Find pages with no `backgroundType`
- Verify migration adds `backgroundType: 'solid'`
- No visual changes occur
### Element Styles Normalization
- [ ] **`cta``cta_text` alias works**
- Find pages with `elementStyles.cta`
- Verify migration normalizes to `elementStyles.cta_text`
- Buttons still styled correctly
---
## Section Rendering Verification
### Hero Section
- [ ] **Static title/subtitle/image render correctly**
- Create a hero section with all static content
- Verify on frontend (human view)
- Verify in SSR (bot view)
- Content matches between both
- [ ] **Dynamic placeholders resolve on frontend**
- Create a template with dynamic `title` source: `post_title`
- Apply template to a blog post
- Verify post title appears in hero
- [ ] **Background styles render correctly**
- Hero with gradient background
- Hero with image background
- Hero with solid color
- All styles match between React and SSR
### Feature Grid Section
- [ ] **Static items render correctly**
- Create feature grid with 3 items
- Verify all items display
- Verify icons render
- [ ] **Dynamic `related_posts` source works**
- Create a template with `items` as `dynamic: 'related_posts'`
- Apply to a blog post with 2+ related posts
- Verify related posts appear as cards
- [ ] **`items` prop used (not `features`)**
- Verify schema uses `items`
- Verify SSR reads `items`
- Backward compat with `features` still works
### Image + Text Section
- [ ] **Image left/right layouts work**
- Create with `layout: 'image-left'`
- Create with `layout: 'image-right'`
- Both render correctly
- [ ] **Dynamic image source works**
- Use `dynamic: 'post_featured_image'`
- Verify image appears
### CTA Banner
- [ ] **Button styling renders**
- CTA with custom colors
- CTA with default styles
- Both render correctly
### Contact Form
- [ ] **Form structure renders**
- Form fields present
- Submit button present
- No functional submission (acceptable for SSR)
### All Other Sections
- [ ] **Bento Category Grid** - Categories render with images
- [ ] **Product Carousel** - Products display with prices
- [ ] **Shoppable Image** - Hotspots position correctly
- [ ] **Marquee Banner** - Text items separated correctly
---
## Dynamic Source Resolution
### Scalar Sources
- [ ] `post_title` resolves correctly
- [ ] `post_content` resolves with formatting
- [ ] `post_excerpt` resolves
- [ ] `post_author` resolves
- [ ] `post_date` resolves
### URL Sources
- [ ] `post_featured_image` resolves to URL
- [ ] `post_url` / `permalink` resolves
### Array Sources
- [ ] `post_categories` resolves to array
- [ ] `post_tags` resolves to array
- [ ] `related_posts` resolves to post objects with `url`, `title`, `featured_image`, `excerpt`
### Fallback Behavior
- [ ] Empty `post_title` shows "(Untitled)"
- [ ] Empty `post_featured_image` returns empty string (no broken image)
- [ ] Empty `related_posts` returns empty array
---
## Caching Behavior
- [ ] **Post data caching works**
- First load fetches fresh data
- Subsequent loads use cache
- Cache expires after 5 minutes
- [ ] **SSR cache invalidation works**
- Edit a page
- Verify SSR cache is invalidated
- Next bot visit renders fresh content
---
## Edge Cases
### Empty States
- [ ] Page with no sections renders empty container gracefully
- [ ] Feature grid with empty items shows empty state or nothing
### Invalid Data
- [ ] Malformed section JSON doesn't crash the site
- [ ] Invalid dynamic source doesn't break rendering
### Legacy Data
- [ ] Pages created before schema migration still work
- [ ] Templates created before migration still work
- [ ] No data loss on migration
---
## Performance
- [ ] **No significant SSR slowdown**
- SSR render time < 200ms for 10 sections
- No N+1 queries in placeholder resolution
- [ ] **No increased memory usage**
- Memory usage stable under load
---
## Rollback Procedure
If issues are found:
1. **Immediate**: Disable plugin via WP Admin
2. **Data safe**: Migration is read-time, no destructive changes
3. **Restore**: Revert to previous plugin version
4. **Investigation**: Check error logs for specific failures
---
## Sign-Off
- [ ] **QA Sign-Off**: _________________________ Date: ________
- [ ] **Tech Lead Sign-Off**: _________________________ Date: ________
- [ ] **Product Sign-Off**: _________________________ Date: ________
---
## Notes
_Use this space to document any issues found and their resolutions._
---

View File

@@ -0,0 +1,223 @@
/**
* Feature Grid Regression Tests
* Tests to prevent regression of items/features naming and default values
*/
import { describe, it, expect } from 'vitest';
import {
SECTION_SCHEMAS,
cloneDefaultProps,
normalizeFeatureGridProps,
} from '../../admin-spa/src/routes/Appearance/Pages/schema/sectionSchema';
describe('Feature Grid Regression Tests', () => {
describe('Items vs Features Naming', () => {
it('schema uses items not features', () => {
const schema = SECTION_SCHEMAS['feature-grid'];
expect(schema.defaultProps).toHaveProperty('items');
expect(schema.defaultProps).not.toHaveProperty('features');
});
it('items default value is empty array', () => {
const props = cloneDefaultProps('feature-grid');
expect(Array.isArray(props.items.value)).toBe(true);
expect(props.items.value).toHaveLength(0);
});
it('normalizeFeatureGridProps uses items not features', () => {
const legacyProps = {
features: [{ title: 'Legacy Feature' }],
};
const normalized = normalizeFeatureGridProps(legacyProps);
expect(normalized).toHaveProperty('items');
expect(normalized.items).toHaveLength(1);
expect(normalized.items[0].title).toBe('Legacy Feature');
});
it('normalizeFeatureGridProps keeps items when present', () => {
const currentProps = {
items: [{ title: 'Current Feature' }],
};
const normalized = normalizeFeatureGridProps(currentProps);
expect(normalized.items).toHaveLength(1);
expect(normalized.items[0].title).toBe('Current Feature');
});
});
describe('Default Value Type', () => {
it('items is always an array, not empty string', () => {
const props = cloneDefaultProps('feature-grid');
// Should be array, not empty string
expect(props.items.value).toEqual([]);
expect(typeof props.items.value).toBe('object');
});
it('normalizeFeatureGridProps handles empty features string', () => {
const legacyProps = {
features: '', // Legacy empty string
};
const normalized = normalizeFeatureGridProps(legacyProps);
expect(Array.isArray(normalized.items)).toBe(true);
expect(normalized.items).toHaveLength(0);
});
it('normalizeFeatureGridProps handles missing features', () => {
const noFeaturesProps = {};
const normalized = normalizeFeatureGridProps(noFeaturesProps);
expect(normalized.items).toBeUndefined();
});
});
describe('Feature Grid Component Props Contract', () => {
// These tests verify the contract between schema and component
it('schema items prop structure matches component expectation', () => {
const props = cloneDefaultProps('feature-grid');
// Component expects: items = array of FeatureItem
expect(props.items.type).toBe('static');
expect(Array.isArray(props.items.value)).toBe(true);
});
it('feature item structure supports title, description, icon', () => {
const schema = SECTION_SCHEMAS['feature-grid'];
// The schema doesn't restrict the item structure directly
// but the component expects these fields
expect(schema.defaultProps.items.value).toEqual([]);
});
it('heading is static by default', () => {
const props = cloneDefaultProps('feature-grid');
expect(props.heading.type).toBe('static');
});
});
describe('Dynamic Source Handling', () => {
it('feature-grid items can be dynamic', () => {
// Verify the schema structure supports dynamic items
// The actual component handles both static arrays and dynamic sources
const dynamicItems = {
items: { type: 'dynamic', source: 'related_posts' },
};
expect(dynamicItems.items.type).toBe('dynamic');
expect(dynamicItems.items.source).toBe('related_posts');
});
it('normalizeFeatureGridProps preserves dynamic items', () => {
const dynamicProps = {
items: { type: 'dynamic', source: 'related_posts' },
};
const normalized = normalizeFeatureGridProps(dynamicProps);
expect(normalized.items.type).toBe('dynamic');
expect(normalized.items.source).toBe('related_posts');
});
});
});
describe('Style Keys Regression Tests', () => {
describe('Section Styles Normalization', () => {
it('contentWidth is the canonical key', () => {
const styles = {
contentWidth: 'full',
};
// Should NOT have container_width
expect(styles).not.toHaveProperty('container_width');
expect(styles.contentWidth).toBe('full');
});
it('backgroundType is required', () => {
const styles = {
backgroundType: 'gradient',
gradientFrom: '#9333ea',
gradientTo: '#3b82f6',
};
expect(styles.backgroundType).toBe('gradient');
});
it('heightPreset replaces old height key', () => {
const styles = {
heightPreset: 'fullscreen',
};
// Should NOT have legacy 'height' key
expect(styles).not.toHaveProperty('height');
expect(styles.heightPreset).toBe('fullscreen');
});
});
describe('Element Styles Keys', () => {
it('cta_text is the canonical button key', () => {
const elementStyles = {
cta_text: { color: '#fff' },
};
// Should NOT use 'cta' or 'button' aliases
expect(elementStyles).not.toHaveProperty('cta');
expect(elementStyles).toHaveProperty('cta_text');
});
it('heading element key is consistent', () => {
const elementStyles = {
heading: { fontSize: '2rem' },
};
expect(elementStyles.heading).toBeDefined();
});
it('feature_item is the canonical card key', () => {
const elementStyles = {
feature_item: { backgroundColor: '#fff' },
};
expect(elementStyles).toHaveProperty('feature_item');
});
});
});
describe('Dynamic Repeater Arrays Regression', () => {
describe('Related Posts Array Structure', () => {
it('related_posts resolves to array of post objects', () => {
const resolvedRelatedPosts = [
{ id: 1, title: 'Post 1', url: '/post-1', featured_image: '/img1.jpg', excerpt: 'Excerpt 1' },
{ id: 2, title: 'Post 2', url: '/post-2', featured_image: '/img2.jpg', excerpt: 'Excerpt 2' },
];
// Component should render these as post cards
expect(Array.isArray(resolvedRelatedPosts)).toBe(true);
expect(resolvedRelatedPosts[0]).toHaveProperty('url');
expect(resolvedRelatedPosts[0]).toHaveProperty('title');
});
it('empty related_posts shows placeholder', () => {
const emptyRelatedPosts: any[] = [];
// Component should show empty state message
expect(emptyRelatedPosts).toHaveLength(0);
});
});
describe('Dynamic Features Array', () => {
it('features can be dynamic source', () => {
const dynamicFeatures = {
features: { type: 'dynamic', source: 'product_categories' },
};
expect(dynamicFeatures.features.type).toBe('dynamic');
});
});
});

View File

@@ -0,0 +1,26 @@
{
"schemaVersion": 0,
"sections": [
{
"id": "fixture-feature-grid-legacy",
"type": "feature-grid",
"layoutVariant": "grid-2",
"colorScheme": "default",
"props": {
"heading": {
"type": "static",
"value": "Legacy Features"
},
"features": {
"type": "static",
"value": [
{
"title": "Legacy-compatible",
"description": "Older saved JSON using features should still render."
}
]
}
}
}
]
}

View File

@@ -0,0 +1,35 @@
{
"schemaVersion": 1,
"sections": [
{
"id": "fixture-feature-grid",
"type": "feature-grid",
"layoutVariant": "grid-3",
"colorScheme": "default",
"styles": {
"contentWidth": "contained"
},
"props": {
"heading": {
"type": "static",
"value": "Our Features"
},
"items": {
"type": "static",
"value": [
{
"title": "Fast setup",
"description": "Launch a storefront page with reusable sections.",
"icon": "Zap"
},
{
"title": "Consistent render",
"description": "Use one contract across editor, storefront, and SSR.",
"icon": "Layers"
}
]
}
}
}
]
}

View File

@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const fixtureDir = resolve('tests/fixtures/page-editor');
function readFixture(name) {
return JSON.parse(readFileSync(resolve(fixtureDir, name), 'utf8'));
}
function assertSectionProp(prop, expectedType = 'static') {
assert.equal(typeof prop, 'object');
assert.equal(prop.type, expectedType);
assert.ok('value' in prop || 'source' in prop);
}
describe('page editor fixtures', () => {
it('uses canonical feature-grid.items for v1 fixtures', () => {
const fixture = readFixture('feature-grid-v1.json');
const section = fixture.sections[0];
assert.equal(fixture.schemaVersion, 1);
assert.equal(section.type, 'feature-grid');
assertSectionProp(section.props.heading);
assertSectionProp(section.props.items);
assert.ok(Array.isArray(section.props.items.value));
assert.equal(section.props.features, undefined);
});
it('keeps a legacy feature-grid.features fixture for migration coverage', () => {
const fixture = readFixture('feature-grid-legacy-features.json');
const section = fixture.sections[0];
assert.equal(fixture.schemaVersion, 0);
assert.equal(section.type, 'feature-grid');
assertSectionProp(section.props.heading);
assertSectionProp(section.props.features);
assert.ok(Array.isArray(section.props.features.value));
});
});

748
tests/parity.test.ts Normal file
View File

@@ -0,0 +1,748 @@
/**
* React vs SSR Parity Tests
* Tests that compare React-rendered content against SSR HTML output
* to ensure consistent rendering between client and server
*
* Note: These are snapshot-based tests. Run with --update to regenerate snapshots.
*/
import { describe, it, expect, vi } from 'vitest';
import { renderToStaticMarkup } from 'react-dom/server';
import {
HeroSection,
ContentSection,
ImageTextSection,
FeatureGridSection,
CTABannerSection,
ContactFormSection,
BentoCategoryGrid,
ProductCarousel,
ShoppableImage,
MarqueeBanner,
} from '../../customer-spa/src/pages/DynamicPage/sections';
// Mock Lucide icons
vi.mock('lucide-react', () => ({
Star: () => null,
Heart: () => null,
Shield: () => null,
Zap: () => null,
Award: () => null,
Clock: () => null,
Truck: () => null,
User: () => null,
Settings: () => null,
}));
// Helper to extract text content from HTML for comparison
function extractTextContent(html: string): string {
return html
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
// Helper to check if class exists in HTML
function hasClass(html: string, className: string): boolean {
return html.includes(`class="${className}"`) || html.includes(`class="${className} `) || html.includes(` ${className} `);
}
// Helper to check if style attribute exists
function hasStyle(html: string, stylePart: string): boolean {
return html.includes(`style="`) && html.includes(stylePart);
}
// Test data fixtures
const FIXTURES = {
hero: {
id: 'hero-test',
title: 'Welcome to Our Store',
subtitle: 'Discover amazing products',
image: 'https://example.com/hero.jpg',
cta_text: 'Shop Now',
cta_url: '/shop',
styles: {},
elementStyles: {},
},
content: {
id: 'content-test',
content: '<p>This is rich content with formatting.</p>',
styles: {},
elementStyles: {},
},
imageText: {
id: 'image-text-test',
title: 'About Our Company',
text: 'We are passionate about quality.',
image: 'https://example.com/about.jpg',
cta_text: 'Learn More',
cta_url: '/about',
styles: {},
elementStyles: {},
},
featureGrid: {
id: 'features-test',
heading: 'Our Features',
items: [
{ title: 'Feature 1', description: 'Description 1', icon: 'Star' },
{ title: 'Feature 2', description: 'Description 2', icon: 'Heart' },
{ title: 'Feature 3', description: 'Description 3', icon: 'Shield' },
],
layout: 'grid-3',
colorScheme: 'default',
styles: {},
elementStyles: {},
},
ctaBanner: {
id: 'cta-test',
title: 'Ready to Get Started?',
text: 'Join thousands of happy customers.',
button_text: 'Sign Up Now',
button_url: '/signup',
layout: 'default',
colorScheme: 'primary',
styles: {},
elementStyles: {},
},
contactForm: {
id: 'contact-test',
title: 'Contact Us',
webhook_url: 'https://example.com/webhook',
redirect_url: '/thank-you',
styles: {},
elementStyles: {},
},
bentoGrid: {
id: 'bento-test',
title: 'Shop by Category',
items: [
{ label: 'Electronics', url: '/category/electronics', image: 'https://example.com/elec.jpg', size: 'large' },
{ label: 'Clothing', url: '/category/clothing', size: 'medium' },
],
colorScheme: 'default',
styles: {},
elementStyles: {},
},
productCarousel: {
id: 'carousel-test',
title: 'Trending Products',
subtitle: 'Popular right now',
cta_text: 'View All',
cta_url: '/products',
products: [
{ name: 'Product 1', url: '/product/1', image: 'https://example.com/p1.jpg', price: '$29.99' },
{ name: 'Product 2', url: '/product/2', image: 'https://example.com/p2.jpg', price: '$39.99' },
],
colorScheme: 'default',
styles: {},
elementStyles: {},
},
shoppableImage: {
id: 'shoppable-test',
title: 'Shop the Look',
subtitle: 'Get inspired',
image: 'https://example.com/look.jpg',
hotspots: [
{ product_name: 'Item 1', product_slug: 'item-1', product_price: '$49.99', x: '25', y: '30' },
{ product_name: 'Item 2', product_slug: 'item-2', product_price: '$59.99', x: '65', y: '50' },
],
colorScheme: 'default',
styles: {},
elementStyles: {},
},
marquee: {
id: 'marquee-test',
text: 'Free Shipping*Easy Returns*24/7 Support',
separator: '*',
styles: {},
elementStyles: {},
},
};
describe('React vs SSR Parity Tests', () => {
describe('Hero Section', () => {
it('renders title content', () => {
const html = renderToStaticMarkup(
HeroSection({
...FIXTURES.hero,
colorScheme: 'default',
})
);
expect(extractTextContent(html)).toContain('Welcome to Our Store');
});
it('renders subtitle content', () => {
const html = renderToStaticMarkup(
HeroSection({
...FIXTURES.hero,
colorScheme: 'default',
})
);
expect(extractTextContent(html)).toContain('Discover amazing products');
});
it('renders CTA button', () => {
const html = renderToStaticMarkup(
HeroSection({
...FIXTURES.hero,
colorScheme: 'default',
})
);
expect(html).toContain('href="/shop"');
expect(extractTextContent(html)).toContain('Shop Now');
});
it('has section class for SSR matching', () => {
const html = renderToStaticMarkup(
HeroSection({
...FIXTURES.hero,
colorScheme: 'default',
})
);
expect(hasClass(html, 'wn-hero')).toBe(true);
expect(hasClass(html, 'wn-section')).toBe(true);
});
});
describe('Content Section', () => {
it('renders content with HTML', () => {
const html = renderToStaticMarkup(
ContentSection({
...FIXTURES.content,
colorScheme: 'default',
})
);
expect(html).toContain('This is rich content');
});
it('has section class for SSR matching', () => {
const html = renderToStaticMarkup(
ContentSection({
...FIXTURES.content,
colorScheme: 'default',
})
);
expect(hasClass(html, 'wn-content')).toBe(true);
expect(hasClass(html, 'wn-section')).toBe(true);
});
});
describe('Image + Text Section', () => {
it('renders title', () => {
const html = renderToStaticMarkup(
ImageTextSection({
...FIXTURES.imageText,
layout: 'image-left',
colorScheme: 'default',
})
);
expect(extractTextContent(html)).toContain('About Our Company');
});
it('renders text content', () => {
const html = renderToStaticMarkup(
ImageTextSection({
...FIXTURES.imageText,
layout: 'image-left',
colorScheme: 'default',
})
);
expect(extractTextContent(html)).toContain('We are passionate');
});
it('renders image with correct src', () => {
const html = renderToStaticMarkup(
ImageTextSection({
...FIXTURES.imageText,
layout: 'image-left',
colorScheme: 'default',
})
);
expect(html).toContain('https://example.com/about.jpg');
});
it('has section class for SSR matching', () => {
const html = renderToStaticMarkup(
ImageTextSection({
...FIXTURES.imageText,
layout: 'image-left',
colorScheme: 'default',
})
);
expect(hasClass(html, 'wn-image-text')).toBe(true);
});
});
describe('Feature Grid Section', () => {
it('renders heading', () => {
const html = renderToStaticMarkup(
FeatureGridSection({
...FIXTURES.featureGrid,
colorScheme: 'default',
})
);
expect(extractTextContent(html)).toContain('Our Features');
});
it('renders all feature items', () => {
const html = renderToStaticMarkup(
FeatureGridSection({
...FIXTURES.featureGrid,
colorScheme: 'default',
})
);
expect(extractTextContent(html)).toContain('Feature 1');
expect(extractTextContent(html)).toContain('Feature 2');
expect(extractTextContent(html)).toContain('Feature 3');
});
it('renders feature descriptions', () => {
const html = renderToStaticMarkup(
FeatureGridSection({
...FIXTURES.featureGrid,
colorScheme: 'default',
})
);
expect(extractTextContent(html)).toContain('Description 1');
});
it('has section class for SSR matching', () => {
const html = renderToStaticMarkup(
FeatureGridSection({
...FIXTURES.featureGrid,
colorScheme: 'default',
})
);
expect(hasClass(html, 'wn-feature-grid')).toBe(true);
});
it('has grid layout class', () => {
const html = renderToStaticMarkup(
FeatureGridSection({
...FIXTURES.featureGrid,
layout: 'grid-3',
colorScheme: 'default',
})
);
expect(hasClass(html, 'wn-feature-grid--grid-3')).toBe(true);
});
});
describe('CTA Banner Section', () => {
it('renders title', () => {
const html = renderToStaticMarkup(
CTABannerSection({
...FIXTURES.ctaBanner,
colorScheme: 'default',
})
);
expect(extractTextContent(html)).toContain('Ready to Get Started?');
});
it('renders description', () => {
const html = renderToStaticMarkup(
CTABannerSection({
...FIXTURES.ctaBanner,
colorScheme: 'default',
})
);
expect(extractTextContent(html)).toContain('Join thousands');
});
it('renders button with href', () => {
const html = renderToStaticMarkup(
CTABannerSection({
...FIXTURES.ctaBanner,
colorScheme: 'default',
})
);
expect(html).toContain('href="/signup"');
expect(extractTextContent(html)).toContain('Sign Up Now');
});
it('has section class for SSR matching', () => {
const html = renderToStaticMarkup(
CTABannerSection({
...FIXTURES.ctaBanner,
colorScheme: 'default',
})
);
expect(hasClass(html, 'wn-cta-banner')).toBe(true);
});
});
describe('Contact Form Section', () => {
it('renders title', () => {
const html = renderToStaticMarkup(
ContactFormSection({
...FIXTURES.contactForm,
colorScheme: 'default',
})
);
expect(extractTextContent(html)).toContain('Contact Us');
});
it('renders form inputs', () => {
const html = renderToStaticMarkup(
ContactFormSection({
...FIXTURES.contactForm,
colorScheme: 'default',
})
);
expect(html).toContain('name="name"');
expect(html).toContain('name="email"');
expect(html).toContain('name="message"');
});
it('has section class for SSR matching', () => {
const html = renderToStaticMarkup(
ContactFormSection({
...FIXTURES.contactForm,
colorScheme: 'default',
})
);
expect(hasClass(html, 'wn-contact-form')).toBe(true);
});
});
describe('Bento Category Grid Section', () => {
it('renders title', () => {
const html = renderToStaticMarkup(
BentoCategoryGrid({
...FIXTURES.bentoGrid,
colorScheme: 'default',
})
);
expect(extractTextContent(html)).toContain('Shop by Category');
});
it('renders category labels', () => {
const html = renderToStaticMarkup(
BentoCategoryGrid({
...FIXTURES.bentoGrid,
colorScheme: 'default',
})
);
expect(extractTextContent(html)).toContain('Electronics');
expect(extractTextContent(html)).toContain('Clothing');
});
it('renders links', () => {
const html = renderToStaticMarkup(
BentoCategoryGrid({
...FIXTURES.bentoGrid,
colorScheme: 'default',
})
);
expect(html).toContain('href="/category/electronics"');
});
it('has section class for SSR matching', () => {
const html = renderToStaticMarkup(
BentoCategoryGrid({
...FIXTURES.bentoGrid,
colorScheme: 'default',
})
);
expect(hasClass(html, 'wn-bento-grid')).toBe(true);
});
});
describe('Product Carousel Section', () => {
it('renders title', () => {
const html = renderToStaticMarkup(
ProductCarousel({
...FIXTURES.productCarousel,
colorScheme: 'default',
})
);
expect(extractTextContent(html)).toContain('Trending Products');
});
it('renders products', () => {
const html = renderToStaticMarkup(
ProductCarousel({
...FIXTURES.productCarousel,
colorScheme: 'default',
})
);
expect(extractTextContent(html)).toContain('Product 1');
expect(extractTextContent(html)).toContain('Product 2');
});
it('renders product prices', () => {
const html = renderToStaticMarkup(
ProductCarousel({
...FIXTURES.productCarousel,
colorScheme: 'default',
})
);
expect(html).toContain('$29.99');
});
it('has section class for SSR matching', () => {
const html = renderToStaticMarkup(
ProductCarousel({
...FIXTURES.productCarousel,
colorScheme: 'default',
})
);
expect(hasClass(html, 'wn-product-carousel')).toBe(true);
});
});
describe('Shoppable Image Section', () => {
it('renders title', () => {
const html = renderToStaticMarkup(
ShoppableImage({
...FIXTURES.shoppableImage,
colorScheme: 'default',
})
);
expect(extractTextContent(html)).toContain('Shop the Look');
});
it('renders image', () => {
const html = renderToStaticMarkup(
ShoppableImage({
...FIXTURES.shoppableImage,
colorScheme: 'default',
})
);
expect(html).toContain('https://example.com/look.jpg');
});
it('renders hotspots', () => {
const html = renderToStaticMarkup(
ShoppableImage({
...FIXTURES.shoppableImage,
colorScheme: 'default',
})
);
expect(extractTextContent(html)).toContain('Item 1');
expect(extractTextContent(html)).toContain('Item 2');
});
it('has section class for SSR matching', () => {
const html = renderToStaticMarkup(
ShoppableImage({
...FIXTURES.shoppableImage,
colorScheme: 'default',
})
);
expect(hasClass(html, 'wn-shoppable-image')).toBe(true);
});
});
describe('Marquee Banner Section', () => {
it('renders text items', () => {
const html = renderToStaticMarkup(
MarqueeBanner({
...FIXTURES.marquee,
colorScheme: 'default',
})
);
expect(extractTextContent(html)).toContain('Free Shipping');
expect(extractTextContent(html)).toContain('Easy Returns');
expect(extractTextContent(html)).toContain('24/7 Support');
});
it('has section class for SSR matching', () => {
const html = renderToStaticMarkup(
MarqueeBanner({
...FIXTURES.marquee,
colorScheme: 'default',
})
);
expect(hasClass(html, 'wn-marquee')).toBe(true);
});
});
});
describe('SSR Class Matching Verification', () => {
/**
* These tests verify that React components use the same CSS classes
* as the PHP SSR renderers. This ensures visual parity.
*/
const SECTION_CLASSES = {
hero: 'wn-hero',
content: 'wn-content',
'image-text': 'wn-image-text',
'feature-grid': 'wn-feature-grid',
'cta-banner': 'wn-cta-banner',
'contact-form': 'wn-contact-form',
'bento-category-grid': 'wn-bento-grid',
'product-carousel': 'wn-product-carousel',
'shoppable-image': 'wn-shoppable-image',
'marquee-banner': 'wn-marquee',
};
it('hero section uses wn-hero class', () => {
const html = renderToStaticMarkup(
HeroSection({
...FIXTURES.hero,
colorScheme: 'default',
})
);
expect(hasClass(html, SECTION_CLASSES.hero)).toBe(true);
});
it('content section uses wn-content class', () => {
const html = renderToStaticMarkup(
ContentSection({
...FIXTURES.content,
colorScheme: 'default',
})
);
expect(hasClass(html, SECTION_CLASSES.content)).toBe(true);
});
it('image-text section uses wn-image-text class', () => {
const html = renderToStaticMarkup(
ImageTextSection({
...FIXTURES.imageText,
layout: 'image-left',
colorScheme: 'default',
})
);
expect(hasClass(html, SECTION_CLASSES['image-text'])).toBe(true);
});
it('feature-grid section uses wn-feature-grid class', () => {
const html = renderToStaticMarkup(
FeatureGridSection({
...FIXTURES.featureGrid,
colorScheme: 'default',
})
);
expect(hasClass(html, SECTION_CLASSES['feature-grid'])).toBe(true);
});
it('cta-banner section uses wn-cta-banner class', () => {
const html = renderToStaticMarkup(
CTABannerSection({
...FIXTURES.ctaBanner,
colorScheme: 'default',
})
);
expect(hasClass(html, SECTION_CLASSES['cta-banner'])).toBe(true);
});
it('contact-form section uses wn-contact-form class', () => {
const html = renderToStaticMarkup(
ContactFormSection({
...FIXTURES.contactForm,
colorScheme: 'default',
})
);
expect(hasClass(html, SECTION_CLASSES['contact-form'])).toBe(true);
});
it('bento-category-grid section uses wn-bento-grid class', () => {
const html = renderToStaticMarkup(
BentoCategoryGrid({
...FIXTURES.bentoGrid,
colorScheme: 'default',
})
);
expect(hasClass(html, SECTION_CLASSES['bento-category-grid'])).toBe(true);
});
it('product-carousel section uses wn-product-carousel class', () => {
const html = renderToStaticMarkup(
ProductCarousel({
...FIXTURES.productCarousel,
colorScheme: 'default',
})
);
expect(hasClass(html, SECTION_CLASSES['product-carousel'])).toBe(true);
});
it('shoppable-image section uses wn-shoppable-image class', () => {
const html = renderToStaticMarkup(
ShoppableImage({
...FIXTURES.shoppableImage,
colorScheme: 'default',
})
);
expect(hasClass(html, SECTION_CLASSES['shoppable-image'])).toBe(true);
});
it('marquee-banner section uses wn-marquee class', () => {
const html = renderToStaticMarkup(
MarqueeBanner({
...FIXTURES.marquee,
colorScheme: 'default',
})
);
expect(hasClass(html, SECTION_CLASSES['marquee-banner'])).toBe(true);
});
});
describe('Color Scheme Parity', () => {
it('hero supports colorScheme prop', () => {
const html = renderToStaticMarkup(
HeroSection({
...FIXTURES.hero,
colorScheme: 'primary',
})
);
expect(hasClass(html, 'wn-scheme--primary')).toBe(true);
});
it('feature-grid supports colorScheme prop', () => {
const html = renderToStaticMarkup(
FeatureGridSection({
...FIXTURES.featureGrid,
colorScheme: 'muted',
})
);
expect(hasClass(html, 'wn-scheme--muted')).toBe(true);
});
it('cta-banner supports colorScheme prop', () => {
const html = renderToStaticMarkup(
CTABannerSection({
...FIXTURES.ctaBanner,
colorScheme: 'secondary',
})
);
expect(hasClass(html, 'wn-scheme--secondary')).toBe(true);
});
});

View File

@@ -0,0 +1,279 @@
/**
* Page Editor Schema Tests
* Tests for canonical section schema and normalization
*/
import { describe, it, expect, beforeEach } from 'vitest';
import {
SECTION_SCHEMAS,
getSectionSchema,
cloneDefaultProps,
cloneDefaultStyles,
normalizeFeatureGridProps,
} from '../../admin-spa/src/routes/Appearance/Pages/schema/sectionSchema';
describe('Section Schema', () => {
describe('SECTION_SCHEMAS', () => {
it('includes all required section types', () => {
const expectedTypes = [
'hero',
'content',
'image-text',
'feature-grid',
'cta-banner',
'contact-form',
'bento-category-grid',
'product-carousel',
'shoppable-image',
'marquee-banner',
];
expectedTypes.forEach((type) => {
expect(SECTION_SCHEMAS).toHaveProperty(type);
});
});
it('feature-grid uses items not features', () => {
const featureGrid = SECTION_SCHEMAS['feature-grid'];
expect(featureGrid.defaultProps).toHaveProperty('items');
expect(featureGrid.defaultProps.items.type).toBe('static');
expect(Array.isArray(featureGrid.defaultProps.items.value)).toBe(true);
expect(featureGrid.defaultProps.items.value).toHaveLength(0);
});
it('hero section has correct default props', () => {
const hero = SECTION_SCHEMAS['hero'];
expect(hero.defaultProps).toHaveProperty('title');
expect(hero.defaultProps).toHaveProperty('subtitle');
expect(hero.defaultProps).toHaveProperty('image');
expect(hero.defaultProps).toHaveProperty('cta_text');
expect(hero.defaultProps).toHaveProperty('cta_url');
});
it('all sections have defaultStyles with contentWidth', () => {
Object.values(SECTION_SCHEMAS).forEach((schema) => {
expect(schema.defaultStyles).toBeDefined();
expect(schema.defaultStyles).toHaveProperty('contentWidth');
});
});
it('all sections have fields defined', () => {
Object.values(SECTION_SCHEMAS).forEach((schema) => {
expect(Array.isArray(schema.fields)).toBe(true);
expect(schema.fields.length).toBeGreaterThan(0);
});
});
});
describe('getSectionSchema', () => {
it('returns schema for valid section type', () => {
const schema = getSectionSchema('hero');
expect(schema).toBeDefined();
expect(schema?.type).toBe('hero');
});
it('returns undefined for invalid section type', () => {
const schema = getSectionSchema('nonexistent');
expect(schema).toBeUndefined();
});
});
describe('cloneDefaultProps', () => {
it('returns props object for valid type', () => {
const props = cloneDefaultProps('hero');
expect(props).toHaveProperty('title');
expect(props).toHaveProperty('subtitle');
});
it('returns empty object for invalid type', () => {
const props = cloneDefaultProps('nonexistent');
expect(Object.keys(props)).toHaveLength(0);
});
it('clones arrays correctly (no reference sharing)', () => {
const props1 = cloneDefaultProps('feature-grid');
const props2 = cloneDefaultProps('feature-grid');
props1.items.value = [{ title: 'Test' }];
expect(props2.items.value).toHaveLength(0);
});
});
describe('cloneDefaultStyles', () => {
it('returns default styles for valid type', () => {
const styles = cloneDefaultStyles('hero');
expect(styles).toHaveProperty('contentWidth');
});
it('returns undefined for invalid type', () => {
const styles = cloneDefaultStyles('nonexistent');
expect(styles).toBeUndefined();
});
});
describe('normalizeFeatureGridProps', () => {
it('leaves props unchanged when items exists', () => {
const props = {
items: [{ title: 'Feature 1' }],
heading: 'Our Features',
};
const normalized = normalizeFeatureGridProps(props);
expect(normalized.items).toHaveLength(1);
expect(normalized.items[0].title).toBe('Feature 1');
});
it('copies features to items when items is undefined', () => {
const props = {
features: [{ title: 'Legacy Feature' }],
heading: 'Our Features',
};
const normalized = normalizeFeatureGridProps(props);
expect(normalized.items).toHaveLength(1);
expect(normalized.items[0].title).toBe('Legacy Feature');
});
it('handles empty props', () => {
const props = {};
const normalized = normalizeFeatureGridProps(props);
expect(normalized).toEqual({});
});
it('handles null/undefined input gracefully', () => {
expect(normalizeFeatureGridProps(null)).toBeNull();
expect(normalizeFeatureGridProps(undefined)).toBeUndefined();
});
});
});
describe('Section Editor Store Schema Integration', () => {
// Test that the store uses the schema correctly
it('section schema is compatible with store SectionProp interface', () => {
const props = cloneDefaultProps('hero');
// Check SectionProp structure
expect(props.title).toHaveProperty('type');
expect(props.title).toHaveProperty('value');
// Ensure type is valid
expect(props.title.type).toMatch(/^(static|dynamic)$/);
});
it('feature-grid default items is an empty array', () => {
const props = cloneDefaultProps('feature-grid');
expect(Array.isArray(props.items.value)).toBe(true);
expect(props.items.value).toHaveLength(0);
});
});
describe('Canvas Renderer Schema Integration', () => {
// Test flattenSectionProps behavior with schema
const flattenSectionProps = (section: any) => {
const props = section.type === 'feature-grid'
? normalizeFeatureGridProps(section.props || {})
: section.props || {};
const flattened: Record<string, any> = {};
for (const [key, value] of Object.entries(props)) {
if (value && typeof value === 'object' && 'type' in value && 'value' in value) {
flattened[key] = value.value;
} else if (value && typeof value === 'object' && 'type' in value && 'source' in value) {
flattened[key] = `[${value.source}]`;
} else {
flattened[key] = value;
}
}
return flattened;
};
it('flattens static props correctly', () => {
const section = {
type: 'hero',
props: {
title: { type: 'static', value: 'Welcome' },
subtitle: { type: 'static', value: 'Subtitle text' },
},
};
const flattened = flattenSectionProps(section);
expect(flattened.title).toBe('Welcome');
expect(flattened.subtitle).toBe('Subtitle text');
});
it('flattens dynamic props with placeholder marker', () => {
const section = {
type: 'hero',
props: {
title: { type: 'dynamic', source: 'post_title' },
},
};
const flattened = flattenSectionProps(section);
expect(flattened.title).toBe('[post_title]');
});
it('flattens feature-grid items correctly', () => {
const section = {
type: 'feature-grid',
props: {
heading: { type: 'static', value: 'Features' },
items: {
type: 'static',
value: [
{ title: 'Feature 1', icon: 'Star' },
{ title: 'Feature 2', icon: 'Heart' },
],
},
},
};
const flattened = flattenSectionProps(section);
expect(flattened.heading).toBe('Features');
expect(Array.isArray(flattened.items)).toBe(true);
expect(flattened.items).toHaveLength(2);
expect(flattened.items[0].title).toBe('Feature 1');
});
it('handles mixed static and dynamic props', () => {
const section = {
type: 'image-text',
props: {
title: { type: 'dynamic', source: 'post_title' },
text: { type: 'static', value: 'Static description' },
image: { type: 'dynamic', source: 'post_featured_image' },
},
};
const flattened = flattenSectionProps(section);
expect(flattened.title).toBe('[post_title]');
expect(flattened.text).toBe('Static description');
expect(flattened.image).toBe('[post_featured_image]');
});
it('handles nested props without SectionProp wrapper', () => {
const section = {
type: 'feature-grid',
props: {
items: [
{ title: 'Direct Item 1' },
{ title: 'Direct Item 2' },
],
},
};
const flattened = flattenSectionProps(section);
expect(Array.isArray(flattened.items)).toBe(true);
expect(flattened.items).toHaveLength(2);
});
});