revert 8cad3c2d3d
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:
@@ -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('"controller"', $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();
|
||||
Reference in New Issue
Block a user