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

218 lines
13 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\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();