revert Merge pull request 'feat(widgets): add phase 4 validation and accessibility' (#6) from worktree/widgets-phase-4 into main

Reviewed-on: #6
This commit is contained in:
2026-08-17 11:20:54 +00:00
parent e1723a8f08
commit 463ef36ed3
23 changed files with 53 additions and 8078 deletions
@@ -1,38 +0,0 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '../..');
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
const js = read('assets/widgets/frontend.js');
const css = read('assets/widgets/frontend.css');
const content = read('src/WidgetSystem/Renderers/ContentRenderer.php');
const knowledge = read('src/WidgetSystem/Renderers/KnowledgeProductRenderer.php');
const general = read('src/WidgetSystem/Renderers/GeneralRenderer.php');
for (const key of ['ArrowRight', 'ArrowLeft', 'Home', 'End']) assert.match(js, new RegExp(key));
assert.match(js, /setAttribute\('tabindex', tab === button \? '0' : '-1'\)/);
assert.match(js, /setAttribute\('aria-labelledby', button\.id\)/);
assert.match(content, /role="tab"[^;]+aria-controls=/);
assert.match(content, /role="tabpanel"[^;]+aria-labelledby=/);
assert.match(knowledge, /role="tab"[^;]+aria-controls=/);
assert.match(knowledge, /role="tabpanel"[^;]+aria-labelledby=/);
assert.match(css, /:focus-visible/);
assert.match(css, /forced-colors: active/);
assert.match(js, /aksrt-widget-swiper__pause/);
assert.match(js, /addEventListener\('focusin', pause\)/);
assert.match(js, /addEventListener\('focusout'/);
assert.match(js, /aria-pressed/);
assert.match(content, /aria-live="polite" aria-busy="true"/);
assert.match(content, /role="status"/);
assert.match(js, /setAttribute\('aria-busy', 'true'\)/);
assert.match(js, /setAttribute\('aria-busy', 'false'\)/);
assert.match(general, /role="alert" aria-live="assertive"/);
assert.match(css, /prefers-reduced-motion: reduce/);
assert.match(js, /prefers-reduced-motion: reduce/);
console.log('Widget accessibility contract tests passed: keyboard tabs, focus, autoplay controls, live states and reduced motion.');
-20
View File
@@ -1,20 +0,0 @@
'use strict';
const assert = require('node:assert/strict');
const core = require('../../assets/widgets/admin-core.js');
assert.deepEqual(core.parseDependency('["mode","==","cards"]'), ['mode', '==', 'cards']);
assert.deepEqual(core.parseDependency('{broken'), []);
assert.equal(core.dependencyVisible(['mode', '==', 'cards'], 'cards'), true);
assert.equal(core.dependencyVisible(['mode', '==', 'cards'], 'list'), false);
assert.equal(core.dependencyVisible(['mode', '!=', 'cards'], 'list'), true);
assert.equal(core.dependencyVisible(['mode', '!=', 'cards'], 'cards'), false);
assert.equal(core.dependencyVisible([], 'anything'), true);
assert.equal(
core.replaceRepeaterIndexes('widget-aksrt_widget_slider[4][slides][__index_ab12cd34__][layers][9][title]', [2, 1]),
'widget-aksrt_widget_slider[4][slides][2][layers][1][title]'
);
assert.equal(core.replaceRepeaterIndexes('plain[name]', [1]), 'plain[name]');
console.log('Widget admin core tests passed: dependency visibility and nested repeater indexing.');
-177
View File
@@ -1,177 +0,0 @@
<?php
declare(strict_types=1);
if (!defined('ABSPATH')) {
define('ABSPATH', dirname(__DIR__, 2) . DIRECTORY_SEPARATOR);
}
if (!defined('DAY_IN_SECONDS')) {
define('DAY_IN_SECONDS', 86400);
}
if (!defined('MINUTE_IN_SECONDS')) {
define('MINUTE_IN_SECONDS', 60);
}
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
if (!(error_reporting() & $severity)) {
return false;
}
throw new ErrorException($message, 0, $severity, $file, $line);
});
$GLOBALS['aksrt_test_state'] = [];
function aksrt_test_reset_state(): void {
$GLOBALS['aksrt_test_state'] = [
'options' => [],
'sidebars' => [],
'routes' => [],
'query_args' => [],
'current_post' => null,
'current_id' => 0,
'singular' => false,
'pay_meta' => ['pay_type' => 'no', 'price' => 0],
'paid_access' => false,
'logged_in' => false,
];
$GLOBALS['wp_registered_sidebars'] = [];
}
aksrt_test_reset_state();
class WP_Error {
public function __construct(private string $code = '', private string $message = '', private array $data = []) {}
public function get_error_code(): string { return $this->code; }
public function get_error_message(): string { return $this->message; }
public function get_error_data(): array { return $this->data; }
}
class WP_REST_Controller {
public string $namespace = '';
public string $rest_base = '';
}
class WP_REST_Server {
public const READABLE = 'GET';
}
class WP_REST_Request {
public function __construct(private array $params = []) {}
public function get_params(): array { return $this->params; }
public function get_param(string $key) { return $this->params[$key] ?? null; }
}
class WP_REST_Response {
public function __construct(private $data) {}
public function get_data() { return $this->data; }
}
class WP_Post {
public int $ID;
public int $post_author;
public function __construct(int $id = 1, int $author = 1) {
$this->ID = $id;
$this->post_author = $author;
}
}
class WP_Query {
public array $posts = [];
public int $max_num_pages = 1;
public int $found_posts = 0;
public function __construct(array $args = []) {
$GLOBALS['aksrt_test_state']['query_args'][] = $args;
}
public function have_posts(): bool { return false; }
public function the_post(): void {}
}
class WP_Widget {
public string $id_base = '';
public string $id = '';
public int $number = 0;
public function __construct(string $id_base = '', string $name = '', array $widget_options = [], array $control_options = []) {
unset($name, $widget_options, $control_options);
$this->id_base = $id_base;
}
public function get_settings(): array {
$value = get_option('widget_' . $this->id_base, []);
return is_array($value) ? $value : [];
}
public function get_field_id(string $field): string { return 'widget-' . $this->id_base . '-' . $this->number . '-' . $field; }
public function get_field_name(string $field): string { return 'widget-' . $this->id_base . '[' . $this->number . '][' . $field . ']'; }
public function _set(int $number): void {
$this->number = $number;
$this->id = $this->id_base . '-' . $number;
}
}
function __($text): string { return (string) $text; }
function esc_html__($text): string { return esc_html((string) $text); }
function esc_attr__($text): string { return esc_attr((string) $text); }
function esc_html_e($text): void { echo esc_html((string) $text); }
function esc_attr_e($text): void { echo esc_attr((string) $text); }
function esc_html($value): string { return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
function esc_attr($value): string { return esc_html((string) $value); }
function esc_textarea($value): string { return esc_html((string) $value); }
function esc_url_raw($value): string {
$value = trim((string) $value);
return preg_match('#^(?:https?://|/)#', $value) ? $value : '';
}
function esc_url($value): string { return esc_attr(esc_url_raw($value)); }
function sanitize_text_field($value): string { return trim(preg_replace('/[\r\n\t]+/', ' ', strip_tags((string) $value)) ?? ''); }
function sanitize_textarea_field($value): string { return trim(strip_tags((string) $value)); }
function sanitize_key($value): string { return preg_replace('/[^a-z0-9_\-]/', '', strtolower((string) $value)) ?? ''; }
function sanitize_html_class($value): string { return preg_replace('/[^A-Za-z0-9_-]/', '', (string) $value) ?? ''; }
function sanitize_hex_color($value): string {
return preg_match('/^#(?:[0-9a-fA-F]{3}){1,2}$/', (string) $value) ? strtolower((string) $value) : '';
}
function sanitize_textarea($value): string { return sanitize_textarea_field($value); }
function wp_kses_post($value): string { return strip_tags((string) $value, '<a><b><strong><em><br><p><span><ul><ol><li>'); }
function wp_json_encode($value): string { return (string) json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); }
function absint($value): int { return abs((int) $value); }
function checked($actual, $expected = true, bool $display = true): string {
$result = $actual == $expected ? 'checked="checked"' : '';
if ($display) echo $result;
return $result;
}
function selected($actual, $expected = true, bool $display = true): string {
$result = $actual == $expected ? 'selected="selected"' : '';
if ($display) echo $result;
return $result;
}
function number_format_i18n($number): string { return number_format((float) $number); }
function wp_trim_words($text, int $count = 55): string {
$words = preg_split('/\s+/', trim((string) $text)) ?: [];
return implode(' ', array_slice($words, 0, $count));
}
function get_option(string $name, $default = false) { return $GLOBALS['aksrt_test_state']['options'][$name] ?? $default; }
function update_option(string $name, $value): bool { $GLOBALS['aksrt_test_state']['options'][$name] = $value; return true; }
function wp_get_sidebars_widgets(): array { return $GLOBALS['aksrt_test_state']['sidebars']; }
function wp_set_sidebars_widgets(array $sidebars): void { $GLOBALS['aksrt_test_state']['sidebars'] = $sidebars; }
function register_rest_route(string $namespace, string $route, array $args): bool {
$GLOBALS['aksrt_test_state']['routes'][$namespace . $route] = $args;
return true;
}
function rest_ensure_response($data): WP_REST_Response { return $data instanceof WP_REST_Response ? $data : new WP_REST_Response($data); }
function is_wp_error($value): bool { return $value instanceof WP_Error; }
function is_singular(): bool { return (bool) $GLOBALS['aksrt_test_state']['singular']; }
function get_queried_object_id(): int { return (int) $GLOBALS['aksrt_test_state']['current_id']; }
function get_post($post = null) {
if ($post instanceof WP_Post) return $post;
return $GLOBALS['aksrt_test_state']['current_post'];
}
function aksrt_get_widget_null(string $message): string { return '<div class="aksrt-widget__empty" role="status">' . esc_html($message) . '</div>'; }
function aksrt_get_pay_meta($post = null): array { unset($post); return $GLOBALS['aksrt_test_state']['pay_meta']; }
function aksrt_has_paid_access($post = null): bool { unset($post); return (bool) $GLOBALS['aksrt_test_state']['paid_access']; }
function aksrt_get_paywall_html(WP_Post $post, bool $fragment = false): string {
unset($post, $fragment);
$state = $GLOBALS['aksrt_test_state']['logged_in'] ? 'member-locked' : 'guest-locked';
return '<div data-test-paywall="' . $state . '">PAYWALL</div>';
}
function aksrt_get_pay_attributes_html($post, string $class): string { unset($post); return '<ul class="' . esc_attr($class) . '"></ul>'; }
function aksrt_get_demo_link_html($post, string $class): string { unset($post); return '<a class="' . esc_attr($class) . '">DEMO</a>'; }
function is_user_logged_in(): bool { return (bool) $GLOBALS['aksrt_test_state']['logged_in']; }
require dirname(__DIR__, 2) . '/vendor/autoload.php';
-217
View File
@@ -1,217 +0,0 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
use AKSRT\WidgetSystem\FieldSchema;
use AKSRT\WidgetSystem\FormRenderer;
use AKSRT\WidgetSystem\PostQuery;
use AKSRT\WidgetSystem\RestController;
use AKSRT\WidgetSystem\SchemaWidget;
use AKSRT\WidgetSystem\WidgetDefinition;
use AKSRT\WidgetSystem\WidgetRegistry;
use AKSRT\WidgetSystem\WidgetTransfer;
use AKSRT\WidgetSystem\WidgetVisibility;
use AKSRT\WidgetSystem\Renderers\ContentRenderer;
final class WidgetSystemTest {
private int $assertions = 0;
private array $failures = [];
public function run(): void {
$this->schema_contract();
$this->query_contract();
$this->visibility_contract();
$this->transfer_round_trip();
$this->rest_contract();
$this->payment_states();
if ($this->failures) {
fwrite(STDERR, "Widget system tests failed:\n- " . implode("\n- ", $this->failures) . "\n");
exit(1);
}
echo "Widget system tests passed: {$this->assertions} assertions across schema, query, visibility, transfer, REST and payment states.\n";
}
private function schema_contract(): void {
$fields = [
['id' => 'mode', 'type' => 'select', 'default' => 'cards', 'options' => ['cards' => 'Cards', 'list' => 'List']],
['id' => 'count', 'type' => 'slider', 'default' => 3, 'min' => 1, 'max' => 8],
['id' => 'ids', 'type' => 'text', 'sanitize' => 'ids'],
['id' => 'link', 'type' => 'link'],
['id' => 'layout', 'type' => 'fieldset', 'fields' => [
['id' => 'enabled', 'type' => 'switcher', 'default' => false],
['id' => 'gap', 'type' => 'number', 'default' => 8, 'min' => 0, 'max' => 40],
]],
['id' => 'rows', 'type' => 'group', 'max_items' => 2, 'fields' => [
['id' => 'title', 'type' => 'text'],
['id' => 'tone', 'type' => 'radio', 'default' => 'blue', 'options' => ['blue' => 'Blue', 'red' => 'Red']],
]],
];
$defaults = FieldSchema::defaults($fields);
$this->same('cards', $defaults['mode'], 'schema defaults preserve enums');
$this->same(['enabled' => false, 'gap' => 8], $defaults['layout'], 'nested defaults are recursive');
$normalized = FieldSchema::normalize($fields, [
'mode' => 'invalid', 'count' => 99, 'ids' => '2, 2,7 bad -4',
'link' => ['url' => 'javascript:alert(1)', 'text' => '<b>课程</b>', 'target' => 'popup'],
'layout' => ['enabled' => 'yes', 'gap' => -9, 'unknown' => 'drop'],
'rows' => [
['title' => '<script>x</script>第一项', 'tone' => 'red'],
['title' => '第二项', 'tone' => 'invalid'],
['title' => '第三项'],
],
]);
$this->same('cards', $normalized['mode'], 'invalid enum falls back');
$this->same(8, $normalized['count'], 'numeric value clamps to maximum');
$this->same('2,7,4', $normalized['ids'], 'ID list is unique and normalized');
$this->same(['url' => '', 'text' => '课程', 'target' => ''], $normalized['link'], 'link parts sanitize independently');
$this->same(['enabled' => true, 'gap' => 0], $normalized['layout'], 'nested values clamp and drop unknown keys');
$this->same(2, count($normalized['rows']), 'group max_items is enforced');
$this->same('blue', $normalized['rows'][1]['tone'], 'nested enum fallback is enforced');
$definition = new WidgetDefinition([
'slug' => 'dependency_test', 'name' => 'Dependency Test',
'common' => ['title' => false, 'affix' => false, 'visibility' => false, 'animation' => false, 'layout' => false],
'fields' => [
['id' => 'controller', 'type' => 'select', 'default' => 'off', 'options' => ['off' => 'Off', 'on' => 'On']],
['id' => 'dependent', 'type' => 'text', 'dependency' => ['controller', '==', 'on']],
],
]);
$widget = new SchemaWidget($definition);
ob_start();
$widget->render_full_form(['controller' => 'on', 'dependent' => 'visible']);
$html = (string) ob_get_clean();
$this->contains('data-dependency=', $html, 'form exports dependency rule');
$this->contains('&quot;controller&quot;', $html, 'dependency JSON is safely escaped');
}
private function query_contract(): void {
$args = PostQuery::arguments([
'count' => 99, 'category_ids' => '2,5', 'category_id' => 9, 'tag_ids' => '8,13',
'limit_day' => 14, 'orderby' => 'downloads', 'order' => 'asc', 'pay_type' => 'gallery',
], 0);
$this->same(50, $args['posts_per_page'], 'query count clamps');
$this->same(1, $args['paged'], 'query page clamps');
$this->same([9], $args['category__in'], 'segment category overrides category list');
$this->same([8, 13], $args['tag__in'], 'query tag filter is normalized');
$this->same('ASC', $args['order'], 'query order allowlist is applied');
$this->same('meta_value_num', $args['orderby'], 'metric ordering uses numeric meta');
$this->contains('gallery', json_encode($args['meta_query']), 'DomainModel pay type reaches meta query');
$invalid = PostQuery::arguments(['pay_type' => '6', 'orderby' => 'drop table'], 1);
$this->false(isset($invalid['meta_query']), 'legacy numeric pay types are rejected');
$this->same('date', $invalid['orderby'], 'unknown orderby falls back');
$tabs = ['tabs' => [['title' => 'A', 'category_id' => 2], ['title' => 'B', 'category_id' => 3]]];
$this->same(3, PostQuery::segment($tabs, '1')['category_id'], 'valid tab segment resolves');
$this->same(2, PostQuery::segment($tabs, '99')['category_id'], 'invalid tab segment falls back to first');
}
private function visibility_contract(): void {
$this->true(WidgetVisibility::should_render([], 0, false), 'unrestricted widget renders');
$this->true(WidgetVisibility::should_render(['show_id_type' => 'show', 'show_ids' => '3,8'], 8, true), 'show list matches singular ID');
$this->false(WidgetVisibility::should_render(['show_id_type' => 'show', 'show_ids' => '3,8'], 4, true), 'show list rejects other IDs');
$this->false(WidgetVisibility::should_render(['show_id_type' => 'show', 'show_ids' => '3,8'], 0, false), 'show list rejects archives');
$this->false(WidgetVisibility::should_render(['show_id_type' => 'hide', 'show_ids' => '3,8'], 3, true), 'hide list suppresses matching ID');
$this->true(WidgetVisibility::should_render(['show_id_type' => 'hide', 'show_ids' => '3,8'], 0, false), 'hide list keeps archives');
$this->same('aksrt-widget--only-pc', WidgetVisibility::device_class(['show_type' => 'only_pc']), 'PC device class is stable');
$this->same('aksrt-widget--only-mobile', WidgetVisibility::device_class(['show_type' => 'only_sm']), 'mobile device class is stable');
$this->same('', WidgetVisibility::device_class(['show_type' => 'invalid']), 'invalid device rule adds no class');
}
private function transfer_round_trip(): void {
aksrt_test_reset_state();
$GLOBALS['wp_registered_sidebars'] = ['home_sidebar' => [], 'single_sidebar' => []];
update_option('widget_aksrt_widget_posts', [2 => ['title' => '<b>课程列表</b>', 'count' => 999, 'style' => 'mini'], '_multiwidget' => 1]);
$GLOBALS['aksrt_test_state']['sidebars'] = ['home_sidebar' => ['text-1', 'aksrt_widget_posts-2']];
$payload = WidgetTransfer::export('home_sidebar');
$this->same(WidgetTransfer::FORMAT, $payload['format'], 'export format is versioned');
$this->same(1, count($payload['sidebars']['home_sidebar']), 'export ignores non-schema widgets');
$this->same(50, $payload['sidebars']['home_sidebar'][0]['settings']['count'], 'export normalizes saved settings');
update_option('widget_aksrt_widget_posts', []);
$GLOBALS['aksrt_test_state']['sidebars']['single_sidebar'] = ['text-9'];
$result = WidgetTransfer::import($payload, 'append', 'single_sidebar');
$this->same(['sidebars' => 1, 'widgets' => 1], $result, 'append import reports changes');
$this->same(['text-9', 'aksrt_widget_posts-1'], $GLOBALS['aksrt_test_state']['sidebars']['single_sidebar'], 'append import preserves existing widgets');
$round_trip = WidgetTransfer::export('single_sidebar');
$this->same($payload['sidebars']['home_sidebar'][0], $round_trip['sidebars']['single_sidebar'][0], 'export/import round trip preserves normalized module');
$replacement = WidgetTransfer::template_payload('links_navigation');
$result = WidgetTransfer::import($replacement, 'replace', 'single_sidebar');
$this->same(3, $result['widgets'], 'targeted template import combines template areas');
$this->false(in_array('text-9', $GLOBALS['aksrt_test_state']['sidebars']['single_sidebar'], true), 'replace import removes previous assignment');
try {
WidgetTransfer::import(['format' => 'zibll.widgets', 'sidebars' => []]);
$this->failures[] = 'transfer rejects legacy formats: no exception thrown';
} catch (InvalidArgumentException $error) {
$this->contains(WidgetTransfer::FORMAT, $error->getMessage(), 'legacy import rejection names accepted format');
}
}
private function rest_contract(): void {
aksrt_test_reset_state();
$controller = new RestController();
$controller->register_routes();
$this->same(2, count($GLOBALS['aksrt_test_state']['routes']), 'REST registers items and quote routes');
$route = $GLOBALS['aksrt_test_state']['routes']['aksrt/v1/widgets/(?P<widget>[a-z][a-z0-9_]*)/items'];
$this->same('__return_true', $route['permission_callback'], 'public items route is read-only accessible');
$this->true($controller->validate_widget('posts'), 'known widget validates');
$this->false($controller->validate_widget('shop_products'), 'unknown/shop widget is rejected');
$error = $controller->get_items(new WP_REST_Request(['widget' => 'posts', 'instance' => 'aksrt_widget_posts-1', 'page' => 1, 'query' => 'arbitrary']));
$this->error('aksrt_widget_unknown_parameter', $error, 'REST rejects arbitrary query parameters');
$error = $controller->get_items(new WP_REST_Request(['widget' => 'search', 'instance' => 'aksrt_widget_search-1', 'page' => 1]));
$this->error('aksrt_widget_items_unsupported', $error, 'REST rejects non-dynamic widgets');
$error = $controller->get_items(new WP_REST_Request(['widget' => 'posts', 'instance' => 'aksrt_widget_posts-1', 'page' => 1]));
$this->error('aksrt_widget_instance_not_registered', $error, 'REST requires an assigned instance');
$GLOBALS['aksrt_test_state']['sidebars'] = ['home_top_content' => ['aksrt_widget_posts-1']];
update_option('widget_aksrt_widget_posts', [1 => ['title' => 'Saved only', 'count' => 2, 'style' => 'list'], '_multiwidget' => 1]);
$response = $controller->get_items(new WP_REST_Request(['widget' => 'posts', 'instance' => 'aksrt_widget_posts-1', 'page' => 2, 'segment' => '']));
$this->true($response instanceof WP_REST_Response, 'registered instance returns REST response');
$data = $response->get_data();
$this->true($data['success'], 'REST success envelope is stable');
$this->same(2, $data['page'], 'REST page reaches saved-query pipeline');
$this->contains('暂无文章', $data['html'], 'REST returns deterministic empty state');
$this->same(2, end($GLOBALS['aksrt_test_state']['query_args'])['posts_per_page'], 'REST reloads saved normalized configuration');
$error = $controller->get_items(new WP_REST_Request(['widget' => 'posts', 'instance' => 'aksrt_widget_hot_posts-1', 'page' => 1]));
$this->error('aksrt_widget_invalid_instance', $error, 'REST binds instance ID to requested widget');
}
private function payment_states(): void {
aksrt_test_reset_state();
$GLOBALS['aksrt_test_state']['current_post'] = new WP_Post(77, 5);
$method = new ReflectionMethod(ContentRenderer::class, 'post_pay');
$render = static fn(): string => (string) $method->invoke(null, ['theme' => 'jb-red']);
$this->contains('未配置付费内容', $render(), 'unconfigured article renders explicit empty state');
$authorized = [
'free' => ['pay_type' => 'reading', 'price' => 0],
'purchased' => ['pay_type' => 'reading', 'price' => 29],
'vip' => ['pay_type' => 'vip', 'price' => 0],
];
foreach ($authorized as $label => $meta) {
$GLOBALS['aksrt_test_state']['pay_meta'] = $meta;
$GLOBALS['aksrt_test_state']['paid_access'] = true;
$this->contains('内容已解锁', $render(), $label . ' authorization delegates to existing access service');
}
$GLOBALS['aksrt_test_state']['pay_meta'] = ['pay_type' => 'video', 'price' => 49];
$GLOBALS['aksrt_test_state']['paid_access'] = false;
$GLOBALS['aksrt_test_state']['logged_in'] = false;
$this->contains('guest-locked', $render(), 'guest unauthorized state delegates to paywall');
$GLOBALS['aksrt_test_state']['logged_in'] = true;
$this->contains('member-locked', $render(), 'logged-in unauthorized state delegates to paywall');
}
private function same($expected, $actual, string $message): void {
$this->assertions++;
if ($expected !== $actual) $this->failures[] = $message . ' (expected ' . var_export($expected, true) . ', got ' . var_export($actual, true) . ')';
}
private function true($actual, string $message): void { $this->same(true, (bool) $actual, $message); }
private function false($actual, string $message): void { $this->same(false, (bool) $actual, $message); }
private function contains(string $needle, ?string $haystack, string $message): void {
$this->assertions++;
if ($haystack === null || !str_contains($haystack, $needle)) $this->failures[] = $message . ' (missing ' . $needle . ')';
}
private function error(string $code, $value, string $message): void {
$this->assertions++;
if (!$value instanceof WP_Error || $value->get_error_code() !== $code) $this->failures[] = $message . ' (expected error ' . $code . ')';
}
}
(new WidgetSystemTest())->run();