Files
wordpress-theme-aksrt/tests/widget-system/run.php
T

352 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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\WidgetSchemas;
use AKSRT\WidgetSystem\Renderers\ContentRenderer;
use AKSRT\WidgetSystem\Renderers\GeneralRenderer;
use AKSRT\WidgetSystem\Renderers\SliderRenderer;
final class WidgetSystemTest {
private int $assertions = 0;
private array $failures = [];
public function run(): void {
$this->schema_contract();
$this->query_contract();
$this->modern_widget_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' => 'like', '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');
$reference = PostQuery::arguments([
'count' => 6, 'cat' => '1, -2,3 bad',
'orderby' => 'views', 'order' => 'DESC', 'limit_day' => 7, 'paginate' => '',
], 3);
$this->same([1, 3], $reference['category__in'], 'reference cat includes positive IDs');
$this->same([2], $reference['category__not_in'], 'reference cat excludes negative IDs');
$this->false(isset($reference['tax_query']), 'topic filters are removed with the topics feature');
$this->same('aksrt_post_views', $reference['meta_key'], 'reference view ordering uses numeric view meta');
$this->false(isset($reference['meta_query']), 'modern reference queries do not apply commerce filters by default');
$this->same(1, $reference['paged'], 'no-pagination queries stay on first page');
$paywalled = PostQuery::arguments(['cat' => '1', 'zibpay_type' => ['1', '6'], 'paginate' => ''], 1, ['cat' => '1']);
$this->contains('reading', json_encode($paywalled['meta_query'] ?? []), 'tab commerce filters map to pay meta');
$this->contains('video', json_encode($paywalled['meta_query'] ?? []), 'tab commerce filters cover every selected type');
$invalid = PostQuery::arguments(['cat' => '', 'orderby' => 'drop table'], 1, ['cat' => '']);
$this->false(isset($invalid['meta_query']), 'modern reference fields never add commerce filters');
$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 modern_widget_contract(): void {
$tab_fields = WidgetSchemas::for('tab_posts');
$tab_defaults = FieldSchema::defaults($tab_fields);
$this->same('mini', $tab_defaults['style'], 'modern tabs default to mini');
$this->same(6, $tab_defaults['count'], 'modern tabs default to six posts');
$this->same(['热门推荐', '最近更新', '猜你喜欢'], array_column($tab_defaults['tabs'], 'title'), 'modern tab defaults match reference ordering');
$tab = FieldSchema::normalize($tab_fields, ['tabs' => [[
'title' => '<b>栏目</b><script>x</script>', 'cat' => '1,-2 3', 'zibpay_type' => ['1', '6'],
'order' => 'asc',
]]]);
$this->same('1,-2,3', $tab['tabs'][0]['cat'], 'tab cat keeps signed reference IDs');
$this->false(isset($tab['tabs'][0]['topics']), 'topic filters are gone from tab configuration');
$this->same(['1', '6'], $tab['tabs'][0]['zibpay_type'], 'tab commerce filters are preserved');
$this->contains('<b>栏目</b>', $tab['tabs'][0]['title'], 'tab title keeps controlled HTML');
$this->false(str_contains($tab['tabs'][0]['title'], '<script>'), 'tab title strips unsafe HTML');
$slider_defaults = FieldSchema::defaults(WidgetSchemas::for('slider'));
$this->true($slider_defaults['option']['button'], 'modern slider defaults to navigation buttons');
$this->true($slider_defaults['option']['pagination'], 'modern slider defaults to pagination');
$this->same(240, $slider_defaults['option']['m_height'], 'modern slider keeps reference mobile height default');
$this->same(2, count($slider_defaults['slides'][0]['image_layer']), 'modern slider ships the two reference layer assets');
$single = SliderRenderer::render('slider', ['slides' => [['background' => 'https://example.test/a.jpg', 'text' => ['title' => '<b>A</b>']]], 'option' => ['button' => true, 'pagination' => true]]);
$this->contains('new-swiper slide-widget mb20', $single, 'standalone slider emits reference wrapper');
$this->contains('swiper-container-initialized', $single, 'single slider is marked initialized');
$this->false(str_contains($single, 'swiper-button-prev'), 'single slider suppresses buttons');
$this->false(str_contains($single, 'swiper-pagination'), 'single slider suppresses pagination');
$multi = SliderRenderer::render('slider', ['slides' => [['background' => 'https://example.test/a.jpg'], ['background' => 'https://example.test/b.jpg']], 'option' => ['button' => true, 'pagination' => true]]);
$this->contains('swiper-button-prev', $multi, 'multi slider emits buttons');
$this->contains('swiper-pagination kaoyou', $multi, 'multi slider emits reference pagination');
$combo_defaults = FieldSchema::defaults(WidgetSchemas::for('slider_media_combo'));
$this->same(4, count($combo_defaults['covers']), 'combo defaults to four graphic covers');
$this->false(isset($combo_defaults['side_cards']), 'combo no longer exposes legacy side cards');
$combo = SliderRenderer::render('slider_media_combo', FieldSchema::normalize(WidgetSchemas::for('slider_media_combo'), [
'slides' => [['background' => 'https://example.test/slide.jpg', 'text' => ['title' => '幻灯片一']]],
'covers' => [
['image' => 'https://example.test/a.jpg', 'title' => '卡片一'],
['image' => 'https://example.test/b.jpg'],
],
]));
$this->contains('widget-slider-graphic m-vertical', $combo, 'combo emits reference wrapper');
$this->contains('new-swiper slide-widget scale-height', $combo, 'combo slider keeps reference classes');
$this->contains('<span><img class="radius8 lazyload swiper-lazy"', $combo, 'combo slide keeps span wrapper and lazy contract');
$this->contains('cover-col mb10', $combo, 'combo emits reference cover column');
$this->contains('widget-graphic-cover mb10', $combo, 'combo cover keeps reference wrapper');
$this->contains('col-sm-6 col-xs-6', $combo, 'combo emits reference grid columns');
$this->contains('graphic-text this-font', $combo, 'combo emits reference cover text');
$this->contains('--cover-ratio:72%', $combo, 'combo derives cover ratio from layout options');
$this->same('', SliderRenderer::render('slider_media_combo', []), 'combo renders nothing without content');
$this->same(
preg_match_all('/<div\b/', $combo),
substr_count($combo, '</div>'),
'combo markup is div-balanced (cover-col must be a sibling of the slider)'
);
$this->same(preg_match_all('/<div\b/', $single), substr_count($single, '</div>'), 'slider markup is div-balanced');
$this->same(preg_match_all('/<div\b/', $multi), substr_count($multi, '</div>'), 'multi slider markup is div-balanced');
$button_defaults = FieldSchema::defaults(WidgetSchemas::for('buttons'));
$this->same('large', $button_defaults['pc_padding'], 'button group keeps reference PC size');
$this->same('medium', $button_defaults['m_padding'], 'button group keeps reference mobile size');
$this->same(12, $button_defaults['pc_gap'], 'button group keeps reference PC spacing');
$this->same(6, $button_defaults['m_gap'], 'button group keeps reference mobile spacing');
$buttons = GeneralRenderer::render('buttons', FieldSchema::normalize(WidgetSchemas::for('buttons'), ['items' => [[
'text' => '<b>开始</b>', 'icon_left' => 'fa-play', 'icon_right' => 'fa-angle-right',
'link' => ['url' => 'https://example.test/start', 'target' => '_blank'], 'color' => 'jb-blue',
]]]));
$this->contains('widget-buttons mb20 pc-size-large m-size-medium', $buttons, 'button group emits reference wrapper');
$this->contains('widget-buttons-wrap flex hh align-center', $buttons, 'button group emits reference flex contract');
$this->contains('开始', $buttons, 'button text is rendered');
$notice_defaults = FieldSchema::defaults(WidgetSchemas::for('notice'));
$this->same(3, $notice_defaults['interval'], 'notice keeps reference interval');
$this->same('c-blue', $notice_defaults['color'], 'notice keeps reference color');
$this->same(2, count($notice_defaults['img_ids']), 'notice ships reference custom messages');
$notice = GeneralRenderer::render('notice', $notice_defaults);
$this->contains('swiper-bulletin mb20 c-blue main-radius', $notice, 'notice emits reference surface classes');
$this->contains('data-direction="vertical" data-loop="true" data-autoplay="1"', $notice, 'notice emits reference swiper options');
$posts_defaults = FieldSchema::defaults(WidgetSchemas::for('posts'));
$this->same('ajax', $posts_defaults['load_mode'], 'posts default to lazy ajax loading');
$this->same(12, $posts_defaults['count'], 'posts default count matches reference');
$this->same('', $posts_defaults['paginate'], 'posts default to no pager');
$this->same('list', $posts_defaults['style'], 'posts default to list style');
$oneline_defaults = FieldSchema::defaults(WidgetSchemas::for('oneline_posts'));
$this->same('card', $oneline_defaults['style'], 'oneline posts default to card style');
$this->false(isset($oneline_defaults['paginate']), 'oneline posts never paginate');
$hot_defaults = FieldSchema::defaults(WidgetSchemas::for('hot_posts'));
$this->same('views', $hot_defaults['orderby'], 'hot posts rank by views by default');
$this->same(6, $hot_defaults['count'], 'hot posts default count matches reference');
$term_cards_defaults = FieldSchema::defaults(WidgetSchemas::for('term_cards'));
$this->same('style-1', $term_cards_defaults['type'], 'term cards default to the simple style');
$this->same('2', $term_cards_defaults['pc_row'], 'term cards default to two per row');
$this->same(60, $term_cards_defaults['height_scale'], 'term cards keep reference cover ratio');
$term_aggregation_defaults = FieldSchema::defaults(WidgetSchemas::for('term_aggregation'));
$this->same('modified', $term_aggregation_defaults['orderby'], 'category aggregation orders by modified');
$this->same(4, $term_aggregation_defaults['count'], 'category aggregation keeps reference post count');
$media_defaults = FieldSchema::defaults(WidgetSchemas::for('media_cards'));
$this->same('4', $media_defaults['pc_row'], 'graphic covers default to four per row');
$this->same(30, $media_defaults['height_scale'], 'graphic covers keep reference ratio');
$this->same([], $media_defaults['covers'], 'graphic covers ship empty content');
$partners_defaults = FieldSchema::defaults(WidgetSchemas::for('partners'));
$this->same('3', $partners_defaults['rows'], 'partners default to three rows');
$this->same('card', $partners_defaults['style_type'], 'partners default to card style');
$this->same('link', $partners_defaults['link_type'], 'partners read from link manager by default');
$combo_defaults_info = FieldSchema::defaults(WidgetSchemas::for('combo_info'));
$this->same('立即体验', $combo_defaults_info['cta_text'], 'combo info keeps reference CTA copy');
$this->same('auto', $combo_defaults_info['cards'][0]['band_color'] ?? 'auto', 'combo info defaults to auto band color');
$comments_defaults = FieldSchema::defaults(WidgetSchemas::for('recent_comments'));
$this->same(8, $comments_defaults['limit'], 'recent comments default count matches reference');
$this->same('', $comments_defaults['style'], 'recent comments default to compact style');
$toc_defaults = FieldSchema::defaults(WidgetSchemas::for('posts_toc'));
$this->same('文章目录', $toc_defaults['title'], 'posts toc keeps reference default title');
$this->false(isset($toc_defaults['min_headings']), 'posts toc drops legacy heading options');
}
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(20, $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' => 'legacy.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(4, 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']);
$GLOBALS['aksrt_test_state']['pay_meta'] = ['pay_type' => 'no', 'price' => 0];
$this->same('', $render(), 'unconfigured article renders nothing and aborts the widget');
$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();