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' => '课程', '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' => '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' => '栏目', '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('栏目', $tab['tabs'][0]['title'], 'tab title keeps controlled HTML'); $this->false(str_contains($tab['tabs'][0]['title'], '