Undertale社区维基

模块:赋值/testcases

欢迎来到Undertale社区维基(*`∀´*)ノ亻,如果想要参与条目创建或编辑,请先登录哦

此模块的文档可以在模块:赋值/testcases/doc创建

-- 模块:赋值/testcases
-- 测试覆盖:14个导出函数 × 正常路径 + 边界 + 已知bug
-- 运行:={{#invoke:赋值/testcases|run}}=
-- 调试:=require('模块:赋值/testcases').run()=

local m = require('Module:赋值')
local ScribuntoUnit = require('Module:ScribuntoUnit')
local suite = ScribuntoUnit:new()

-- Mock frame — 模拟 {{#invoke}} 传入,缺失参数返回空串
local function makeFrame(args)
    local frame = {}
    frame.args = setmetatable({}, {__index = function() return '' end})
    if args then for k, v in pairs(args) do frame.args[k] = v end end
    function frame:preprocess(text) return text end
    return frame
end

-- ============================================================
-- 测试说明映射表(每个 test_xxx → 中文说明)
-- ============================================================
local DESC = {
    test_https_re                       = 'URL协议补全:无协议→https,已有→原样',
    test_https_re_edge                  = 'URL协议边界:大写HTTP、空串、含空格',
    test_maxnum                         = '取数组最大值:正常/单元素/全零',
    test_maxnum_edge                    = '空表边界:返回nil不崩溃',
    test_maxnum_empty_returns_nil       = '空表确认:返回nil',
    test_parsedatetime                  = '日期格式化:7种精度补零',
    test_parsedatetime_edge             = '日期边界:越界月/日/时应返回nil',
    test_ref_source                     = 'ref包裹(source):基本/拆分/已有跳过/空',
    test_ref_note                       = 'ref包裹(note):基本/跳过/空',
    test_ref_source_edge                = 'ref边界:连续分号空段跳过',
    test_ref_unknown_mode               = 'ref边界:未知mode→空串',
    test_ref_mixed_tags                 = 'ref边界:混合ref分片各自独立判断',
    test_id2link_person                 = '平台字典(person):用户名→URL',
    test_id2link_homepage               = '平台字典(homepage):域名模式',
    test_person_icon                    = '人物:基本图标显示',
    test_person_role_founder            = '人物:创始者角色',
    test_person_current_holder          = '人物:现持有者角色',
    test_person_unknown_url             = '人物:未知平台URL(SMW仅存数据)',
    test_person_with_source_ref         = '人物:来源自动包裹ref',
    test_person_with_note_ref           = '人物:注释自动包裹group=注',
    test_person_empty_name              = '人物边界:空人名',
    test_person_no_url_only_role        = '人物边界:有角色无URL',
    test_person_unrecognized_role       = '人物边界:未识别角色',
    test_person_platform_case           = '人物边界:平台参数大小写',
    test_person_multiple_same_platform  = '人物边界:同平台多次出现',
    test_person_unrecognized_role_passthrough = '人物边界:未识别角色原文保留',
    test_person_is_can_break_loop       = '人物边界:IS_CAN_BREAK_LOOP正确跳出',
    test_person_source_offset           = '人物:链接来源索引正确',
    test_name_basic                     = '名称:基本/类型显示',
    test_name_multiple                  = '名称:多名换行',
    test_name_current                   = '名称:"现"前缀',
    test_name_generation                = '名称:代数显示',
    test_name_empty                     = '名称边界:空名称',
    test_name_unrecognized_set          = '名称边界:未识别类型',
    test_name_generation_zero           = '名称边界:第0代',
    test_name_double_algebra            = '名称边界:双代数(前+后)',
    test_name_partial_algebra           = '名称边界:单侧前代数正常',
    test_name_partial_algebra_reverse   = '名称边界:单侧后代数正常',
    test_type_a_class                   = '类型:A类(平行/准则/正典/续写/扩展)',
    test_type_b_class                   = '类型:B类(世界观/迁移/错位)',
    test_type_c_class                   = '类型:C类(交叉同人-xxx)',
    test_type_c_first                   = '类型:C类首项不重复前缀',
    test_type_with_time                 = '类型:带时间标签',
    test_type_empty                     = '类型边界:空类型',
    test_type_unknown                   = '类型边界:未知类型原文保留',
    test_type_mixed_valid_invalid       = '类型边界:混合有效无效均保留',
    test_status_basic                   = '状态:基本',
    test_status_with_time               = '状态:带时间',
    test_status_multiple                = '状态:多状态换行',
    test_status_empty                   = '状态边界:空状态',
    test_status_crash_with_time_only    = '状态边界:纯time无[1]不崩溃',
    test_style                          = '风格:基本/时间',
    test_style_no_value_with_time       = '风格边界:纯time→不崩(有or保护)',
    test_backdrop                       = '背景:基本',
    test_backdrop_no_value_with_time    = '背景边界:纯time→不崩(有or保护)',
    test_homepage_platform_mode         = '主页:指定平台名+URL',
    test_homepage_auto_detect           = '主页:自动识别平台',
    test_homepage_empty_url             = '主页边界:空URL',
    test_homepage_fake_platform         = '主页边界:未知平台用args[1]做图标',
    test_homepage_no_protocol           = '主页边界:无协议→补https',
    test_licence_cc_by_sa               = '许可:CC BY-SA 4.0图标+版本',
    test_licence_cc0                    = '许可:CC0',
    test_licence_cc_invalid             = '许可:非法CC组合报错',
    test_licence_copyright              = '许可:Copyright模板',
    test_licence_unknown                = '许可:未知许可原文',
    test_licence_lowercase              = '许可边界:小写cc',
    test_licence_no_version             = '许可边界:无版本号',
    test_licence_too_many_modifiers     = '许可边界:四修饰符',
    test_licence_cczero_variants        = '许可边界:CC ZERO变体',
    test_licence_custom_text            = '许可边界:Copyright@UTCWIKi',
    test_date_basic                     = '日期:发布日期→去后缀',
    test_date_reset                     = '日期:重置日期',
    test_date_remake                    = '日期:重制日期',
    test_date_custom_suffix             = '日期:自定义xx日期→去后缀',
    test_date_malformed                 = '日期边界:非法格式',
    test_date_crash_with_no_set         = '日期边界:有date无set不崩溃',
}

-- ============================================================
-- 1️⃣  p.https_re — URL 协议自动补全
-- ============================================================
function suite:test_https_re()
    self:assertEquals('https://example.com',        m.https_re('example.com'),         '无协议补https')
    self:assertEquals('http://example.com',          m.https_re('http://example.com'),  'http原样')
    self:assertEquals('https://example.com',         m.https_re('https://example.com'), 'https原样')
end

function suite:test_https_re_edge()
    self:assertEquals('HTTP://E.X', m.https_re('HTTP://E.X'), '大写HTTP→应识别已有协议')
    self:assertEquals('https://',      m.https_re(''),              '空串补https')
    self:assertEquals('https://a b',   m.https_re('a b'),           '含空格URL')
end

-- ============================================================
-- 2️⃣  p.maxnum — 取数组最大值
-- ============================================================
function suite:test_maxnum()
    self:assertEquals(5, m.maxnum({3, 1, 5, 2}), '正常')
    self:assertEquals(7, m.maxnum({7}),           '单元素')
    self:assertEquals(0, m.maxnum({0, 0, 0}),     '全零')
end

function suite:test_maxnum_edge()
    self:assertEquals(nil, m.maxnum({}), '空表→nil')
end

function suite:test_maxnum_empty_returns_nil()
    local result = m.maxnum({})
    self:assertEquals(nil, result, '空表→nil(非报错)')
end

-- ============================================================
-- 3️⃣  p.parsedatetime — 日期格式化
-- ============================================================
function suite:test_parsedatetime()
    self:assertEquals('2024-01-05',            m.parsedatetime('2024-01-05'),           'YYYY-MM-DD')
    self:assertEquals('2024-01-05',            m.parsedatetime('2024-1-5'),             '补零 Y-M-D')
    self:assertEquals('2024-01-05 14:30:00',   m.parsedatetime('2024-01-05 14:30:00'), '完整datetime')
    self:assertEquals('2024-01-05 14:30',      m.parsedatetime('2024-01-05 14:30'),     'Y-M-D HH:MM')
    self:assertEquals('2024-01-05 14',         m.parsedatetime('2024-01-05 14'),        'Y-M-D HH')
    self:assertEquals('2024-01',               m.parsedatetime('2024-01'),              'YYYY-MM')
    self:assertEquals('2024',                  m.parsedatetime('2024'),                 'YYYY')
    self:assertEquals(nil,                     m.parsedatetime('abc-def'),              '非法输入→nil')
end

function suite:test_parsedatetime_edge()
    self:assertEquals(nil, m.parsedatetime('2024-13-01'),       '非法月份→应返回nil')
    self:assertEquals(nil, m.parsedatetime('2024-01-32'),       '非法日期→应返回nil')
    self:assertEquals(nil, m.parsedatetime('2024-01-01 25:00'), '非法小时→应返回nil')
    self:assertEquals(nil, m.parsedatetime(''),                  '空串→nil')
end

-- ============================================================
-- 4️⃣  p.ref — 引用自动包裹
-- ============================================================
function suite:test_ref_source()
    self:assertEquals('{{#tag:ref|一段引用}}',           m.ref('一段引用', 'source'),            '基本包裹')
    self:assertEquals('{{#tag:ref|A}}{{#tag:ref|B}}',    m.ref('A;B', 'source'),                '分号拆分')
    self:assertEquals('<ref>已有</ref>',                  m.ref('<ref>已有</ref>', 'source'),     '已有ref跳过')
    self:assertEquals('{{#tag:ref|已有内容}}',            m.ref('{{#tag:ref|已有内容}}', 'source'), '已有tag:ref跳过')
    self:assertEquals('',                                m.ref('', 'source'),                    '空输入')
end

function suite:test_ref_note()
    self:assertEquals('{{#tag:ref|一条注释|group=注}}',    m.ref('一条注释', 'note'),              'note基本')
    self:assertEquals("<ref group='注'>已有</ref>",       m.ref("<ref group='注'>已有</ref>", 'note'), 'note已有跳过')
    self:assertEquals('',                                m.ref('', 'note'),                      'note空输入')
end

function suite:test_ref_source_edge()
    self:assertEquals('{{#tag:ref|A}}{{#tag:ref|C}}', m.ref('A;;C', 'source'), '连续分号-空段跳过')
end

function suite:test_ref_unknown_mode()
    self:assertEquals('', m.ref('text', 'other'), '未知mode→空串')
end

function suite:test_ref_mixed_tags()
    local html = m.ref('A;<ref>B</ref>', 'source')
    self:assertStringContains('{{#tag:ref|A}}', html, true, 'A无ref→应包裹')
    self:assertStringContains('<ref>B</ref>',   html, true, 'B有ref→应跳过')
    self:assertFalse(mw.ustring.find(html, '{{#tag:ref|B}}') ~= nil, 'B无双重包裹')
end

-- ============================================================
-- 5️⃣  p.id2link — 平台字典
-- ============================================================
function suite:test_id2link_person()
    local d = m.id2link_person({bilibili = '123', twitter = 'abc'})
    self:assertEquals('bilibili', d.bilibili[1],  'bilibili图标名')
    self:assertEquals('twitter',  d.twitter[1],   'twitter图标名')
    self:assertTrue(mw.ustring.find(d.bilibili[2][1], '123') ~= nil, 'bilibili URL含123')
    self:assertTrue(mw.ustring.find(d.twitter[2][1], 'abc') ~= nil,  'twitter URL含abc')
end

function suite:test_id2link_homepage()
    local d = m.id2link_homepage()
    self:assertEquals('mspfa',    d.mspfa[1],    'mspfa存在')
    self:assertEquals('bilibili', d.bilibili[1], 'bilibili存在')
    self:assertEquals(2,          #d.tumblr[2],  'tumblr有2个URL模式')
end

-- ============================================================
-- 6️⃣  p.person — 人物信息模板
-- ============================================================
function suite:test_person_icon()
    local html = m.person(makeFrame{[1] = '测试AU', bilibili = '12345'})
    self:assertTrue(mw.ustring.find(html, '测试AU') ~= nil, '人名出现')
    self:assertTrue(mw.ustring.find(html, 'bilibili') ~= nil, 'bilibili图标出现')
end

function suite:test_person_role_founder()
    local html = m.person(makeFrame{[1] = '测试AU', url = 'https://space.bilibili.com/67890', set = '创始者'})
    self:assertStringContains('创始者', html, true, '创始者文案出现')
end

function suite:test_person_current_holder()
    local html = m.person(makeFrame{[1] = '测试AU', url = 'https://space.bilibili.com/67890', set = '现持有者'})
    self:assertStringContains('现持有者', html, true, '现持有者文案出现')
end

function suite:test_person_unknown_url()
    local html = m.person(makeFrame{[1] = '测试AU', url = 'https://example.com/wiki'})
    self:assertStringContains('测试AU', html, true, '人名出现(未识别URL→无图标)')
end

function suite:test_person_with_source_ref()
    local html = m.person(makeFrame{[1] = '测试AU', url = 'https://example.com/wiki', ['链接来源'] = '来源文字'})
    self:assertStringContains('{{#tag:ref|来源文字}}', html, true, '来源自动包裹ref')
end

function suite:test_person_with_note_ref()
    local html = m.person(makeFrame{[1] = '测试AU', url = 'https://example.com/wiki', ['链接注释'] = '注释文字'})
    self:assertStringContains('group=注', html, true, '注释自动包裹note ref')
end

function suite:test_person_empty_name()
    local html = m.person(makeFrame{[1] = '', bilibili = '123'})
    self:assertStringContains('<div', html, true, '空人名不崩溃')
end

function suite:test_person_no_url_only_role()
    local html = m.person(makeFrame{[1] = 'A', set = '参与制作者'})
    self:assertStringContains('参与制作者', html, true, '无URL纯角色→角色显示')
end

function suite:test_person_unrecognized_role()
    local html = m.person(makeFrame{[1] = 'A', set = '摸鱼者'})
    self:assertStringContains('A', html, true, '未识别角色→不崩溃')
end

function suite:test_person_platform_case()
    local html = m.person(makeFrame{[1] = 'A', Bilibili = '123'})
    self:assertStringContains('A', html, true, '大写平台名→不崩溃')
end

function suite:test_person_multiple_same_platform()
    local html = m.person(makeFrame{[1] = 'A', bilibili = '123', url = 'https://space.bilibili.com/456、https://space.bilibili.com/789'})
    self:assertStringContains('A', html, true, '同平台多次→不崩溃')
end

function suite:test_person_unrecognized_role_passthrough()
    local html = m.person(makeFrame{[1] = '测试AU', set = '翻译者'})
    self:assertStringContains('翻译者', html, true, '未识别角色→原文保留')
end

function suite:test_person_is_can_break_loop()
    local html = m.person(makeFrame{
        [1] = '测试AU', bilibili = '123', twitter = '456', tumblr = '789',
        url = 'https://www.youtube.com/c/test、https://www.pixiv.net/users/111、https://www.reddit.com/user/test',
        set = '持有者、参与制作者、创始者',
    })
    self:assertStringContains('测试AU', html, true, '多平台混合→不崩溃不卡死')
end

function suite:test_person_source_offset()
    local html = m.person(makeFrame{
        [1] = '测试AU', url = 'https://space.bilibili.com/1、https://twitter.com/user2',
        ['链接来源'] = '来源一、来源二',
    })
    local _, count = mw.ustring.gsub(html, '{#tag:ref|来源', '')
    self:assertEquals(2, count, '2个来源→应有2个ref包裹')
end

-- ============================================================
-- 7️⃣  p.name — 名称模板
-- ============================================================
function suite:test_name_basic()
    local html = m.name(makeFrame{[1] = 'MyName', set = '官方译名'})
    self:assertStringContains('MyName', html, true, '名称出现')
    self:assertStringContains('官方译名', html, true, '类型出现')
end

function suite:test_name_multiple()
    local html = m.name(makeFrame{[1] = 'Name1、Name2', set = '官方译名、民间译名'})
    self:assertStringContains('Name1', html, true, '第一个名称出现')
    self:assertStringContains('Name2', html, true, '第二个名称出现')
    self:assertStringContains('<br>', html, true, '多名称换行分隔')
end

function suite:test_name_current()
    local html = m.name(makeFrame{[1] = 'CurrentName', set = '现官方译名'})
    self:assertStringContains('CurrentName', html, true, '当前名称出现')
    self:assertStringContains('现官方译名', html, true, '"现"前缀保留')
end

function suite:test_name_generation()
    local html = m.name(makeFrame{[1] = 'TranslatedName', set = '官方译名', ['代数'] = '3'})
    self:assertStringContains('第3代', html, true, '代数显示')
end

function suite:test_name_empty()
    local html = m.name(makeFrame{[1] = '', set = '官方译名'})
    self:assertStringContains('<div', html, true, '空名称→不崩溃')
end

function suite:test_name_unrecognized_set()
    local html = m.name(makeFrame{[1] = 'X', set = '火星译名'})
    self:assertStringContains('X', html, true, '未识别名称类型→不崩溃')
end

function suite:test_name_generation_zero()
    local html = m.name(makeFrame{[1] = 'X', set = '官方译名', ['代数'] = '0'})
    self:assertStringContains('X', html, true, '第0代→不崩溃')
end

function suite:test_name_double_algebra()
    local html = m.name(makeFrame{[1] = 'X', set = '民间译名', ['前者代数'] = '1', ['后者代数'] = '2'})
    self:assertStringContains('X', html, true, '双代数→不崩溃')
end

function suite:test_name_partial_algebra()
    local html = m.name(makeFrame{[1] = 'TestName', set = '民间译名', ['前者代数'] = '2'})
    self:assertStringContains('TestName', html, true, '单侧代数→不崩溃')
end

function suite:test_name_partial_algebra_reverse()
    local html = m.name(makeFrame{[1] = 'TestName', set = '民间译名', ['后者代数'] = '3'})
    self:assertStringContains('TestName', html, true, '单侧后代数→不崩溃')
end

-- ============================================================
-- 8️⃣  p.type — AU 类型分类
-- ============================================================
function suite:test_type_a_class()
    self:assertStringContains('平行时间轴', m.type(makeFrame{[1] = '平行时间轴'}), true, 'A类:平行时间轴')
    self:assertStringContains('扩展宇宙',   m.type(makeFrame{[1] = '扩展宇宙'}),   true, 'A类:扩展宇宙')
end

function suite:test_type_b_class()
    self:assertStringContains('世界观变动', m.type(makeFrame{[1] = '世界观变动'}), true, 'B类:世界观变动')
    self:assertStringContains('角色错位',   m.type(makeFrame{[1] = '角色错位'}),   true, 'B类:角色错位')
end

function suite:test_type_c_class()
    local html = m.type(makeFrame{[1] = '元素代入'})
    self:assertStringContains('交叉同人', html, true, 'C类前缀出现')
    self:assertStringContains('元素代入', html, true, 'C类具体类型出现')
end

function suite:test_type_c_first()
    local html = m.type(makeFrame{[1] = '交叉同人'})
    self:assertStringContains('交叉同人', html, true, 'C类首项显示')
    self:assertFalse(mw.ustring.find(html, '交叉同人 %-'), 'C类首项不重复前缀')
end

function suite:test_type_with_time()
    local html = m.type(makeFrame{[1] = '世界观变动', time = '2024'})
    self:assertStringContains('世界观变动', html, true, '类型文本出现')
    self:assertStringContains('2024',       html, true, '时间标签出现')
end

function suite:test_type_empty()
    local html = m.type(makeFrame{[1] = ''})
    self:assertStringContains('<div', html, true, '空类型→不崩溃')
end

function suite:test_type_unknown()
    local html = m.type(makeFrame{[1] = '精神污染'})
    self:assertStringContains('精神污染', html, true, '未知类型→应原文保留')
end

function suite:test_type_mixed_valid_invalid()
    local html = m.type(makeFrame{[1] = '平行时间轴、精神污染'})
    self:assertStringContains('平行时间轴', html, true, '有效类型出现')
    self:assertStringContains('精神污染',   html, true, '无效类型→应保留')
end

-- ============================================================
-- 9️⃣  p.status — 状态模板
-- ============================================================
function suite:test_status_basic()
    local html = m.status(makeFrame{[1] = '连载中'})
    self:assertStringContains('连载中', html, true, '状态文本出现')
end

function suite:test_status_with_time()
    local html = m.status(makeFrame{[1] = '连载中', time = '2024-01-01'})
    self:assertStringContains('连载中', html, true, '状态文本')
    self:assertStringContains('2024-01-01', html, true, '时间文本')
end

function suite:test_status_multiple()
    local html = m.status(makeFrame{[1] = '连载中、已完结', time = '2020、2024'})
    self:assertStringContains('连载中', html, true, '第一状态')
    self:assertStringContains('已完结', html, true, '第二状态')
    self:assertStringContains('<br>', html, true, '多状态换行')
end

function suite:test_status_empty()
    local html = m.status(makeFrame{[1] = ''})
    self:assertStringContains('<div', html, true, '空状态→不崩溃')
end

function suite:test_status_crash_with_time_only()
    local ok = pcall(function() m.status(makeFrame{time = '2024'}) end)
    self:assertTrue(ok, 'status纯时间→不应崩溃')
end

-- ============================================================
-- 🔟  p.style — 风格模板
-- ============================================================
function suite:test_style()
    local html = m.style(makeFrame{[1] = '像素风', time = '2020'})
    self:assertStringContains('像素风', html, true, '风格文本')
    self:assertStringContains('2020',   html, true, '时间文本')
end

function suite:test_style_no_value_with_time()
    local html = m.style(makeFrame{time = '2024'})
    self:assertStringContains('<div', html, true, 'style纯时间→不崩溃(有or保护)')
end

-- ============================================================
-- 1️⃣1️⃣  p.backdrop — 背景模板
-- ============================================================
function suite:test_backdrop()
    local html = m.backdrop(makeFrame{[1] = '现代都市'})
    self:assertStringContains('现代都市', html, true, '背景文本')
end

function suite:test_backdrop_no_value_with_time()
    local html = m.backdrop(makeFrame{time = '2024'})
    self:assertStringContains('<div', html, true, 'backdrop纯时间→不崩溃(有or保护)')
end

-- ============================================================
-- 1️⃣2️⃣  p.homepage — 主页链接模板
-- ============================================================
function suite:test_homepage_platform_mode()
    local html = m.homepage(makeFrame{[1] = 'bilibili', [2] = 'https://space.bilibili.com/123'})
    self:assertStringContains('bilibili.png', html, true, '平台图标出现')
    self:assertStringContains('space.bilibili.com/123', html, true, 'URL出现')
end

function suite:test_homepage_auto_detect()
    local html = m.homepage(makeFrame{[1] = 'https://twitter.com/user'})
    self:assertStringContains('twitter.png', html, true, '自动识别twitter图标')
end

function suite:test_homepage_empty_url()
    local html = m.homepage(makeFrame{[1] = 'bilibili', [2] = ''})
    self:assertStringContains('<div', html, true, '空URL→不崩溃')
end

function suite:test_homepage_fake_platform()
    local html = m.homepage(makeFrame{[1] = 'fakeplatform', [2] = 'https://example.com'})
    self:assertStringContains('fakeplatform.png', html, true, '未知平台→应用args[1]做图标名')
end

function suite:test_homepage_no_protocol()
    local html = m.homepage(makeFrame{[1] = 'example.com/wiki'})
    self:assertStringContains('https://example.com/wiki', html, true, '无协议→自动补https')
end

-- ============================================================
-- 1️⃣3️⃣  p.licence — 许可协议模板
-- ============================================================
function suite:test_licence_cc_by_sa()
    local html = m.licence(makeFrame{[1] = 'CC BY-SA 4.0'})
    self:assertStringContains('CC.png', html, true, 'CC基础图标')
    self:assertStringContains('BY', html, true, 'BY标识')
    self:assertStringContains('SA', html, true, 'SA标识')
    self:assertStringContains('4.0', html, true, '版本号')
end

function suite:test_licence_cc0()
    local html = m.licence(makeFrame{[1] = 'CC0 1.0'})
    self:assertStringContains('CC.png', html, true, 'CC图标')
    self:assertStringContains('1.0', html, true, '版本号')
end

function suite:test_licence_cc_invalid()
    local html = m.licence(makeFrame{[1] = 'CC BY-ND-SA 4.0'})
    self:assertStringContains('搭配不正确', html, true, '非法CC报错')
end

function suite:test_licence_copyright()
    local html = m.licence(makeFrame{[1] = 'Copyright'})
    self:assertStringContains('Copyright', html, true, 'Copyright模板')
end

function suite:test_licence_unknown()
    local html = m.licence(makeFrame{[1] = 'WTFPL'})
    self:assertStringContains('WTFPL', html, true, '未知许可原文输出')
end

function suite:test_licence_lowercase()
    local html = m.licence(makeFrame{[1] = 'cc by-sa 4.0'})
    self:assertStringContains('CC', html, true, '小写cc→识别为CC')
end

function suite:test_licence_no_version()
    local html = m.licence(makeFrame{[1] = 'CC BY-NC'})
    self:assertStringContains('BY', html, true, '无版本号→仍识别组件')
end

function suite:test_licence_too_many_modifiers()
    local html = m.licence(makeFrame{[1] = 'CC BY-NC-ND-SA 4.0'})
    self:assertStringContains('搭配不正确', html, true, '四修饰符→报错')
end

function suite:test_licence_cczero_variants()
    local html = m.licence(makeFrame{[1] = 'CC ZERO 1.0'})
    self:assertStringContains('CC', html, true, 'CC ZERO→识别')
end

function suite:test_licence_custom_text()
    local html = m.licence(makeFrame{[1] = 'Copyright @ UTCWIKi'})
    self:assertStringContains('Copyright @ UTCWIKi', html, true, 'Copyright@UTCWIKi→识别')
end

-- ============================================================
-- 1️⃣4️⃣  p.date — 日期模板
-- ============================================================
function suite:test_date_basic()
    local html = m.date(makeFrame{[1] = '2024-01-05', set = '发布日期'})
    self:assertStringContains('发布', html, true, '"日期"后缀被移除')
    self:assertStringContains('2024-01-05', html, true, '日期文本')
end

function suite:test_date_reset()
    local html = m.date(makeFrame{[1] = '2024-06', set = '重置日期'})
    self:assertStringContains('重置', html, true, '"重置"识别')
end

function suite:test_date_remake()
    local html = m.date(makeFrame{[1] = '2024', set = '重制日期'})
    self:assertStringContains('重制', html, true, '"重制"识别')
end

function suite:test_date_custom_suffix()
    local html = m.date(makeFrame{[1] = '2024', set = '上线日期'})
    self:assertStringContains('上线', html, true, '自定义"xx日期"→去后缀')
end

function suite:test_date_malformed()
    local html = m.date(makeFrame{[1] = 'not-a-date', set = '发布日期'})
    self:assertStringContains('<div', html, true, '非法日期→不崩溃')
end

function suite:test_date_crash_with_no_set()
    local ok = pcall(function() m.date(makeFrame{[1] = '2024-01-01'}) end)
    self:assertTrue(ok, 'date无set→不应崩溃')
end

-- ============================================================
-- 自定义 run():包装原生 ScribuntoUnit,输出表格增加"说明"列
-- 使用闭包绑定 self,与 ScribuntoUnit.new() 模式一致
-- ============================================================
suite.run = function(frame)
    local testData = ScribuntoUnit.runSuite(suite, frame)
    local cfg = mw.loadData('Module:ScribuntoUnit/config')

    if testData.failureCount > 0 then
        local out = mw.ustring.gsub(cfg.failureSummary, '$1', tostring(testData.failureCount)) .. '\n'
        out = out .. '{| class="wikitable" style="width:100%"\n'
        out = out .. '! 状态 !! 测试函数 !! 说明 !! 详情\n'
        for _, r in ipairs(testData.results) do
            local status = r.error and '❌' or '✅'
            local desc = DESC[r.name] or ''
            local bg = r.error and 'style="background:#ffe0e0"' or ''
            local detail = ''
            if r.error then
                detail = tostring(r.message or r.error)
                detail = mw.ustring.gsub(detail, '\n', ' ')
                if #detail > 130 then detail = mw.ustring.sub(detail, 1, 127) .. '...' end
                detail = '<br><small style="color:#c00">' .. mw.text.nowiki(detail) .. '</small>'
            end
            out = out .. '|- ' .. bg .. '\n| ' .. status .. ' || <code>' .. r.name .. '</code> || ' .. desc .. ' || ' .. detail .. '\n'
        end
        out = out .. '|}\n'
        return out
    else
        local out = cfg.successSummary .. '\n'
        out = out .. '{| class="wikitable" style="width:100%"\n'
        out = out .. '! 状态 !! 测试函数 !! 说明\n'
        for _, r in ipairs(testData.results) do
            out = out .. '|-\n| ✅ || <code>' .. r.name .. '</code> || ' .. (DESC[r.name] or '') .. '\n'
        end
        out = out .. '|}\n'
        return out
    end
end

return suite