chore(repo): track full tests/ and tools/ suites, drop per-file whitelist
This commit is contained in:
@@ -0,0 +1,351 @@
|
||||
<?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('"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' => '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();
|
||||
Reference in New Issue
Block a user