const test = require('node:test'); const assert = require('node:assert/strict'); const { execFileSync } = require('node:child_process'); const fs = require('node:fs'); const path = require('node:path'); const root = path.resolve(__dirname, '..', '..'); const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); function render() { const output = execFileSync('php', [path.join(__dirname, 'tab-posts-render-harness.php')], { encoding: 'utf8' }); return JSON.parse(output).cases; } test('missing and invalid tab style use the reference mini layout and ignore global post-card settings', () => { const cases = render(); for (const name of ['default', 'invalid']) { const current = cases[name]; assert.equal(current.instance_style, 'mini'); assert.match(current.tab_markup, /widget-tab-post style-mini posts-mini-lists jio-widget-box/); assert.match(current.first, /class="posts-mini ajax-item"/); assert.doesNotMatch(current.first, /class="mr10"|class="item-meta|class="badg badg-sm/); assert.doesNotMatch(current.first, /class="posts-item list|class="posts-item card|class="no_margin/); } }); test('un-normalized missing and invalid style use one shared mini resolution in both renderers', () => { const cases = render(); for (const name of ['raw_default', 'raw_invalid']) { assert.match(cases[name].tab_markup, /widget-tab-post style-mini posts-mini-lists jio-widget-box/); assert.match(cases[name].first, /class="posts-mini ajax-item"/); assert.doesNotMatch(cases[name].first, /class="posts-item list|class="posts-item card/); } }); test('mini options render thumbnail, rank, author and meta; rank is limited to the first page', () => { const current = render().mini_all_options; assert.equal((current.first.match(/class="posts-mini ajax-item"/g) || []).length, 4); assert.match(current.first, /class="mr10">
1<\/span>/); assert.match(current.first, /class="meta-author flex ac"/); assert.match(current.first, /class="item-meta muted-2-color flex jsb ac/); assert.doesNotMatch(current.second, /class="badg badg-sm/); assert.match(current.second, /真实文章 32/); assert.doesNotMatch(current.first + current.second, /class="posts-row|class="posts-item list|class="posts-item card/); }); test('mini, list and card meta use the linked avatar box with its badge image contract', () => { const cases = render(); const avatarBox = /]*>]*><\/span><\/a>/; for (const name of ['list', 'card', 'mini_all_options']) { const current = cases[name]; assert.match(current.first, avatarBox, 'the shared linked avatar wrapper and badge stay in the article meta'); assert.doesNotMatch(current.first, / 0, 'the shared avatar renderer receives each visible author'); for (const call of current.avatar_calls) { assert.deepEqual(call, { user_id: 5, class: 'avatar-mini', link: true, vip: true }); } } }); test('mini publication time follows list_orderby even when the generic list uses modified time', () => { const cases = render(); assert.match(cases.mini_time_source.html, /title="2026-01-01 00:00:00" class="icon-circle mln3">发布前/); assert.match(cases.list.first, /title="2026-02-01 00:00:00" class="icon-circle">修改前/); }); test('list tabs use the reference list item structure without inheriting archive context', () => { const current = render().list; assert.match(current.tab_markup, /widget-tab-post style-list index-tab/); assert.match(current.first, / { const current = render().card; assert.match(current.tab_markup, /widget-tab-post style-card index-tab/); assert.match(current.first, / { const markup = render().mini.tab_markup; assert.equal((markup.match(/role="tab"/g) || []).length, 2); assert.match(markup, /aria-selected="true" tabindex="0" data-segment="0"/); assert.match(markup, /aria-selected="false" tabindex="-1" data-segment="2"/); assert.doesNotMatch(markup, /data-segment="1"/); assert.match(markup, /class="tab-pane fade active in"/); assert.match(markup, /data-widget-items data-widget="tab_posts"[^>]*data-segment="2"/); }); test('pagination follows the reference boundaries, ellipsis, jump control, and final-page rules', () => { const cases = render(); const first = cases.list.pagination; const last = cases.list.last_pagination; assert.match(first, /class="page-numbers current">1<\/span>/); assert.match(first, /data-widget-page="2"/); assert.match(first, /class="page-numbers dots">…<\/span>/); assert.match(first, /data-widget-page="9"/); assert.match(first, /max="9" current="1"[^>]*data-widget-jump/); assert.doesNotMatch(first, /class="next-page page-numbers prev"/); assert.match(last, /class="next-page page-numbers prev"[^>]*data-widget-page="8"/); assert.match(last, /class="page-numbers current">9<\/span>/); assert.doesNotMatch(last, /class="next-page page-numbers next"/); assert.match(last, /max="9" current="9"[^>]*data-widget-jump/); assert.match(cases.ajax_pagination.first, /data-next-page="2"/); assert.equal(cases.ajax_pagination.last, ''); }); test('mobile pagination uses no middle neighbors and retains the jump control through the final page', () => { const current = render().mobile_pagination; const middleJump = current.middle.match(/]*data-widget-jump[^>]*>/)?.[0] || ''; assert.match(middleJump, /type="number"/); assert.match(middleJump, /inputmode="numeric"/); assert.match(current.middle, /class="page-numbers current">5<\/span>/); assert.match(current.middle, /data-widget-page="1"/); assert.match(current.middle, /data-widget-page="9"/); assert.doesNotMatch(current.middle, /class="next-page page-numbers"[^>]*data-widget-page="4"/); assert.doesNotMatch(current.middle, /class="next-page page-numbers"[^>]*data-widget-page="6"/); assert.equal((current.middle.match(/class="page-numbers dots"/g) || []).length, 2); assert.match(current.last, /class="page-numbers current">9<\/span>/); assert.match(current.last, /data-widget-page="8"/); assert.doesNotMatch(current.last, /class="next-page page-numbers next"/); const lastJump = current.last.match(/]*data-widget-jump[^>]*>/)?.[0] || ''; assert.match(lastJump, /max="9"/); assert.match(lastJump, /current="9"/); assert.match(lastJump, /type="number"/); }); test('list margin defaults inherit the site setting while an explicit widget false stays false', () => { const current = render().context_margins; assert.equal(current.implicit, true); assert.equal(current.explicit_false, false); }); test('multi-column frontend output uses the reference widget wrapper and carries layout variables', () => { const wrapper = render().frontend_wrapper; assert.match(wrapper, /class="jio-widget-box-wrap jio-tab-posts-wrap"/); assert.match(wrapper, /data-jio-widget="tab_posts" data-widget-id="jio_widget_tab_posts-4"/); assert.match(wrapper, /--jio-widget-space-top:8px/); assert.match(wrapper, /--jio-widget-space-m-bottom:10px/); assert.match(wrapper, /--jio-widget-bg-light:#ffffff/); assert.match(wrapper, /--jio-widget-bg-dark:#111111/); assert.match(wrapper, /widget-container/); assert.match(wrapper, /class="widget-content"/); assert.match(wrapper, /class="widget-tab-post style-mini/); assert.doesNotMatch(wrapper, /jio-widget__inner container|