chore(repo): exclude local development files

This commit is contained in:
2026-08-17 20:08:56 +08:00
parent ac5c0b2dd7
commit 7419130066
32 changed files with 11 additions and 10806 deletions
-50
View File
@@ -1,50 +0,0 @@
# Admin contract map
Generated by process, not a migration guide. Source of truth for menu ↔ schema wiring.
## Rules
1. Every `schemaPage` must have matching PHP section under the same domain.
2. Enum field values must be identical in PHP schema and JS schema.
3. REST `'callback' => [$this, 'method']` methods must exist (class or trait).
4. Deploy `assets/admin/build/` as one atomic unit after `npm run build`.
5. No compat aliases, dual-read, or silent frontend catch for missing APIs.
## Audit
```bash
php tools/audit-admin-contracts.php
```
Exit code `1` means hard contract errors.
## Domain builders
| Domain | PHP builder map key | Menu constants file |
|--------|---------------------|---------------------|
| global | `AKSRT_Admin_Schema_Global` | `assets/admin/src/constants/sections/global.js` |
| page_display | `AKSRT_Admin_Schema_Page_Display` | `page-display.js` |
| post_list | `AKSRT_Admin_Schema_Post_List` | `post-list.js` |
| permissions | `AKSRT_Admin_Schema_Permissions` | `permissions.js` |
| users | `AKSRT_Admin_Schema_Users` | `users.js` |
| payment | `AKSRT_Admin_Schema_Payment` | `payment.js` |
| extensions | `AKSRT_Admin_Schema_Extensions` | `extensions.js` |
| tools | `AKSRT_Admin_Schema_Tools` | `tools.js` |
| shop | `AKSRT_Admin_Schema_Shop` | `shop.js` |
## Known custom pages (not schema REST)
These use dedicated Vue pages + dedicated REST modules:
- Homepage: `HomepageSettings` + `/settings/homepage`
- Footer: `FooterSettings` + footer settings API
- Permissions capabilities matrix: `PermissionsCapabilities` + `/permissions/capabilities`
- Shop products/orders and other operational tables
## Deploy checklist
1. `cd assets/admin && npm ci && npm run build`
2. Confirm `build/index.js` and every dynamically imported `./Chunk.hash.js` exist
3. Replace the entire remote `assets/admin/build/` directory
4. Hard-refresh wp-admin (or purge CDN)
5. Re-run `php tools/audit-admin-contracts.php`
-61
View File
@@ -1,61 +0,0 @@
# ADR: Domain folder placement + PSR-4 namespaces for theme types
## Status
Accepted (2026-07-17); **updated 2026-08-02** for Composer PSR-4 root flatten to `src/`.
## Context
Domain folders were frozen after the root→domain move. Theme types then migrated from global `AKSRT_*` + manual `require_once` to real PHP namespaces under `src/` with Composer PSR-4.
## Decision
### Domain ownership (unchanged)
| Concern | Domain | Why |
|---------|--------|-----|
| Theme shell (setup, scripts, templates, SEO, meta boxes, DB migrator, front-submit, docs presenter, home modules, stats, redirects, update checker, comment actions) | `Core` | Shared CMS plumbing |
| SPA shell + controllers + schema + native ops | `Admin` | Dual-surface IA |
| Settings facade + runtime delegates | `Settings` | Single public settings API |
| Auth, OAuth, captcha, moderation, email rollback | `Auth` | Identity & access |
| User center/home/badges/level/metrics/notifications/follow | `User` | Account surface |
| Credits, balance wallet, signin, invite, points-free | `Credits` | Points + cash wallet |
| Download tokens/state, content pay, VIP, visibility | `Content` | Post-centric paid content |
| Orders, gateways, coupon, charge-card, revenue, creator-revenue, withdrawal, rebate, discount, after-sale, shipping, wechat-notify | `Payment` | Commerce + settlement |
| Shop services/meta/reviews | `Shop` | Product catalog / cart |
| SMS stack | `Sms` | Messaging channel |
| AI runtime (admin AI controller stays in `Admin`) | `Ai` | Generation helpers |
| Message, private message, site notice, report center | `Messages` | In-site messaging |
| MeiliSearch, search-push, ip-geo, content-audit, api-audit | `Extensions` | Optional integrations |
| Widgets + registry | `Widgets` | WP widgets |
| Inline style / custom code / loading overlay | `Frontend` | Asset render helpers |
### PSR-4 layout
- Composer: `"AKSRT\\": "src/"`
- Namespace `AKSRT\{Domain}\…` ↔ path `src/{Domain}/…`
- One type per file; StudlyCaps filename = short class/trait/interface name
- Nested segments: `Admin\Schema`, `Admin\Native`, `Payment\Gateways`, `Admin\Schema\PostList`
- OAuth stays under `Auth` (`AKSRT\Auth\OAuth`) — no separate `oauth/` tree
- Do not move `TopicPay` / `PlateFollowPay` into `Content` without updating this ADR
- `CreatorRevenue` and `WeChatNotify` stay under `Payment`
### Loader
1. `functions.php` loads `vendor/autoload.php` (Composer: third-party + theme PSR-4)
2. Immediately after, canary `class_exists(\AKSRT\Core\DomainModel::class)` — if missing, `wp_die` with `composer install` / `composer dump-autoload -o` (stale `vendor/` after PSR-4 cutover is the common failure)
3. `inc/bootstrap.php` loads helpers + procedural modules (`inc/modules/*`) + core-hooks only
4. Singleton `init()` remains in `functions.php` using FQCNs
Deploy note: `vendor/` is gitignored. After pull/upload, run `composer install --no-dev --optimize-autoloader` (or at least `composer dump-autoload -o`) in the theme root so `autoload_psr4` includes `"AKSRT\\": ["…/src"]`.
### Procedural (not PSR-4)
- `inc/helpers/*` function libraries
- `inc/modules/*` (captcha, user-hub/parts, user-notifications, widgets-register, …)
- `inc/core-hooks.php` (+ parts)
The one-shot `tools/build-psr4-map.php` migrator (and `tools/psr4-map/` artifacts) were removed when `inc/classes` was deleted — they scanned the old tree and cannot run anymore.
## Out of scope
- Dual-read `class_alias` shims for old `AKSRT_*` global names (rewrite references instead)
- Converting all helpers to classes
-436
View File
@@ -1,436 +0,0 @@
<?php
/**
* Admin contract audit — menu ↔ PHP schema ↔ REST $this callbacks ↔ build integrity.
*
* Usage (from theme root):
* php tools/audit-admin-contracts.php
*
* Exit 1 on hard errors.
*/
declare(strict_types=1);
$themeRoot = dirname(__DIR__);
$errors = [];
$warnings = [];
$info = [];
function rel(string $path, string $root): string {
return str_replace('\\', '/', ltrim(str_replace($root, '', $path), '\\/'));
}
/**
* Extract section keys from composite schema builders (including delegated leaf builders).
*
* @return array<string, array{sections: string[], files: string[]}>
*/
function collect_php_schema_sections(string $themeRoot): array {
$map = [];
$register = static function (string $domain, string $section, string $file) use (&$map, $themeRoot): void {
if ($domain === '' || $section === '') {
return;
}
if (!isset($map[$domain])) {
$map[$domain] = ['sections' => [], 'files' => []];
}
$map[$domain]['sections'][$section] = true;
$fileRel = rel($file, $themeRoot);
if (!in_array($fileRel, $map[$domain]['files'], true)) {
$map[$domain]['files'][] = $fileRel;
}
};
// Primary domain builders.
$primary = [
'global' => $themeRoot . '/src/Admin/Schema/GlobalSchema.php',
'page_display' => $themeRoot . '/src/Admin/Schema/SchemaPageDisplay.php',
'post_list' => $themeRoot . '/src/Admin/Schema/SchemaPostList.php',
'permissions' => $themeRoot . '/src/Admin/Schema/SchemaPermissions.php',
'users' => $themeRoot . '/src/Admin/Schema/SchemaUsers.php',
'payment' => $themeRoot . '/src/Admin/Schema/SchemaPayment.php',
'extensions' => $themeRoot . '/src/Admin/Schema/SchemaExtensions.php',
'tools' => $themeRoot . '/src/Admin/Schema/SchemaTools.php',
'shop' => $themeRoot . '/src/Admin/Schema/SchemaShop.php',
];
foreach ($primary as $domain => $file) {
if (!is_file($file)) {
continue;
}
$src = file_get_contents($file);
if ($src === false) {
continue;
}
// Direct section keys: 'foo' => [ or 'foo' => Class::build()
if (preg_match_all("/'([a-z0-9_-]+)'\\s*=>\\s*(?:\\[|[A-Za-z0-9_]+::build\\(\\))/", $src, $m)) {
foreach ($m[1] as $section) {
if (in_array($section, ['domain', 'title', 'version', 'option_key', 'key', 'sections', 'groups', 'fields'], true)) {
continue;
}
$register($domain, $section, $file);
}
}
}
// Leaf builders that return a single section with 'key' => 'section-id'
$leafFiles = array_merge(glob($themeRoot . '/src/Admin/Schema/*.php') ?: [], glob($themeRoot . '/src/Admin/Schema/*/*.php') ?: []);
foreach ($leafFiles as $file) {
$src = file_get_contents($file);
if ($src === false) {
continue;
}
// Skip pure composites that only delegate.
$isComposite = (bool) preg_match("/'sections'\\s*=>\\s*\\[/", $src);
if ($isComposite) {
continue;
}
if (!preg_match("/'key'\\s*=>\\s*'([a-z0-9_-]+)'/", $src, $km)) {
continue;
}
$section = $km[1];
// Infer domain from filename: class-aksrt-admin-schema-{domain}-{section}.php
$base = basename($file, '.php');
$base = preg_replace('/^(class-aksrt-admin-schema-|Schema)/', '', $base);
$parts = explode('-', $base);
// domain may be multi-token (page_display is page-display in menus but page_display in PHP).
// Prefer matching known domains by prefix.
$domain = null;
foreach (array_keys($primary) as $candidate) {
$candidateDash = str_replace('_', '-', $candidate);
if ($base === $candidateDash || str_starts_with($base, $candidateDash . '-')) {
$domain = $candidate;
break;
}
}
if ($domain === null) {
// Fallback: first token / PSR-4 Schema* short names.
if (str_contains($base, 'PostList')) {
$domain = 'post_list';
} else {
$domain = str_replace('-', '_', $parts[0] ?? '');
}
}
// Leaf under Schema/PostList/ with key list → post_list/list
if ($domain === 'post_list' && $section === 'list') {
$register($domain, $section, $file);
continue;
}
$register($domain, $section, $file);
}
foreach ($map as $domain => $payload) {
$map[$domain]['sections'] = array_keys($payload['sections']);
sort($map[$domain]['sections']);
}
return $map;
}
function collect_menu_pages(string $themeRoot): array {
$dir = $themeRoot . '/assets/admin/src/constants/sections';
$pages = [];
foreach (glob($dir . '/*.js') ?: [] as $file) {
$base = basename($file);
if (in_array($base, ['helpers.js', 'index.js'], true)) {
continue;
}
$src = file_get_contents($file);
if ($src === false) {
continue;
}
foreach (['schemaPage' => 'schemaPage', 'customPage' => 'customPage', 'deferredPage' => 'deferredPage'] as $fn => $kind) {
if (!preg_match_all('/' . $fn . '\\(\\s*\\{([\\s\\S]*?)\\}\\s*\\)/', $src, $blocks)) {
continue;
}
foreach ($blocks[1] as $block) {
$page = ['file' => rel($file, $themeRoot), 'kind' => $kind];
foreach (['key', 'canonicalDomain', 'section', 'route', 'component', 'name'] as $prop) {
if (preg_match('/' . $prop . ":\\s*'([^']*)'/", $block, $m)) {
$page[$prop === 'canonicalDomain' ? 'domain' : $prop] = $m[1];
}
}
$pages[] = $page;
}
}
}
return $pages;
}
/**
* Collect enum options for fields with balanced-enough heuristics:
* only consider field objects that include both type radio|select and an options array
* on nearby lines, keyed by the field key property.
*
* @return array{php: array<string, string[]>, js: array<string, string[]>}
*/
function collect_enum_maps(string $themeRoot): array {
$php = [];
$js = [];
$phpFiles = array_merge(glob($themeRoot . '/src/Admin/Schema/*.php') ?: [], glob($themeRoot . '/src/Admin/Schema/*/*.php') ?: []);
foreach ($phpFiles as $file) {
$src = file_get_contents($file);
if ($src === false) {
continue;
}
// Split roughly by field array starts: ['key' => '...'
if (!preg_match_all("/\\['key'\\s*=>\\s*'([^']+)'([\\s\\S]*?)(?=\\['key'\\s*=>|\\]\\s*,\\s*\\[\\s*'key'|\\]\\s*\\]\\s*,\\s*\\[\\s*'key'|$)/", $src, $chunks, PREG_SET_ORDER)) {
continue;
}
foreach ($chunks as $chunk) {
$key = $chunk[1];
$body = $chunk[2];
if (!preg_match("/'type'\\s*=>\\s*'(radio|select)'/", $body)) {
continue;
}
if (!preg_match("/'options'\\s*=>\\s*\\[([\\s\\S]*?)\\]/", $body, $om)) {
continue;
}
$values = [];
if (preg_match_all("/'value'\\s*=>\\s*'([^']*)'/", $om[1], $vm)) {
$values = $vm[1];
}
// Keep first definition; warn later if duplicates differ.
if (!isset($php[$key])) {
$php[$key] = $values;
}
}
}
$jsRoot = $themeRoot . '/assets/admin/src/schema/sections';
if (is_dir($jsRoot)) {
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($jsRoot, FilesystemIterator::SKIP_DOTS));
foreach ($iterator as $file) {
if ($file->getExtension() !== 'js') {
continue;
}
$src = file_get_contents($file->getPathname());
if ($src === false) {
continue;
}
if (!preg_match_all("/\\{\\s*key:\\s*'([^']+)'([\\s\\S]*?)(?=\\{\\s*key:\\s*'|$)/", $src, $chunks, PREG_SET_ORDER)) {
continue;
}
foreach ($chunks as $chunk) {
$key = $chunk[1];
$body = $chunk[2];
// Stop body at next field-ish boundary if options not closed well.
if (!preg_match("/type:\\s*'(radio|select)'/", $body)) {
continue;
}
if (!preg_match("/options:\\s*\\[([\\s\\S]*?)\\]/", $body, $om)) {
continue;
}
$values = [];
if (preg_match_all("/value:\\s*'([^']*)'/", $om[1], $vm)) {
$values = $vm[1];
}
if (!isset($js[$key])) {
$js[$key] = $values;
}
}
}
}
return ['php' => $php, 'js' => $js];
}
function collect_missing_this_callbacks(string $themeRoot): array {
$issues = [];
$adminRoot = $themeRoot . '/src/Admin';
$methodIndex = [];
if (!is_dir($adminRoot)) {
return $issues;
}
$allPhp = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($adminRoot, FilesystemIterator::SKIP_DOTS));
foreach ($allPhp as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$src = file_get_contents($file->getPathname());
if ($src === false) {
continue;
}
if (preg_match_all('/function\\s+([a-zA-Z_][a-zA-Z0-9_]*)\\s*\\(/', $src, $mm)) {
foreach ($mm[1] as $method) {
$methodIndex[$method] = true;
}
}
}
foreach ($allPhp as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$path = $file->getPathname();
$src = file_get_contents($path);
if ($src === false) {
continue;
}
if (!preg_match_all("/'callback'\\s*=>\\s*\\[\\s*\\\$this\\s*,\\s*'([^']+)'\\s*\\]/", $src, $matches)) {
continue;
}
foreach ($matches[1] as $method) {
if (empty($methodIndex[$method])) {
$issues[] = [
'file' => rel($path, $themeRoot),
'method' => $method,
];
}
}
}
return $issues;
}
function audit_build_manifest(string $themeRoot): array {
$buildDir = $themeRoot . '/assets/admin/build';
$result = [
'present' => is_dir($buildDir),
'entry' => false,
'missing_chunks' => [],
'chunks' => [],
];
if (!$result['present']) {
return $result;
}
$entry = $buildDir . '/index.js';
$result['entry'] = is_file($entry);
if (!$result['entry']) {
return $result;
}
$src = file_get_contents($entry);
if ($src === false) {
return $result;
}
if (preg_match_all('/\\.\\/([A-Za-z0-9_.-]+\\.js)/', $src, $m)) {
$result['chunks'] = array_values(array_unique($m[1]));
foreach ($result['chunks'] as $chunk) {
if (!is_file($buildDir . '/' . $chunk)) {
$result['missing_chunks'][] = $chunk;
}
}
}
return $result;
}
$phpSchemas = collect_php_schema_sections($themeRoot);
$menuPages = collect_menu_pages($themeRoot);
$enums = collect_enum_maps($themeRoot);
$restIssues = collect_missing_this_callbacks($themeRoot);
$build = audit_build_manifest($themeRoot);
$info[] = 'PHP schema domains: ' . implode(', ', array_keys($phpSchemas));
$info[] = 'Menu pages discovered: ' . count($menuPages);
foreach ($menuPages as $page) {
if (($page['kind'] ?? '') !== 'schemaPage') {
continue;
}
$domain = $page['domain'] ?? '';
$section = $page['section'] ?? '';
$key = $page['key'] ?? '(unknown)';
if ($domain === '' || $section === '') {
$errors[] = "schemaPage {$key} missing canonicalDomain/section in {$page['file']}";
continue;
}
if (!isset($phpSchemas[$domain])) {
$errors[] = "schemaPage {$key} domain '{$domain}' has no PHP schema builder";
continue;
}
if (!in_array($section, $phpSchemas[$domain]['sections'], true)) {
$errors[] = "schemaPage {$key} section '{$domain}/{$section}' missing in PHP schema; known=[" . implode(',', $phpSchemas[$domain]['sections']) . '] files=' . implode(',', $phpSchemas[$domain]['files']);
}
}
foreach ($menuPages as $page) {
if (($page['kind'] ?? '') !== 'customPage') {
continue;
}
$domain = $page['domain'] ?? '';
$section = $page['section'] ?? '';
if ($domain === '' || $section === '') {
continue;
}
if (!isset($phpSchemas[$domain])) {
$warnings[] = "customPage {$page['key']} references domain '{$domain}' without PHP schema map entry";
continue;
}
if (!in_array($section, $phpSchemas[$domain]['sections'], true)) {
$warnings[] = "customPage {$page['key']} section '{$domain}/{$section}' not in PHP schema (ok if pure operational page)";
}
}
// Enum mismatches only for shared keys with non-empty option sets on both sides.
// Heuristic parsers often capture only the default option from dense PHP arrays;
// treat "PHP is a non-empty proper subset of JS" as a parser warning, not a hard error.
// Hard error when sets are disjoint or PHP introduces values JS does not have.
$shared = array_intersect(array_keys($enums['php']), array_keys($enums['js']));
sort($shared);
foreach ($shared as $key) {
$phpValues = $enums['php'][$key];
$jsValues = $enums['js'][$key];
if ($phpValues === [] && $jsValues === []) {
continue;
}
$phpSorted = $phpValues;
$jsSorted = $jsValues;
sort($phpSorted);
sort($jsSorted);
if ($phpSorted === $jsSorted) {
continue;
}
$phpOnly = array_values(array_diff($phpValues, $jsValues));
$jsOnly = array_values(array_diff($jsValues, $phpValues));
if ($phpOnly === [] && $jsOnly !== [] && $phpValues !== []) {
$warnings[] = "enum incomplete parse for '{$key}' (php subset of js): php=[" . implode(',', $phpValues) . '] js=[' . implode(',', $jsValues) . ']';
continue;
}
$errors[] = "enum mismatch for '{$key}': php=[" . implode(',', $phpValues) . '] js=[' . implode(',', $jsValues) . '] php_only=[' . implode(',', $phpOnly) . '] js_only=[' . implode(',', $jsOnly) . ']';
}
foreach ($restIssues as $issue) {
$errors[] = "REST \$this callback missing: {$issue['method']} in {$issue['file']}";
}
if (!$build['present']) {
$warnings[] = 'assets/admin/build/ missing — run: cd assets/admin && npm run build';
} elseif (!$build['entry']) {
$errors[] = 'assets/admin/build/index.js missing';
} elseif ($build['missing_chunks']) {
$errors[] = 'admin build missing chunks: ' . implode(', ', $build['missing_chunks']);
} else {
$info[] = 'admin build ok; chunks=' . count($build['chunks']);
}
echo "AKSRT admin contract audit\n";
echo str_repeat('=', 48) . "\n";
foreach ($info as $line) {
echo "[info] {$line}\n";
}
foreach ($warnings as $line) {
echo "[warn] {$line}\n";
}
foreach ($errors as $line) {
echo "[error] {$line}\n";
}
echo str_repeat('-', 48) . "\n";
echo 'errors=' . count($errors) . ' warnings=' . count($warnings) . "\n";
$summaryPath = $themeRoot . '/tools/admin-contract-report.json';
file_put_contents($summaryPath, json_encode([
'generated_at' => gmdate('c'),
'errors' => $errors,
'warnings' => $warnings,
'info' => $info,
'domains' => $phpSchemas,
'page_count' => count($menuPages),
'build' => $build,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
echo "wrote tools/admin-contract-report.json\n";
// Ignore report in git by default? Keep it for CI artifact; do not commit noise if huge.
exit(count($errors) > 0 ? 1 : 0);
-316
View File
@@ -1,316 +0,0 @@
<?php
declare(strict_types=1);
if (!defined('ABSPATH')) {
define('ABSPATH', __DIR__ . DIRECTORY_SEPARATOR);
}
// Minimal WordPress-compatible stubs keep the schema audit runnable without a site checkout.
if (!function_exists('sanitize_text_field')) {
function sanitize_text_field($value): string {
return trim(preg_replace('/[\r\n\t]+/', ' ', strip_tags((string) $value)) ?? '');
}
}
if (!function_exists('__')) {
function __($text): string {
return (string) $text;
}
}
if (!function_exists('sanitize_textarea_field')) {
function sanitize_textarea_field($value): string {
return trim(strip_tags((string) $value));
}
}
if (!function_exists('wp_kses_post')) {
function wp_kses_post($value): string {
return strip_tags((string) $value, '<a><b><strong><em><br><p><span><ul><ol><li>');
}
}
if (!function_exists('esc_url_raw')) {
function esc_url_raw($value): string {
$value = trim((string) $value);
return filter_var($value, FILTER_VALIDATE_URL) ? $value : '';
}
}
if (!function_exists('absint')) {
function absint($value): int {
return abs((int) $value);
}
}
if (!function_exists('sanitize_hex_color')) {
function sanitize_hex_color($value): string {
$value = (string) $value;
return preg_match('/^#(?:[0-9a-fA-F]{3}){1,2}$/', $value) ? strtolower($value) : '';
}
}
require dirname(__DIR__) . '/vendor/autoload.php';
use AKSRT\WidgetSystem\FieldSchema;
use AKSRT\WidgetSystem\PostQuery;
use AKSRT\WidgetSystem\RendererRegistry;
use AKSRT\WidgetSystem\WidgetAreas;
use AKSRT\WidgetSystem\WidgetRegistry;
use AKSRT\WidgetSystem\WidgetTransfer;
$errors = [];
$expected = [
'search', 'posts_toc', 'quote', 'icon_cards', 'video', 'embed', 'media_cards',
'icon_cover_cards', 'partners', 'combo_info', 'text_title', 'buttons', 'layout_gap',
'tag_cloud', 'recent_comments', 'links', 'notice', 'term_cards', 'term_aggregation',
'hot_posts', 'post_pay', 'posts', 'tab_posts', 'oneline_posts', 'pricing_plans',
'reviews', 'faq', 'stats', 'highlights', 'feature_intro', 'tilt_cases', 'slider',
'slider_media_combo', 'user_card', 'author_card', 'user_ranking', 'user_list',
];
$registry = WidgetRegistry::all();
if (count($registry) !== 37) {
$errors[] = 'Registry must contain exactly 37 widgets; got ' . count($registry) . '.';
}
if (array_keys($registry) !== $expected) {
$errors[] = 'Registry slugs or ordering differ from the frozen contract.';
}
$expected_areas = [
'all_top_fluid', 'all_bottom_fluid', 'all_sidebar_top', 'all_sidebar_bottom',
'home_top_fluid', 'home_top_content', 'home_bottom_content', 'home_bottom_fluid', 'home_sidebar',
'single_top_fluid', 'single_top_content', 'single_bottom_content', 'single_bottom_fluid', 'single_sidebar',
'cat_top_fluid', 'cat_top_content', 'cat_bottom_content', 'cat_bottom_fluid', 'cat_sidebar',
'tag_top_fluid', 'tag_top_content', 'tag_bottom_content', 'tag_bottom_fluid', 'tag_sidebar',
'search_top_fluid', 'search_top_content', 'search_bottom_content', 'search_bottom_fluid', 'search_sidebar',
'author_top_fluid', 'author_bottom_fluid', 'msg_top_fluid', 'msg_bottom_fluid',
'user_top_fluid', 'user_top_content', 'user_bottom_fluid',
'newposts_sidebar_top', 'newposts_sidebar_bottom', 'all_footer', 'mobile_nav_fluid',
];
if (WidgetAreas::static_ids() !== $expected_areas || count(WidgetAreas::static_ids()) !== 40) {
$errors[] = 'Widget area registry must contain the exact 40 static areas.';
}
$templates = WidgetTransfer::templates();
$expected_templates = ['article_home', 'article_list', 'links_navigation', 'article_sidebar', 'knowledge_landing'];
if (array_keys($templates) !== $expected_templates || count($templates) !== 5) {
$errors[] = 'Import/export must expose the exact five built-in templates.';
}
foreach ($templates as $template => $contract) {
foreach ((array) ($contract['sidebars'] ?? []) as $items) {
foreach ((array) $items as $item) {
if (!isset($registry[(string) ($item['widget'] ?? '')])) {
$errors[] = $template . ': template contains an unknown widget.';
}
}
}
}
$supported = FieldSchema::supported_types();
$walk = static function (array $fields, string $owner) use (&$walk, &$errors, $supported): void {
$ids = [];
foreach ($fields as $field) {
$id = (string) ($field['id'] ?? '');
$type = (string) ($field['type'] ?? 'text');
if (!in_array($type, $supported, true)) {
$errors[] = $owner . ': unsupported field type ' . $type . '.';
}
if ($id !== '') {
if (isset($ids[$id])) {
$errors[] = $owner . ': duplicate field id ' . $id . '.';
}
$ids[$id] = true;
}
if (!empty($field['fields']) && is_array($field['fields'])) {
$walk($field['fields'], $owner . '.' . ($id ?: $type));
}
}
};
foreach ($registry as $slug => $definition) {
if ($definition->id_base() !== 'aksrt_widget_' . $slug) {
$errors[] = $slug . ': invalid id_base.';
}
if (preg_match('/(?:bbs|forum|shop|cart|inventory|shipping)/i', $slug . ' ' . $definition->renderer())) {
$errors[] = $slug . ': forbidden forum/shop contract.';
}
$fields = $definition->fields();
$walk($fields, $slug);
$field_ids = array_column($fields, 'id');
foreach (['show_type', 'show_id_type', 'show_ids', 'animation_in', 'layout_pin_pc', 'layout_bg'] as $required) {
if (!in_array($required, $field_ids, true)) {
$errors[] = $slug . ': missing common field ' . $required . '.';
}
}
}
$renderers = RendererRegistry::all();
if (count($renderers) !== 37 || array_keys($renderers) !== $expected) {
$errors[] = 'Renderer registry must map the exact 37 frozen slugs.';
}
foreach ($renderers as $slug => $renderer) {
if (!is_callable([$renderer, 'render'])) {
$errors[] = $slug . ': renderer is not callable.';
}
}
$dynamic = ['posts', 'tab_posts', 'oneline_posts', 'hot_posts'];
foreach ($expected as $slug) {
if (RendererRegistry::supports_items($slug) !== in_array($slug, $dynamic, true)) {
$errors[] = $slug . ': invalid dynamic items capability.';
}
}
if (!defined('DAY_IN_SECONDS')) {
define('DAY_IN_SECONDS', 86400);
}
$query_args = PostQuery::arguments([
'count' => 7,
'category_ids' => '2,5',
'tag_ids' => '8,13',
'limit_day' => 30,
'orderby' => 'views',
'order' => 'ASC',
'pay_type' => 'video',
], 3);
if (($query_args['posts_per_page'] ?? 0) !== 7 || ($query_args['paged'] ?? 0) !== 3) {
$errors[] = 'Post query pagination contract failed.';
}
if (($query_args['category__in'] ?? []) !== [2, 5] || ($query_args['tag__in'] ?? []) !== [8, 13]) {
$errors[] = 'Post query taxonomy contract failed.';
}
if (($query_args['orderby'] ?? '') !== 'meta_value_num' || ($query_args['order'] ?? '') !== 'ASC') {
$errors[] = 'Post query ordering contract failed.';
}
$pay_query = json_encode($query_args['meta_query'] ?? []);
if (!is_string($pay_query) || !str_contains($pay_query, 'video')) {
$errors[] = 'Post query paid-content contract failed.';
}
$rest_source = (string) file_get_contents(dirname(__DIR__) . '/src/WidgetSystem/RestController.php');
foreach ([
"'permission_callback' => '__return_true'",
"'instance' => ['type' => 'string', 'required' => true",
"'page' => ['type' => 'integer'",
"'segment' => ['type' => 'string'",
'wp_get_sidebars_widgets()',
'$widget->get_settings()',
] as $contract) {
if (!str_contains($rest_source, $contract)) {
$errors[] = 'REST controller missing contract: ' . $contract;
}
}
if (preg_match('/\$_(?:GET|POST|REQUEST)/', $rest_source)) {
$errors[] = 'REST controller must use WP_REST_Request only.';
}
$frontend_files = [
dirname(__DIR__) . '/assets/widgets/frontend.css',
dirname(__DIR__) . '/assets/widgets/frontend.js',
];
foreach ($frontend_files as $frontend_file) {
if (!is_file($frontend_file) || filesize($frontend_file) === 0) {
$errors[] = 'Missing frontend widget asset: ' . basename($frontend_file);
}
}
$root = dirname(__DIR__);
$legacy_paths = [
'/src/Core/WidgetAjax.php',
'/src/Core/SetupPart1.php',
'/src/Widgets',
'/inc/modules/widgets-register.php',
'/inc/modules/widget-common.php',
'/inc/modules/widget-areas.php',
'/assets/css/frontend/widgets.css',
'/assets/css/frontend/widgets',
'/assets/css/frontend/widget-batch5.css',
'/assets/js/widget-set.js',
];
foreach ($legacy_paths as $legacy_path) {
if (file_exists($root . $legacy_path)) {
$errors[] = 'Legacy widget runtime remains: ' . $legacy_path;
}
}
$runtime_source = (string) file_get_contents($root . '/src/WidgetSystem/Runtime.php');
foreach (['WidgetAreas::class', 'WidgetRegistry::all()', 'register_widget(new SchemaWidget', 'unregister_core_widgets'] as $contract) {
if (!str_contains($runtime_source, $contract)) {
$errors[] = 'Widget runtime missing contract: ' . $contract;
}
}
$asset_source = (string) file_get_contents($root . '/src/WidgetSystem/AssetManager.php');
if (str_contains($asset_source, 'cdn.jsdelivr.net')) {
$errors[] = 'Widget assets must not use CDN dependencies.';
}
$admin_import_sources = (string) file_get_contents($root . '/src/Admin/ImportExportController.php')
. (string) file_get_contents($root . '/src/Admin/ImportExportControllerPart1.php');
foreach (['/widgets/export', '/widgets/import', '/widgets/templates', 'aksrt_widget_templates'] as $legacy_contract) {
if (str_contains($admin_import_sources, $legacy_contract)) {
$errors[] = 'Legacy widget import/export contract remains: ' . $legacy_contract;
}
}
$pinned_assets = [
'DPlayer.min.js' => '361689807fc1e856f868df91036e41195d2c255619bd1c5526849c5ad7491241',
'hls.min.js' => 'e876282a26116e323c8ed3e86bed3838a80dbbe3a2faa09f834bee86963ff760',
'flv.min.js' => '0668d36757cdc17804d5c0bd6fe8956d76370e73a733bf5bbc0eddbfbca2c6cc',
'dash.all.min.js' => '3e753969c16a2a78f420a25d24869f14721eb1d284418f9f22d35a31c3693cd9',
'vanilla-tilt.min.js' => 'de6fa8d3f40dbae2726a72f7d1ae46bb0588267af05355bef1d128f737f4a282',
];
foreach ($pinned_assets as $asset => $hash) {
$path = $root . '/assets/widgets/third-party/' . $asset;
if (!is_file($path)) {
$errors[] = 'Missing pinned widget dependency: ' . $asset;
} else {
$contents = (string) file_get_contents($path);
$raw_hash = hash('sha256', $contents);
$lf_hash = hash('sha256', str_replace("\r\n", "\n", $contents));
if ($raw_hash !== $hash && $lf_hash !== $hash) {
$errors[] = 'Pinned widget dependency changed: ' . $asset;
}
}
}
if (!is_file($root . '/assets/widgets/third-party/THIRD_PARTY.md')) {
$errors[] = 'Missing widget dependency license manifest.';
}
$posts = $registry['posts'];
$normalized = $posts->normalize([
'title' => '<script>alert(1)</script> 最新课程',
'show_type' => 'invalid',
'show_id_type' => 'show',
'show_ids' => '3, 3,9 bad -4',
'count' => 999,
'style' => 'broken',
'pay_type' => 'video',
]);
if ($normalized['count'] !== 50 || $normalized['style'] !== 'card' || $normalized['show_type'] !== 'all') {
$errors[] = 'Schema clamping or enum fallback failed.';
}
if ($normalized['show_ids'] !== '3,9,4') {
$errors[] = 'ID list normalization failed: ' . $normalized['show_ids'];
}
if (str_contains($normalized['title'], '<script>')) {
$errors[] = 'Text sanitization failed.';
}
$baseline_file = dirname(__DIR__) . '/.specs/baselines/widget-reference.json';
$baseline = json_decode((string) file_get_contents($baseline_file), true);
if (!is_array($baseline) || ($baseline['source_policy']['modern_widget_count'] ?? null) !== 37) {
$errors[] = 'Reference baseline is missing or invalid.';
} elseif (is_dir((string) ($baseline['source_root'] ?? ''))) {
foreach ((array) ($baseline['files'] ?? []) as $file) {
$path = rtrim((string) $baseline['source_root'], '\\/') . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, (string) $file['path']);
if (!is_file($path)) {
$errors[] = 'Reference file missing: ' . $path;
continue;
}
if (hash_file('sha256', $path) !== ($file['sha256'] ?? '')) {
$errors[] = 'Reference file changed: ' . $file['path'];
}
}
}
if ($errors) {
fwrite(STDERR, "Widget system audit failed:\n- " . implode("\n- ", $errors) . "\n");
exit(1);
}
echo "Widget system audit passed: 37 widgets, 40 areas, 5 templates, single runtime and pinned assets.\n";
-229
View File
@@ -1,229 +0,0 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/tests/widget-system/bootstrap.php';
use AKSRT\WidgetSystem\FieldSchema;
use AKSRT\WidgetSystem\WidgetRegistry;
$root = dirname(__DIR__);
$baseline_path = $root . '/.specs/baselines/widget-reference.json';
$baseline = json_decode((string) file_get_contents($baseline_path), true, 512, JSON_THROW_ON_ERROR);
if (($baseline['schema'] ?? '') !== 'aksrt.widget-reference.v1') {
throw new RuntimeException('Frozen widget reference baseline is invalid.');
}
$reference_root = rtrim((string) ($baseline['source_root'] ?? ''), '\\/');
$reference_hashes = [];
foreach ((array) ($baseline['files'] ?? []) as $file) {
$relative = (string) ($file['path'] ?? '');
$expected = (string) ($file['sha256'] ?? '');
$path = $reference_root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relative);
if (!is_file($path) || hash_file('sha256', $path) !== $expected) {
throw new RuntimeException('Frozen reference changed or is missing: ' . $relative);
}
$reference_hashes[$relative] = $expected;
}
$group_sources = [
'general' => 'inc/widgets/widget-more.php',
'content' => 'inc/widgets/widget-posts.php',
'knowledge_product' => 'inc/widgets/widget-produck.php',
'slider' => 'inc/widgets/widget-slider.php',
'user' => 'inc/widgets/widget-user.php',
];
$viewports = [
['name' => 'desktop', 'width' => 1440, 'height' => 1100],
['name' => 'tablet', 'width' => 1024, 'height' => 1000],
['name' => 'mobile', 'width' => 390, 'height' => 844],
];
$modes = ['light', 'dark'];
$images = [
'https://fixtures.aksrt.test/media/course-cover-01.jpg',
'https://fixtures.aksrt.test/media/course-cover-02.jpg',
'https://fixtures.aksrt.test/media/course-cover-03.jpg',
];
function fixture_text(string $id, string $slug, int $index): string {
$labels = [
'question' => '购买后可以长期学习吗?',
'answer' => '可以,已购买内容会永久保存在你的学习中心。',
'name' => 'AKSRT 学员 ' . ($index + 1),
'author' => 'AKSRT 学员 ' . ($index + 1),
'role' => '知识产品体验官',
'price' => '¥99',
'value' => (string) (($index + 1) * 1280) . '+',
'label' => '累计学习人数',
'heading' => '从入门到精通的系统学习路径',
'description' => '固定内容用于组件 DOM、响应式和视觉差异验收。',
'content' => '结构清晰、案例完整,并持续更新配套资料。',
'title' => 'AKSRT 固定示例 ' . ($index + 1),
'text' => '保持好奇,持续创造。',
];
if (isset($labels[$id])) {
return $labels[$id];
}
if (str_contains($id, 'video')) {
return 'https://fixtures.aksrt.test/media/lesson.m3u8';
}
if (str_contains($id, 'url')) {
return 'https://fixtures.aksrt.test/' . $slug . '/' . ($index + 1);
}
if (str_contains($id, 'ids')) {
return str_contains($id, 'term') || str_contains($id, 'category') ? '101,102,103' : '';
}
return 'AKSRT ' . str_replace('_', ' ', $id) . ' ' . ($index + 1);
}
function fixture_value(array $field, string $slug, int $index, array $images) {
$type = (string) ($field['type'] ?? 'text');
$id = (string) ($field['id'] ?? '');
$default = $field['default'] ?? null;
if ($id === 'title') return 'AKSRT · ' . ucfirst(str_replace('_', ' ', $slug));
if ($id === 'subtitle') return '固定内容与交互状态视觉验收';
if ($id === 'title_highlight') return 'AKSRT';
if ($id === 'show_type') return 'all';
if ($id === 'show_id_type' || $id === 'show_ids') return '';
if ($id === 'layout_bg') {
return [
'light' => ['color' => '#ffffff', 'image' => '', 'position' => 'center center', 'repeat' => 'no-repeat', 'attachment' => 'scroll', 'size' => 'cover'],
'dark' => ['color' => '#1d1e24', 'image' => '', 'position' => 'center center', 'repeat' => 'no-repeat', 'attachment' => 'scroll', 'size' => 'cover'],
];
}
if ($type === 'group') {
$rows = [];
$count = min(3, max(1, (int) ($field['max_items'] ?? 3)));
for ($row = 0; $row < $count; $row++) {
$item = [];
foreach ((array) ($field['fields'] ?? []) as $child) {
$child_id = (string) ($child['id'] ?? '');
if ($child_id !== '') $item[$child_id] = fixture_value($child, $slug, $row, $images);
}
$rows[] = $item;
}
return $rows;
}
if (in_array($type, ['fieldset', 'accordion'], true)) {
$value = [];
foreach ((array) ($field['fields'] ?? []) as $child) {
$child_id = (string) ($child['id'] ?? '');
if ($child_id !== '') $value[$child_id] = fixture_value($child, $slug, $index, $images);
}
return $value;
}
if ($type === 'link') return ['url' => 'https://fixtures.aksrt.test/' . $slug, 'text' => '查看详情', 'target' => ''];
if ($type === 'upload') return $images[$index % count($images)];
if ($type === 'icon') return 'fas fa-graduation-cap';
if ($type === 'color') return '#667eea';
if ($type === 'spacing') return ['top' => 0, 'right' => 0, 'bottom' => 20, 'left' => 0];
if ($type === 'background') return ['color' => '#ffffff', 'image' => '', 'position' => 'center center', 'repeat' => 'no-repeat', 'attachment' => 'scroll', 'size' => 'cover'];
if ($type === 'switcher') return $default === null ? true : (bool) $default;
if (in_array($type, ['number', 'spinner', 'slider'], true)) return $default ?? ($field['min'] ?? 1);
if (in_array($type, ['radio', 'button_set', 'select', 'palette'], true)) return $default ?? (array_key_first((array) ($field['options'] ?? [])) ?? '');
if ($type === 'checkbox') return $default ?? [];
if ($id === 'title_link') return ['url' => 'https://fixtures.aksrt.test/' . $slug, 'text' => '查看更多', 'target' => ''];
if ($default !== null && $default !== '') return $default;
return fixture_text($id, $slug, $index);
}
$dynamic = ['posts', 'tab_posts', 'oneline_posts', 'hot_posts'];
$expandable = ['posts_toc', 'faq'];
$tabbed = ['tab_posts', 'feature_intro'];
$carousel = ['partners', 'notice', 'oneline_posts', 'reviews', 'slider', 'slider_media_combo'];
$components = [];
$matrix = [];
foreach (WidgetRegistry::all() as $slug => $definition) {
$settings = [];
foreach ($definition->fields() as $field) {
$id = (string) ($field['id'] ?? '');
$type = (string) ($field['type'] ?? 'text');
if ($id === '' || in_array($type, ['content', 'subheading', 'submessage'], true)) continue;
$settings[$id] = fixture_value($field, $slug, 0, $images);
}
$settings = $definition->normalize($settings);
$states = ['default', 'hover', 'focus'];
if (in_array($slug, $expandable, true)) $states[] = 'expanded';
if (in_array($slug, $tabbed, true)) $states[] = 'alternate-tab';
if (in_array($slug, $carousel, true)) $states[] = 'next-slide';
if (in_array($slug, $dynamic, true)) $states = array_merge($states, ['loading', 'empty', 'error']);
if ($slug === 'post_pay') $states = array_merge($states, ['guest-locked', 'member-locked', 'authorized', 'unconfigured']);
if ($slug === 'user_card') $states = array_merge($states, ['guest', 'signed-in']);
$source = $group_sources[$definition->group()] ?? 'inc/widgets/widget-index.php';
$components[$slug] = [
'id_base' => $definition->id_base(),
'name' => $definition->name(),
'group' => $definition->group(),
'size' => $definition->size(),
'reference' => ['file' => $source, 'sha256' => $reference_hashes[$source] ?? ''],
'field_ids' => array_values(array_filter(array_map(static fn(array $field): string => (string) ($field['id'] ?? ''), $definition->fields()))),
'settings' => $settings,
'root_selector' => '[data-aksrt-widget="' . $slug . '"]',
'states' => array_values(array_unique($states)),
];
foreach ($viewports as $viewport) {
foreach ($modes as $mode) {
$id = $slug . ':' . $viewport['name'] . ':' . $mode;
$matrix[$id] = [
'widget' => $slug,
'viewport' => $viewport,
'mode' => $mode,
'path' => 'tests/visual-results/' . $slug . '/' . $viewport['name'] . '-' . $mode . '.png',
];
}
}
}
$fixture = [
'schema' => 'aksrt.widget-golden.v1',
'captured_at' => (string) ($baseline['captured_at'] ?? ''),
'reference' => [
'source_root' => $reference_root,
'fingerprint' => hash('sha256', implode('', $reference_hashes)),
'files' => $reference_hashes,
'policy' => $baseline['source_policy'] ?? [],
],
'acceptance' => [
'dimension_delta_px_max' => 2,
'pixel_difference_percent_max' => 1.5,
'console_errors_max' => 0,
'unexpected_network_failures_max' => 0,
'a11y_critical_max' => 0,
'a11y_serious_max' => 0,
'reduced_motion_required' => true,
],
'dataset' => [
'site' => ['name' => 'AKSRT 知识库', 'url' => 'https://fixtures.aksrt.test'],
'terms' => [
['id' => 101, 'taxonomy' => 'category', 'name' => '产品设计', 'count' => 24],
['id' => 102, 'taxonomy' => 'category', 'name' => '前端开发', 'count' => 36],
['id' => 103, 'taxonomy' => 'post_tag', 'name' => '系统课程', 'count' => 18],
],
'users' => [
['id' => 201, 'name' => '林知夏', 'role' => '讲师', 'points' => 9680, 'followers' => 3280],
['id' => 202, 'name' => '周屿', 'role' => '作者', 'points' => 7420, 'followers' => 2160],
['id' => 203, 'name' => '陈墨', 'role' => '学员', 'points' => 5360, 'followers' => 980],
],
'posts' => [
['id' => 301, 'title' => '从零构建知识产品体系', 'pay_type' => 'reading', 'author' => 201, 'category' => 101],
['id' => 302, 'title' => '现代 WordPress 主题工程实践', 'pay_type' => 'video', 'author' => 202, 'category' => 102],
['id' => 303, 'title' => '高质量课程资料包', 'pay_type' => 'download', 'author' => 201, 'category' => 102],
['id' => 304, 'title' => '视觉案例图库', 'pay_type' => 'gallery', 'author' => 202, 'category' => 101],
['id' => 305, 'title' => '会员专属进阶课', 'pay_type' => 'vip', 'author' => 201, 'category' => 101],
['id' => 306, 'title' => '公开学习指南', 'pay_type' => 'no', 'author' => 203, 'category' => 102],
],
'comments' => [
['id' => 401, 'user' => 203, 'post' => 301, 'content' => '课程结构清晰,案例非常实用。'],
['id' => 402, 'user' => 202, 'post' => 302, 'content' => '对主题工程化讲解得很完整。'],
],
'payment_states' => ['guest-locked', 'member-locked', 'free', 'purchased', 'vip-authorized', 'unconfigured'],
'media' => ['images' => $images, 'video' => 'https://fixtures.aksrt.test/media/lesson.m3u8'],
],
'components' => $components,
'screenshot_matrix' => $matrix,
];
$output = $root . '/tests/fixtures/widget-golden-v1.json';
file_put_contents($output, json_encode($fixture, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n");
echo 'Generated widget golden fixture: ' . count($components) . ' components, ' . count($matrix) . " screenshots.\n";
-70
View File
@@ -1,70 +0,0 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/tests/widget-system/bootstrap.php';
use AKSRT\WidgetSystem\WidgetRegistry;
$root = dirname(__DIR__);
$fixture_path = $root . '/tests/fixtures/widget-golden-v1.json';
$fixture = json_decode((string) file_get_contents($fixture_path), true, 512, JSON_THROW_ON_ERROR);
$errors = [];
if (($fixture['schema'] ?? '') !== 'aksrt.widget-golden.v1') $errors[] = 'Invalid golden fixture schema.';
$components = (array) ($fixture['components'] ?? []);
$matrix = (array) ($fixture['screenshot_matrix'] ?? []);
if (array_keys($components) !== array_keys(WidgetRegistry::all()) || count($components) !== 37) $errors[] = 'Golden fixture must contain the exact 37 registry widgets.';
if (count($matrix) !== 222) $errors[] = 'Screenshot matrix must contain 37 × 3 × 2 = 222 entries.';
foreach ($components as $slug => $component) {
$definition = WidgetRegistry::get((string) $slug);
if ($definition === null) continue;
if (($component['id_base'] ?? '') !== $definition->id_base()) $errors[] = $slug . ': id_base mismatch.';
if (($component['settings'] ?? []) !== $definition->normalize((array) ($component['settings'] ?? []))) $errors[] = $slug . ': settings are not normalized.';
foreach (['default', 'hover', 'focus'] as $state) {
if (!in_array($state, (array) ($component['states'] ?? []), true)) $errors[] = $slug . ': missing state ' . $state . '.';
}
}
foreach ((array) ($fixture['reference']['files'] ?? []) as $relative => $expected) {
$path = rtrim((string) ($fixture['reference']['source_root'] ?? ''), '\\/') . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, (string) $relative);
if (!is_file($path) || hash_file('sha256', $path) !== $expected) $errors[] = 'Reference drift: ' . $relative;
}
$require_results = in_array('--require-results', $argv, true);
if ($require_results) {
$result_path = $root . '/tests/visual-results/manifest.json';
if (!is_file($result_path)) {
$errors[] = 'Visual result manifest is missing.';
} else {
$result = json_decode((string) file_get_contents($result_path), true, 512, JSON_THROW_ON_ERROR);
if (($result['schema'] ?? '') !== 'aksrt.widget-visual-results.v1') $errors[] = 'Invalid visual result schema.';
$screenshots = (array) ($result['screenshots'] ?? []);
foreach ($matrix as $id => $expected) {
$actual = (array) ($screenshots[$id] ?? []);
$path = $root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, (string) ($expected['path'] ?? ''));
if (!$actual || !is_file($path)) $errors[] = $id . ': screenshot result is missing.';
}
foreach ($components as $slug => $component) {
$states = (array) ($result['components'][$slug]['states'] ?? []);
foreach ((array) ($component['states'] ?? []) as $state) {
if (($states[$state] ?? '') !== 'pass') $errors[] = $slug . ': state not passed: ' . $state . '.';
}
}
$acceptance = (array) ($fixture['acceptance'] ?? []);
$metrics = (array) ($result['metrics'] ?? []);
if ((float) ($metrics['dimension_delta_px_max'] ?? INF) > (float) $acceptance['dimension_delta_px_max']) $errors[] = 'Dimension delta exceeds threshold.';
if ((float) ($metrics['pixel_difference_percent_max'] ?? INF) > (float) $acceptance['pixel_difference_percent_max']) $errors[] = 'Pixel difference exceeds threshold.';
if ((int) ($result['console']['errors'] ?? PHP_INT_MAX) > (int) $acceptance['console_errors_max']) $errors[] = 'Console contains errors.';
if ((int) ($result['network']['unexpected_failures'] ?? PHP_INT_MAX) > (int) $acceptance['unexpected_network_failures_max']) $errors[] = 'Network contains unexpected failures.';
if ((int) ($result['a11y']['critical'] ?? PHP_INT_MAX) > (int) $acceptance['a11y_critical_max']) $errors[] = 'Accessibility critical findings remain.';
if ((int) ($result['a11y']['serious'] ?? PHP_INT_MAX) > (int) $acceptance['a11y_serious_max']) $errors[] = 'Accessibility serious findings remain.';
if (empty($result['reduced_motion']['passed'])) $errors[] = 'Reduced-motion behavior was not verified.';
}
}
if ($errors) {
fwrite(STDERR, "Widget golden validation failed:\n- " . implode("\n- ", array_slice($errors, 0, 50)) . "\n");
exit(1);
}
echo 'Widget golden fixture passed: 37 components, 222 screenshot targets, frozen reference intact';
echo $require_results ? ", runtime results passed.\n" : ", runtime results pending.\n";