feat(topics): restore topic taxonomy workflow

This commit is contained in:
kate522 committed 2026-11-10 22:31:11 +08:00
1 parent f3dd1ec3b1
commit a254cbd3e9
59 files changed
+771 -192

No files matched your search

@@ -1,9 +1,4 @@
/**
* 高级筛选设置页参考主题 1:1 契约。
*
* 参考实现「高级筛选」区块与前台链路(分类/标签归档的 AJAX 选项菜单、自定义
* 筛选按钮、每页筛选条数)。
*/
/** 高级筛选设置页与归档查询的字段契约。 */
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
@@ -23,9 +18,9 @@ const tagTpl = read('tag.php');
const listJs = read('assets/js/runtime/main.js');
const globalController = read('src/Admin/GlobalController.php');
// 参考主题字段键(顺序即参考主题定义顺序)。
// 保存字段的顺序。
const REFERENCE_KEYS = ['custom_filter_show', 'custom_filter', 'cat_custom_filter'];
// Jio 私有旧字段(参考主题无此配置,须移除避免双路)。
// 已移除的旧字段。
const LEGACY_KEYS = [
'filter_enabled',
'filter_categories',
@@ -37,8 +32,6 @@ const LEGACY_KEYS = [
'filter_tag_show_cat',
'filter_tag_show_tag',
];
// 参考主题字段中本项目不做的部分(无 topics 专题分类法)。
const EXCLUDED_KEYS = ['topics_s', 'topics_lists'];
test('JS schema 字段键与顺序对齐参考主题', () => {
const found = [...js.matchAll(/key:\s*'([a-z0-9_]+)',\s*(?:label|type):/g)].map((m) => m[1]);
@@ -53,7 +46,7 @@ test('JS schema 字段键与顺序对齐参考主题', () => {
assert.ok(new RegExp(`key:\\s*'${sub}',\\s*label:`).test(js), `custom_filter 缺少子字段 ${sub}`);
}
const catSub = js.slice(js.indexOf("key: 'cat_custom_filter'"));
const catOrder = ['cats', 'cat_s', 'cat_lists', 'tag_s', 'tag_lists', 'orderby_s', 'orderby_lists', 'custom_filters'];
const catOrder = ['cats', 'cat_s', 'cat_lists', 'tag_s', 'tag_lists', 'topics_s', 'topics_lists', 'orderby_s', 'orderby_lists', 'custom_filters'];
const catIdx = catOrder.map((k) => catSub.indexOf(`key: '${k}'`));
assert.ok(catIdx.every((v) => v !== -1), 'cat_custom_filter 缺少子字段');
assert.ok(catIdx.every((v, i) => i === 0 || v > catIdx[i - 1]), 'cat_custom_filter 子字段顺序应符合参考主题');
@@ -66,7 +59,7 @@ test('PHP schema 字段键与顺序对齐参考主题', () => {
const order = REFERENCE_KEYS.map((k) => filterSection.indexOf(`'${k}'`));
assert.ok(order.every((v) => v !== -1), `PHP schema 缺少字段: ${REFERENCE_KEYS.filter((k) => order.indexOf(-1) === -1)}`);
assert.ok(order.every((v, i) => i === 0 || v > order[i - 1]), 'PHP 字段顺序应符合参考主题');
const catOrder = ['cats', 'cat_s', 'cat_lists', 'tag_s', 'tag_lists', 'orderby_s', 'orderby_lists', 'custom_filters'];
const catOrder = ['cats', 'cat_s', 'cat_lists', 'tag_s', 'tag_lists', 'topics_s', 'topics_lists', 'orderby_s', 'orderby_lists', 'custom_filters'];
const catSub = filterSection.slice(filterSection.indexOf("'cat_custom_filter'"));
const catIdx = catOrder.map((k) => catSub.indexOf(`'${k}'`));
assert.ok(catIdx.every((v) => v !== -1), 'PHP cat_custom_filter 缺少子字段');
@@ -77,36 +70,36 @@ test('JS schema 默认值、选项与依赖对齐参考主题', () => {
assert.match(js, /custom_filter_show:\s*\[\]/);
assert.match(js, /custom_filter:\s*\[\]/);
assert.match(js, /cat_custom_filter:\s*\[\]/);
for (const option of ['分类页面显示', '标签页面显示']) {
for (const option of ['分类页面显示', '标签页面显示', '专题页面显示']) {
assert.ok(js.includes(option), `custom_filter_show 缺少选项 ${option}`);
}
// 排序方式默认值与参考主题一致(orderby() default)。
// 排序方式默认值。
assert.match(js, /default:\s*\[\s*'modified',\s*'views',\s*'like',\s*'comment_count'\s*\]/);
for (const option of ['更新', '发布', '浏览', '点赞', '评论', '收藏', '随机']) {
assert.ok(js.includes(`label: '${option}'`), `排序方式缺少选项 ${option}`);
}
// 依赖链(参考主题 dependency)。
// 表单依赖链。
assert.match(js, /key: 'cat_lists',[\s\S]{0,200}?dependsOn:\s*\{\s*key:\s*'cat_s',\s*value:\s*true\s*\}/);
assert.match(js, /key: 'tag_lists',[\s\S]{0,200}?dependsOn:\s*\{\s*key:\s*'tag_s',\s*value:\s*true\s*\}/);
assert.match(js, /key: 'topics_lists',[\s\S]{0,200}?dependsOn:\s*\{\s*key:\s*'topics_s',\s*value:\s*true\s*\}/);
assert.match(js, /key: 'orderby_lists',[\s\S]{0,220}?dependsOn:\s*\{\s*key:\s*'orderby_s',\s*value:\s*true\s*\}/);
// custom_filters 为动态选项 checkbox(引用 custom_filter 维度定义)。
assert.match(js, /key: 'custom_filters',[\s\S]{0,400}?options:\s*'custom_filter'/);
// 提示行承载筛选说明,且不得再指向第三方站点。
assert.match(js, /type:\s*'content'/);
assert.match(js, /ui_only:\s*true/);
assert.doesNotMatch(js, /zibll\.com/);
assert.match(js, /避免与其他mate_key重复/);
});
test('旧字段与 topics 在 schema 中均已移除(无双路)', () => {
test('旧字段已移除,专题筛选由单一字段组驱动', () => {
for (const legacy of LEGACY_KEYS) {
assert.ok(!js.includes(legacy), `JS schema 残留旧字段: ${legacy}`);
assert.ok(!php.includes(legacy), `PHP schema 残留旧字段: ${legacy}`);
assert.ok(!postDisplay.includes(`$settings['${legacy}']`), `PostDisplay 残留旧字段读取: ${legacy}`);
}
for (const excluded of EXCLUDED_KEYS) {
assert.ok(!js.includes(excluded), `JS schema 不应包含 ${excluded}(本项目无专题分类法)`);
assert.ok(!php.includes(excluded), `PHP schema 不应包含 ${excluded}`);
for (const key of ['topics_s', 'topics_lists']) {
assert.ok(js.includes(`key: '${key}'`), `JS schema 缺少 ${key}`);
assert.ok(php.includes(`'key' => '${key}'`), `PHP schema 缺少 ${key}`);
}
// 运行时只读新键。
assert.match(postDisplay, /\$settings\['custom_filter_show'\]/);
@@ -119,7 +112,7 @@ test('JS schema 与 PHP schema 一致性', () => {
for (const key of REFERENCE_KEYS) {
assert.ok(php.includes(`'${key}'`), `PHP schema 缺少 ${key}`);
}
// addText 与参考主题 button_title 一致。
// 添加按钮文案。
assert.match(js, /addText:\s*'添加筛选类别'/);
assert.match(js, /addText:\s*'添加值'/);
assert.match(js, /addText:\s*'添加自定义分类筛选'/);
@@ -147,7 +140,7 @@ test('前台链路:覆盖与门禁', () => {
});
test('前台链路:维度筛选渲染与查询过滤', () => {
// 渲染:维度 chip 组(全部 chip 参考主题 focus 语义)。
// 渲染维度按钮组。
assert.match(filtersHelper, /function jio_archive_build_custom_filter_groups\(/);
assert.match(filtersHelper, /ajax-replace="true" class="ajax-next/);
assert.match(filtersHelper, /\$keys !== 'all' && !in_array\(\$dim_key, \(array\) \$keys, true\)/);
@@ -1,20 +1,6 @@
'use strict';
/**
* 「前台投稿」后台设置页契约(对齐参考主题 1:1,不含专题):
* 1) PHP schema(SchemaPostList.php submit section)字段键、顺序、默认值与
* JS schema(assets/admin/src/schema/sections/post-list/submit.js)一致;
* 2) 参考主题字段全集存在:post_article_s / post_article_btn_txte / post_article_cat /
* post_article_custom_filter_s / post_article_tag / post_article_user /
* post_article_title_strlen_limit / post_article_pay_free_s /
* post_article_pay_price_limit / post_article_pay_vip_price_s /
* post_article_pay_vip_1_discount / post_article_pay_vip_2_discount /
* post_t_placeholder / post_c_placeholder;
* 3) 专题(topics)相关字段不存在;
* 4) 旧 Jio 私有字段(front_submit_*、post_article_title_strlen_limit_min/max、
* post_article_pay_price_min/max)已彻底移除,无双路兼容;
* 5) 运行时(PostDisplay::get_submit_settings)只读取新键。
*/
/** 前台投稿设置的字段顺序、默认值与运行时契约。 */
const assert = require('node:assert/strict');
const fs = require('node:fs');
@@ -30,6 +16,8 @@ const REFERENCE_KEYS = [
'post_article_s',
'post_article_btn_txte',
'post_article_cat',
'post_article_topics_s',
'post_article_topics',
'post_article_custom_filter_s',
'post_article_tag',
'post_article_user',
@@ -114,9 +102,9 @@ test('PHP submit section 字段键与参考主题一致且顺序相同', () => {
assert.deepEqual(found, PHP_FIELD_ORDER);
});
test('PHP submit section 不含专题字段', () => {
assert.doesNotMatch(submitPhp, /post_article_topics/);
assert.doesNotMatch(submitPhp, /'topics'/);
test('PHP submit section 包含专题开关与可选专题', () => {
assert.match(submitPhp, /'post_article_topics_s',\s*'label'\s*=>\s*'允许选择专题',\s*'type'\s*=>\s*'switch',\s*'default'\s*=>\s*false/);
assert.match(submitPhp, /'post_article_topics',\s*'label'\s*=>\s*'投稿允许选择的专题',\s*'type'\s*=>\s*'taxonomy_select',\s*'taxonomy'\s*=>\s*'topics'/);
});
test('PHP submit section 默认值对齐参考主题', () => {
@@ -154,7 +142,7 @@ test('JS schema 与 PHP 字段键一致(ui_only content 行除外)', () => {
for (const legacy of LEGACY_KEYS) {
assert.ok(!keys.includes(legacy), `JS schema 残留旧字段: ${legacy}`);
}
// 两个 ui_only 提示行承载参考主题 desc 中的【去设置】链接。
// 两个 ui_only 提示行承载【去设置】链接。
assert.match(js, /permissions\/capabilities/);
assert.match(js, /post-list\/filter/);
assert.match(js, /ui_only:\s*true/);
+48
View File
@@ -0,0 +1,48 @@
<?php
define('ABSPATH', __DIR__);
class WP_Term {
public function __construct(
public int $term_id,
public int $parent,
public string $name
) {}
}
function absint($value): int { return abs((int) $value); }
function esc_attr($value): string { return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); }
function esc_html($value): string { return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); }
function get_terms(array $args): array {
if ($args['taxonomy'] !== 'topics') return [];
return [new WP_Term(1, 0, 'Parent'), new WP_Term(2, 1, 'Child'), new WP_Term(3, 1, 'Sibling'), new WP_Term(4, 0, 'Other')];
}
function get_term_children(int $id, string $taxonomy): array {
return $taxonomy === 'topics' && $id === 1 ? [2, 3] : [];
}
function term_exists(int $id, string $taxonomy): bool {
return $taxonomy === 'topics' && in_array($id, [1, 2, 3, 4], true);
}
require dirname(__DIR__, 2) . '/src/Core/SubmitTopics.php';
require dirname(__DIR__, 2) . '/templates/parts/submit/term-checks.php';
$root = ['allow_topics' => true, 'allowed_topic_ids' => [1]];
$child = ['allow_topics' => true, 'allowed_topic_ids' => [2]];
$disabled = ['allow_topics' => false, 'allowed_topic_ids' => []];
$tree = static fn(array $settings): array => array_map(
static fn(array $node): array => ['id' => $node['term']->term_id, 'depth' => $node['depth']],
\Jio\Core\SubmitTopics::tree($settings)
);
ob_start();
jio_submit_render_term_checks([new WP_Term(2, 1, 'Child')], [], 'topics');
$orphan_html = ob_get_clean();
echo json_encode([
'root_tree' => $tree($root),
'child_tree' => $tree($child),
'disabled_tree' => $tree($disabled),
'filtered' => \Jio\Core\SubmitTopics::filter_ids([2, 4, 999, 2], $child),
'disabled_filtered' => \Jio\Core\SubmitTopics::filter_ids([2], $disabled),
'orphan_html' => $orphan_html,
], JSON_THROW_ON_ERROR);
+22
View File
@@ -0,0 +1,22 @@
const assert = require('node:assert/strict');
const { execFileSync } = require('node:child_process');
const path = require('node:path');
const test = require('node:test');
const result = JSON.parse(execFileSync('php', [path.join(__dirname, 'topics-harness.php')], { encoding: 'utf8' }));
test('topic whitelist includes descendants and preserves a selected child without its parent', () => {
assert.deepEqual(result.root_tree, [
{ id: 1, depth: 0 },
{ id: 2, depth: 1 },
{ id: 3, depth: 1 },
]);
assert.deepEqual(result.child_tree, [{ id: 2, depth: 0 }]);
assert.match(result.orphan_html, /name="topics\[\]" value="2"/);
});
test('topic submissions accept only existing terms within the configured whitelist', () => {
assert.deepEqual(result.filtered, [2]);
assert.deepEqual(result.disabled_tree, []);
assert.deepEqual(result.disabled_filtered, []);
});
@@ -548,7 +548,8 @@ test('publish settings use a multi-select category picker and a creatable tag in
assert.match(category, /createPortal/);
assert.match(category, /type="checkbox"/);
assert.match(category, /onChange\(/);
assert.match(category, /选择分类(可多选)/);
assert.match(category, /选择%s(可多选)/);
assert.match(settings, /label=\{__\('专题', 'jio'\)\}/);
// 旧的分类平铺复选列表已移除(自定义分类法仍是复选标签,不受影响)。
assert.doesNotMatch(settings, /toggleCategory/);
assert.doesNotMatch(settings, /categories\.includes\(/);
@@ -11,7 +11,7 @@ test('modern widget schemas expose reference field names and defaults', () => {
for (const field of ['mini_opt', 'paginate', 'tabs', "'id' => 'cat'", "'id' => 'pay_type'", 'layout_ratio', 'slide_scale', 'm_layout_type', 'm_cover_ratio', 'image_layer', 'image_select']) {
assert.match(source, new RegExp(field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
}
assert.doesNotMatch(source, /'id' => 'topics'/);
assert.match(source, /'id' => 'topics'/);
assert.match(source, /'热门推荐'.*'orderby' => 'views'/s);
assert.match(source, /'最近更新'.*'orderby' => 'modified'/s);
assert.match(source, /'猜你喜欢'.*'orderby' => 'rand'/s);
@@ -62,15 +62,15 @@ function topLevelFieldIds(name) {
return [...functionBody(name).matchAll(/self::field\('([^']+)'/g)].map(match => match[1])
}
test('page display exposes exactly eight ordered routes', () => {
const expected = ['header-nav', 'header-components', 'footer', 'homepage', 'category', 'tag', 'user-home', 'other']
test('page display exposes nine ordered routes', () => {
const expected = ['header-nav', 'header-components', 'footer', 'homepage', 'category', 'tag', 'topics', 'user-home', 'other']
const schemaOrder = [...schema.matchAll(/^\t\t\t\t'([^']+)'\s+=> self::/gm)].map(match => match[1])
assert.deepEqual(schemaOrder, expected)
const routeOrder = [...routes.matchAll(/section:\s*'([^']+)'/g)].map(match => match[1])
assert.deepEqual(routeOrder, expected)
})
test('top-level field IDs match the legacy V9.0 eight-page contract', () => {
test('top-level field IDs cover all nine page settings', () => {
assert.deepEqual(topLevelFieldIds('header_nav'), [
'header_theme_custom', 'header_theme_bg_custom', 'header_theme_bg', 'header_theme_color_custom',
'header_layout', 'mobile_header_layout', 'mobile_navbar_align', 'nav_fixed', 'nav_scroll_down_hide',
@@ -96,10 +96,17 @@ test('top-level field IDs match the legacy V9.0 eight-page contract', () => {
assert.deepEqual(topLevelFieldIds('category'), [
'page_cover_cat_s', 'cat_post_count_s', 'cat_default_cover', 'cat_orderby_s', 'cat_orderby_option',
'ajax_list_cat_cat', 'ajax_list_option_cat_cat', 'ajax_list_cat_tag', 'ajax_list_option_cat_tag',
'ajax_list_cat_topics', 'ajax_list_option_cat_topics',
])
assert.deepEqual(topLevelFieldIds('tag'), [
'tag_post_count_s', 'page_cover_tag_s', 'tag_default_cover', 'tag_orderby_s', 'tag_orderby_option',
'ajax_list_tag_cat', 'ajax_list_option_tag_cat', 'ajax_list_tag_tag', 'ajax_list_option_tag_tag',
'ajax_list_tag_topics', 'ajax_list_option_tag_topics',
])
assert.deepEqual(topLevelFieldIds('topics'), [
'topics_post_count_s', 'topics_default_cover', 'topics_orderby_s', 'topics_orderby_option',
'ajax_list_topics_cat', 'ajax_list_option_topics_cat', 'ajax_list_topics_tag',
'ajax_list_option_topics_tag', 'ajax_list_topics_topics', 'ajax_list_option_topics_topics',
])
assert.deepEqual(topLevelFieldIds('user_home'), ['author_main_tab_opt', 'author_main_tab_swiper'])
assert.deepEqual(topLevelFieldIds('other'), ['page_header_style', 'page_header_cover_img'])
@@ -115,7 +122,7 @@ test('defaults, nested structures, dependencies, and excluded product families a
assert.match(schema, /'header_slider_card_s'.*?'dependsOn' => self::not_empty\('header_slider_show'\)/)
assert.match(schema, /'header_slider_filter_blur'.*?'dependsOn' => self::not_empty\('header_slider_show'\)/)
assert.match(schema, /'all' => \[self::enabled\('home_posts_list_s'\), self::enabled\('home_list1_orderby_s'\)\]/)
for (const dead of ['nav_newposts_btns', 'forum_home', 'forum_plate', 'forum_post', 'ajax_list_cat_topics', 'ajax_list_tag_topics']) {
for (const dead of ['nav_newposts_btns', 'forum_home', 'forum_plate', 'forum_post']) {
assert.equal(schema.includes(dead), false, `legacy/excluded field remains: ${dead}`)
}
assert.equal(headerMenuButtons.includes('nav_newposts_btns'), false)
@@ -209,7 +216,7 @@ test('schema REST saves are privileged, section-whitelisted, and deeply sanitize
assert.match(provider, /wp_kses_post/)
assert.match(provider, /esc_url_raw/)
assert.match(provider, /has an invalid value/)
assert.match(controller, /search\/\(\?P<source>posts\|categories\|tags\)/)
assert.match(controller, /search\/\(\?P<source>posts\|categories\|tags\|topics\|terms\)/)
assert.match(controller, /'permission_callback' => \[\$this, 'check_permission'\]/)
})
@@ -13,15 +13,11 @@ const pay = read('inc/helpers/jio-pay.php')
const metabox = read('src/Core/PostMetaBox.php')
const resourceBox = read('src/Core/ResourceMetaBox.php')
test('quick and bulk edit boxes hook the post list with no topics column', () => {
// 参考主题把配置项挂在「专题」分类法列上;本项目没有专题,统一挂在互动数据列。
test('quick and bulk edit boxes hook the topics taxonomy column', () => {
assert.match(list, /quick_edit_custom_box/)
assert.match(list, /bulk_edit_custom_box/)
assert.match(list, /COLUMN_STATS/)
// 代码里不得再出现专题分类法列(类文档块的说明除外,只看 namespace 之后的部分)。
const code = list.slice(list.indexOf('namespace Jio'))
assert.doesNotMatch(code, /taxonomy-topics/)
assert.doesNotMatch(code, /topics/)
assert.match(code, /\$column_name !== 'taxonomy-topics'/)
})
test('every quick-edit field group is rendered', () => {
@@ -87,8 +87,8 @@ test('settings use legacy list modes, grouped options, and destructive related k
for (const key of legacyKeys) {
assert.equal(source.includes(key), false, `legacy article key remains: ${key}`);
}
// bare `topics` is legal again: the related-posts query mirrors the legacy taxonomy OR.
assert.doesNotMatch(source, /list_card_topics|is_topics/);
assert.match(listSchema, /list_card_topics: false/);
assert.match(source, /list_card_topics/);
});
test('forced mode wins and thumb_if_has ignores spare and lazy placeholders', () => {
@@ -275,7 +275,7 @@ test('related posts, author page and widgets use their dedicated renderers', ()
// 翻页 / 切 tab 是整页替换语义(author.js 最终就是 panel.innerHTML = html),
// 骨架必须独占内容区。若骨架只是追加在旧卡片下方,加载期会先看到
// 「第 1 页卡片 + 脚下一摞骨架」再换页。对齐参考主题 post_ajax() 的
// 「第 1 页卡片 + 脚下一摞骨架」再换页。
// beforeSend:replace 时 $item.remove() + find(pag).remove()。
// 刻意不按 data-view 收窄:card 模式下真实卡片同样是直接子级 .posts-item,
// 也要一起收起,否则卡片面板旧内容不会让位。
@@ -572,7 +572,7 @@ test('profile routes load versioned leaf styles and settings dialogs use the sha
assert.match(userCenterCreditsCss, /#panel-credits\.dashboard-panel\s*\{[^}]*background:\s*transparent;[^}]*box-shadow:\s*none;/);
assert.match(userCenterCreditsCss, /\.uc-asset-tabs\s*\{[^}]*background:\s*var\(--surface-1,\s*#fff\);[^}]*border:[^;}]+;[^}]*box-shadow:[^;}]+;/);
assert.doesNotMatch(profileCredits, /credits-signin\.php|data-jio-signin-calendar/);
// 签到详情弹窗为参考主题 1:1 结构(src/Credits/Signin.php),日历已随
// 签到详情弹窗的日历已随
// 旧版 lite 弹窗一并移除;用户中心里的孤儿渲染器也已整包移除。
assert.equal(fs.existsSync(path.join(root, 'inc/helpers/user-center-renderers/credits-signin.php')), false);
assert.doesNotMatch(userCenterCreditsCss + read('assets/css/frontend/enhancements/components/custom-amount.css'), /uc-signin-calendar/);
+7 -2
View File
@@ -113,7 +113,10 @@ final class WidgetSystemTest {
], 3);
$this->same('1,-2,3', $reference['cat'], 'reference cat preserves cleaned signed IDs for WP_Query descendant semantics');
$this->false(isset($reference['category__in']) || isset($reference['category__not_in']), 'reference cat does not narrow matches to direct categories');
$this->false(isset($reference['tax_query']), 'topic filters are removed with the topics feature');
$this->false(isset($reference['tax_query']), 'queries without a topic filter remain unrestricted');
$topic_query = PostQuery::arguments(['count' => 6, 'topics' => '7,8'], 1);
$this->same('topics', $topic_query['tax_query'][0]['taxonomy'], 'topic query uses the topics taxonomy');
$this->same([7, 8], $topic_query['tax_query'][0]['terms'], 'topic IDs reach the taxonomy query');
$this->same('jio_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');
@@ -148,7 +151,9 @@ final class WidgetSystemTest {
'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('', $tab['tabs'][0]['topics'], 'tab exposes the topic filter');
$topic_tab = FieldSchema::normalize($tab_fields, ['tabs' => [['title' => '专题', 'topics' => '7,8']]]);
$this->same('7,8', $topic_tab['tabs'][0]['topics'], 'tab preserves selected topic IDs');
$this->same(['1', '6'], $tab['tabs'][0]['pay_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');
@@ -120,6 +120,7 @@ namespace {
function get_the_ID(): int { return (int) ($GLOBALS['tab_fixture_current_post']->ID ?? 0); }
function strip_shortcodes(string $value): string { return $value; }
function get_the_category(int $id = 0): array { unset($id); return []; }
function get_the_terms(int $id, string $taxonomy): array { unset($id, $taxonomy); return []; }
function get_the_tags(int $id = 0): array { unset($id); return []; }
function get_the_excerpt(int $id = 0): string { unset($id); return '首页通用文章列表的长摘要,这里只用于卡片和列表样式的内容渲染'; }
function get_post_field(string $field, int $id = 0, string $context = 'display'): string {