chore(repo): exclude local development files
This commit is contained in:
@@ -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`
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user