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:
382
tests/PageSSRTest.php
Normal file
382
tests/PageSSRTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
224
tests/PlaceholderRendererTest.php
Normal file
224
tests/PlaceholderRendererTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
207
tests/SchemaMigrationTest.php
Normal file
207
tests/SchemaMigrationTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
205
tests/VERIFICATION_CHECKLIST.md
Normal file
205
tests/VERIFICATION_CHECKLIST.md
Normal 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._
|
||||
|
||||
---
|
||||
223
tests/feature-grid-regression.test.ts
Normal file
223
tests/feature-grid-regression.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
26
tests/fixtures/page-editor/feature-grid-legacy-features.json
vendored
Normal file
26
tests/fixtures/page-editor/feature-grid-legacy-features.json
vendored
Normal 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."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
35
tests/fixtures/page-editor/feature-grid-v1.json
vendored
Normal file
35
tests/fixtures/page-editor/feature-grid-v1.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
41
tests/page-editor-fixtures.test.mjs
Normal file
41
tests/page-editor-fixtures.test.mjs
Normal 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
748
tests/parity.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
279
tests/schema-integration.test.ts
Normal file
279
tests/schema-integration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user