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' => '课程', 'target' => 'popup'], 'layout' => ['enabled' => 'yes', 'gap' => -9, 'unknown' => 'drop'], 'rows' => [ ['title' => '第一项', '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' => '课程列表', '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[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();