-- Copyright (c) 2026 Huawei Technologies Co., Ltd.
-- openUBMC is licensed under Mulan PSL v2.
-- You can use this software according to the terms and conditions of the Mulan PSL v2.
-- You may obtain a copy of Mulan PSL v2 at:
--         http://license.coscl.org.cn/MulanPSL2
-- THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
-- EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
-- MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
-- See the Mulan PSL v2 for more details.

-- RAID 卡配置自动备份/恢复(controller.ctrl_config_backup)单元测试

local lu = require('luaunit')
local common_def = require('common_def')

-- 保存被替换模块的原始引用,tearDown 时恢复,避免污染后续用例
-- (TestCtrlLogDump 等用例会在运行时 reload 依赖 storageconfig_object 的模块)
local orig_storageconfig = package.loaded['storageconfig.storageconfig_object']
local orig_rpc_controller = package.loaded['rpc_services.rpc_service_controller']

local skynet = require('skynet')

-- ---------- 可注入的 stub ----------

-- 记录当前模块实例(每次 reload 后刷新)
local ctrl_backup

-- skynet.fork:默认异步不执行回调;用例中按需 patch(setUp 恢复默认行为)
local orig_fork = skynet.fork

-- rpc_service_controller stub:返回码可配置
local rpc_results = {}
local rpc_controller_stub = {}
for _, m in ipairs({
    'set_copyback_state',
    'set_smart_copyback_state',
    'set_jbod_state',
    'set_maintain_pd_fail_history_state',
    'set_no_battery_write_cache',
    'set_read_cache_percent',
    'set_work_mode',
    'set_write_cache_policy',
}) do
    rpc_controller_stub[m] = function(...)
        if rpc_results[m] == nil then
            return true
        end
        if type(rpc_results[m]) == 'table' then
            error(rpc_results[m])
        end
        return rpc_results[m]
    end
end

-- 内存版 ORM mock:支持 select:where({..}):first()/all()、字段:eq() 形式、save/delete
local fail_save = false
local fail_delete = false
local mock_tables = {}
local mock_db

local function reset_mock_db()
    mock_tables = {}
    fail_save = false
    fail_delete = false
end

local function match_conds(rec, conds)
    for k, v in pairs(conds) do
        if rec[k] ~= v then
            return false
        end
    end
    return true
end

local function normalize_where(...)
    local conds = {}
    local args = { ... }
    if #args == 1 and type(args[1]) == 'table' then
        for k, v in pairs(args[1]) do
            conds[k] = v
        end
    else
        for _, c in ipairs(args) do
            if type(c) == 'table' and c.__field then
                conds[c.__field] = c.__value
            end
        end
    end
    return conds
end

local function make_mock_db()
    local db = {}

    local function make_chain(tbl_name)
        local chain = {}
        local conds = {}
        function chain:where(...)
            for k, v in pairs(normalize_where(...)) do
                conds[k] = v
            end
            return chain
        end
        function chain:first()
            for _, r in ipairs(mock_tables[tbl_name] or {}) do
                if match_conds(r, conds) then
                    return r
                end
            end
            return nil
        end
        function chain:all()
            local out = {}
            for _, r in ipairs(mock_tables[tbl_name] or {}) do
                if match_conds(r, conds) then
                    out[#out + 1] = r
                end
            end
            return out
        end
        return chain
    end

    local function make_tbl(tbl_name)
        local t = { __tbl_name = tbl_name }
        setmetatable(t, {
            __call = function(_, snapshot)
                local rec = {}
                for k, v in pairs(snapshot) do
                    rec[k] = v
                end
                function rec:save()
                    if fail_save then
                        error('mock save failed')
                    end
                    local list = mock_tables[tbl_name]
                    for _, r in ipairs(list or {}) do
                        if r == rec then
                            return
                        end
                    end
                    mock_tables[tbl_name] = mock_tables[tbl_name] or {}
                    mock_tables[tbl_name][#mock_tables[tbl_name] + 1] = rec
                end
                function rec:delete()
                    if fail_delete then
                        error('mock delete failed')
                    end
                    local list = mock_tables[tbl_name] or {}
                    for i, r in ipairs(list) do
                        if r == rec then
                            table.remove(list, i)
                            return
                        end
                    end
                end
                return rec
            end,
        })
        for _, f in ipairs({
            'ControllerTypeId',
            'ControllerSlotId',
            'BackupTimestamp',
            'DriveModel',
            'DriveSlotNumber',
        }) do
            t[f] = {
                eq = function(_, v)
                    return { __field = f, __value = v }
                end,
            }
        end
        return t
    end

    db.ControllerConfigBackup = make_tbl('ControllerConfigBackup')
    db.DriveConfigBackup = make_tbl('DriveConfigBackup')

    function db:select(tbl)
        return make_chain(tbl.__tbl_name)
    end

    return db
end

-- storageconfig stub:get_instance().db 返回 mock_db
local storageconfig_stub
local function reset_storageconfig_stub()
    storageconfig_stub = {
        get_instance = function()
            return { db = mock_db }
        end,
    }
end

-- 信号捕获
local signals = {}
local function make_bus()
    return {
        signal = function(bus, path, intf, name, sig, args, value)
            signals[#signals + 1] = { path = path, intf = intf, name = name, value = value }
        end,
    }
end

-- 构造 mock controller 对象
local function make_controller(overrides)
    local ctrl = {
        Id = 0,
        SlotId = 0,
        TypeId = 1,
        Name = 'Ctrl0',
        obj = { path = '/Storage/Controller0' },
        bus = make_bus(),
        config_backed_up = false,
        CopyBackState = 1,
        SmarterCopyBackState = 1,
        JBODState = 0,
        MaintainPDFailHistrory = 1,
        NoBatteryWriteCacheEnabled = 0,
        ReadCachePercent = 30,
        WorkMode = 'RAID',
        UnconfiguredDriveWriteCachePolicy = 'WriteThrough',
        HBADriveWriteCachePolicy = 'WriteThrough',
        ConfiguredDriveWriteCachePolicy = 'WriteThrough',
    }
    if overrides then
        for k, v in pairs(overrides) do
            ctrl[k] = v
        end
    end
    return ctrl
end

-- 重新加载模块:注入 stub 并拿到新的单例实例
local function reload_module()
    package.loaded['storageconfig.storageconfig_object'] = nil
    package.loaded['rpc_services.rpc_service_controller'] = nil
    package.loaded['controller.ctrl_config_backup'] = nil

    package.loaded['storageconfig.storageconfig_object'] = storageconfig_stub
    package.loaded['rpc_services.rpc_service_controller'] = rpc_controller_stub

    ctrl_backup = require('controller.ctrl_config_backup').get_instance()
end

-- ---------- 用例 ----------

TestCtrlConfigBackup = {}

function TestCtrlConfigBackup:setUp()
    reset_mock_db()
    mock_db = make_mock_db()
    reset_storageconfig_stub()
    rpc_results = {}
    signals = {}
    skynet.fork = orig_fork
    reload_module()
end

function TestCtrlConfigBackup:tearDown()
    skynet.fork = orig_fork
    package.loaded['storageconfig.storageconfig_object'] = orig_storageconfig
    package.loaded['rpc_services.rpc_service_controller'] = orig_rpc_controller
end

-- compare_config:nil 与无效值 255 等价(未设置视为没设置过)
function TestCtrlConfigBackup:test_compare_config_u8_nil_equals_invalid()
    local controller = make_controller()
    controller.ReadCachePercent = nil
    local persisted = {
        ReadCachePercent = common_def.INVALID_U8,
        CopyBackState = 1,
        SmarterCopyBackState = 1,
        JBODState = 0,
        MaintainPDFailHistory = 1,
        NoBatteryWriteCacheEnabled = 0,
        WorkMode = 'RAID',
        UnconfiguredDriveWriteCachePolicy = 'WriteThrough',
        HBADriveWriteCachePolicy = 'WriteThrough',
        ConfiguredDriveWriteCachePolicy = 'WriteThrough',
    }
    lu.assertNil(ctrl_backup:compare_config(controller, persisted))
end

-- compare_config:字符串 nil 与 'N/A' 等价
function TestCtrlConfigBackup:test_compare_config_str_nil_equals_na()
    local controller = make_controller()
    controller.WorkMode = nil
    local persisted = {
        ReadCachePercent = 30,
        CopyBackState = 1,
        SmarterCopyBackState = 1,
        JBODState = 0,
        MaintainPDFailHistory = 1,
        NoBatteryWriteCacheEnabled = 0,
        WorkMode = common_def.INVALID_STRING,
        UnconfiguredDriveWriteCachePolicy = 'WriteThrough',
        HBADriveWriteCachePolicy = 'WriteThrough',
        ConfiguredDriveWriteCachePolicy = 'WriteThrough',
    }
    lu.assertNil(ctrl_backup:compare_config(controller, persisted))
end

-- compare_config:存在真实差异时返回 diff(含当前值与持久化值)
function TestCtrlConfigBackup:test_compare_config_detects_diff()
    local controller = make_controller({ CopyBackState = 0 })
    local persisted = {
        ReadCachePercent = 30,
        CopyBackState = 1,
        SmarterCopyBackState = 1,
        JBODState = 0,
        MaintainPDFailHistory = 1,
        NoBatteryWriteCacheEnabled = 0,
        WorkMode = 'RAID',
        UnconfiguredDriveWriteCachePolicy = 'WriteThrough',
        HBADriveWriteCachePolicy = 'WriteThrough',
        ConfiguredDriveWriteCachePolicy = 'WriteThrough',
    }
    local diff = ctrl_backup:compare_config(controller, persisted)
    lu.assertNotNil(diff)
    lu.assertEquals(diff.CopyBackState.current, 0)
    lu.assertEquals(diff.CopyBackState.persisted, 1)
end

-- compare_config:持久化记录缺字段(旧记录)不误判差异
function TestCtrlConfigBackup:test_compare_config_missing_persisted_field_no_diff()
    -- 新属性未设置(默认 255/'N/A'),旧记录没有这些字段,归一化后等价
    local controller = make_controller({
        ReadCachePercent = common_def.INVALID_U8,
        SmarterCopyBackState = common_def.INVALID_U8,
        JBODState = common_def.INVALID_U8,
        MaintainPDFailHistrory = common_def.INVALID_U8,
        NoBatteryWriteCacheEnabled = common_def.INVALID_U8,
        WorkMode = common_def.INVALID_STRING,
        UnconfiguredDriveWriteCachePolicy = common_def.INVALID_STRING,
        HBADriveWriteCachePolicy = common_def.INVALID_STRING,
        ConfiguredDriveWriteCachePolicy = common_def.INVALID_STRING,
    })
    local persisted = { CopyBackState = 1 }
    lu.assertNil(ctrl_backup:compare_config(controller, persisted))
end

-- upsert_backup:无记录时插入,nil 归一化为无效值,返回 true
function TestCtrlConfigBackup:test_upsert_backup_insert_normalizes()
    local controller = make_controller()
    controller.ReadCachePercent = nil
    controller.WorkMode = nil
    local ok = ctrl_backup:upsert_backup(controller)
    lu.assertTrue(ok)
    local records = mock_tables['ControllerConfigBackup']
    lu.assertEquals(#records, 1)
    lu.assertEquals(records[1].ControllerTypeId, controller.TypeId)
    lu.assertEquals(records[1].ControllerSlotId, controller.SlotId)
    lu.assertEquals(records[1].ReadCachePercent, common_def.INVALID_U8)
    lu.assertEquals(records[1].WorkMode, common_def.INVALID_STRING)
    lu.assertEquals(records[1].CopyBackState, 1)
end

-- upsert_backup:已有记录时更新,返回 true
function TestCtrlConfigBackup:test_upsert_backup_update()
    mock_db
        .ControllerConfigBackup({
            ControllerTypeId = 1,
            ControllerSlotId = 0,
            CopyBackState = 1,
            ReadCachePercent = 30,
            WorkMode = 'RAID',
            BackupTimestamp = 1,
        })
        :save()
    local controller = make_controller({ ReadCachePercent = 60 })
    local ok = ctrl_backup:upsert_backup(controller)
    lu.assertTrue(ok)
    local records = mock_tables['ControllerConfigBackup']
    lu.assertEquals(#records, 1)
    lu.assertEquals(records[1].ReadCachePercent, 60)
    lu.assertTrue(records[1].BackupTimestamp > 0)
end

-- upsert_backup:save 失败时记录日志并返回 false
function TestCtrlConfigBackup:test_upsert_backup_insert_save_failure_returns_false()
    fail_save = true
    local controller = make_controller()
    lu.assertFalse(ctrl_backup:upsert_backup(controller))
end

-- evict_if_needed:每槽位最多 5 条,超出淘汰最老记录
function TestCtrlConfigBackup:test_evict_if_needed_evicts_oldest_per_slot()
    for i = 1, 5 do
        mock_db
            .ControllerConfigBackup({
                ControllerTypeId = i,
                ControllerSlotId = 1,
                BackupTimestamp = i,
            })
            :save()
    end
    -- 同槽位再加一条不同型号
    ctrl_backup:evict_if_needed(1)
    ctrl_backup:upsert_backup(make_controller({ TypeId = 6, SlotId = 1 }))
    local records = mock_tables['ControllerConfigBackup']
    lu.assertEquals(#records, 5)
    -- 最老(timestamp=1)被淘汰
    local has_oldest = false
    for _, r in ipairs(records) do
        if r.BackupTimestamp == 1 then
            has_oldest = true
        end
    end
    lu.assertFalse(has_oldest)
    -- 其他槽位不受影响
    mock_db.ControllerConfigBackup({ ControllerTypeId = 9, ControllerSlotId = 2, BackupTimestamp = 9 }):save()
    ctrl_backup:evict_if_needed(2)
    lu.assertEquals(#mock_tables['ControllerConfigBackup'], 6)
end

-- evict_if_needed:delete 失败时记录日志并中断淘汰
function TestCtrlConfigBackup:test_evict_if_needed_delete_failure_breaks()
    for i = 1, 5 do
        mock_db
            .ControllerConfigBackup({
                ControllerTypeId = i,
                ControllerSlotId = 1,
                BackupTimestamp = i,
            })
            :save()
    end
    fail_delete = true
    ctrl_backup:evict_if_needed(1)
    lu.assertEquals(#mock_tables['ControllerConfigBackup'], 5)
end

-- set_need_recover:上发/撤销恢复信号
function TestCtrlConfigBackup:test_set_need_recover_signals()
    local controller = make_controller()
    ctrl_backup:set_need_recover(controller, true)
    lu.assertEquals(signals[#signals].value, true)
    ctrl_backup:set_need_recover(controller, false)
    lu.assertEquals(signals[#signals].value, false)
end

-- on_first_config_loaded:无记录判定为新卡,直接备份,上发无需恢复信号
function TestCtrlConfigBackup:test_on_first_config_loaded_new_controller_backs_up()
    local controller = make_controller()
    ctrl_backup:on_first_config_loaded(controller)
    lu.assertTrue(controller.config_backed_up)
    lu.assertEquals(#mock_tables['ControllerConfigBackup'], 1)
    lu.assertEquals(signals[#signals].value, false)
end

-- on_first_config_loaded:有记录且配置一致,跳过,上发无需恢复信号
function TestCtrlConfigBackup:test_on_first_config_loaded_no_diff_skips()
    ctrl_backup:upsert_backup(make_controller())
    local controller = make_controller()
    ctrl_backup:on_first_config_loaded(controller)
    lu.assertTrue(controller.config_backed_up)
    lu.assertEquals(signals[#signals].value, false)
    lu.assertEquals(#mock_tables['ControllerConfigBackup'], 1)
end

-- on_first_config_loaded:有记录且配置差异,上发恢复信号且不备份
function TestCtrlConfigBackup:test_on_first_config_loaded_diff_notifies_recover()
    ctrl_backup:upsert_backup(make_controller({ CopyBackState = 1 }))
    local controller = make_controller({ CopyBackState = 0 })
    ctrl_backup:on_first_config_loaded(controller)
    lu.assertTrue(controller.config_backed_up)
    lu.assertEquals(signals[#signals].value, true)
    -- 不备份当前配置,保留旧配置供决策
    lu.assertEquals(#mock_tables['ControllerConfigBackup'], 1)
end

-- update_backup_prop:只更新被设置的属性
function TestCtrlConfigBackup:test_update_backup_prop_single_prop_update()
    local controller = make_controller({ CopyBackState = 1 })
    ctrl_backup:upsert_backup(controller)
    controller.CopyBackState = 0
    ctrl_backup:update_backup_prop(controller, 'CopyBackState')
    local records = mock_tables['ControllerConfigBackup']
    lu.assertEquals(records[1].CopyBackState, 0)
    -- 其他属性不受影响
    lu.assertEquals(records[1].ReadCachePercent, 30)
    -- 备份内容更新时同步刷新备份时间戳
    lu.assertNotNil(records[1].BackupTimestamp)
end

-- update_backup_prop:无持久化记录时兜底全量备份
function TestCtrlConfigBackup:test_update_backup_prop_no_record_fallback()
    local controller = make_controller()
    controller.config_backed_up = true
    ctrl_backup:update_backup_prop(controller, 'CopyBackState')
    lu.assertEquals(#mock_tables['ControllerConfigBackup'], 1)
end

-- 换卡触发"需要恢复"后,北向手动设置属性使配置与库一致:update_backup_prop 清除恢复信号
function TestCtrlConfigBackup:test_update_backup_prop_aligned_clears_need_recover()
    ctrl_backup:upsert_backup(make_controller({ CopyBackState = 1 }))
    local controller = make_controller({ CopyBackState = 0 })
    ctrl_backup:on_first_config_loaded(controller)
    lu.assertEquals(signals[#signals].value, true)
    -- 北向手动设置 CopyBackState=1(与库一致)
    controller.CopyBackState = 1
    ctrl_backup:update_backup_prop(controller, 'CopyBackState')
    lu.assertEquals(signals[#signals].value, false)
end

-- recover:有差异时逐项下发并撤销恢复信号;库中字段保持恢复目标值
function TestCtrlConfigBackup:test_recover_success()
    ctrl_backup:upsert_backup(make_controller({ CopyBackState = 1, ReadCachePercent = 30 }))
    local controller = make_controller({ CopyBackState = 0, ReadCachePercent = 30 })
    controller.config_backed_up = true
    lu.assertTrue(ctrl_backup:recover(controller))
    -- 恢复成功后库中字段保持恢复目标值(差异属性已下发到设备,库中即目标值;
    -- 不按 controller 属性重建快照,避免 set 方法未同步属性时把库写回恢复前值)
    local records = mock_tables['ControllerConfigBackup']
    lu.assertEquals(records[1].CopyBackState, 1)
    -- 撤销恢复信号,恢复标记清理
    lu.assertEquals(signals[#signals].value, false)
    lu.assertNil(ctrl_backup.recovering_ctrl_ids[controller.Id])
end

-- recover:下发失败时记录日志并重置 config_backed_up,保证下次配置刷新重检(掉电/重启断点续恢)
function TestCtrlConfigBackup:test_recover_failure_resets_flag()
    ctrl_backup:upsert_backup(make_controller({ CopyBackState = 1, ReadCachePercent = 30 }))
    rpc_results.set_copyback_state = { 'rpc err' }
    local controller = make_controller({ CopyBackState = 0, ReadCachePercent = 30 })
    controller.config_backed_up = true
    -- recover 不抛错,失败时返回 false(由 storage_app 判断后向 RPC 调用方抛错)
    lu.assertFalse(ctrl_backup:recover(controller))
    lu.assertFalse(controller.config_backed_up)
    lu.assertNil(ctrl_backup.recovering_ctrl_ids[controller.Id])
    -- 部分失败不更新持久化库
    local records = mock_tables['ControllerConfigBackup']
    lu.assertEquals(records[1].CopyBackState, 1)
end

-- recover:无差异时直接撤销恢复信号
function TestCtrlConfigBackup:test_recover_no_diff_clears_need_recover()
    local controller = make_controller()
    ctrl_backup:upsert_backup(controller)
    lu.assertTrue(ctrl_backup:recover(controller))
    lu.assertEquals(signals[#signals].value, false)
end

-- restore_item:WorkMode 字符串映射为索引下发
function TestCtrlConfigBackup:test_restore_item_work_mode()
    local controller = make_controller()
    local ok = ctrl_backup:restore_item(controller, 'WorkMode', 'JBOD')
    lu.assertTrue(ok)
    ok = ctrl_backup:restore_item(controller, 'WorkMode', 'NotExistMode')
    lu.assertFalse(ok)
end

-- restore_item:未知属性返回 false
function TestCtrlConfigBackup:test_restore_item_unknown_prop()
    local controller = make_controller()
    lu.assertFalse(ctrl_backup:restore_item(controller, 'NoSuchProp', 1))
end

-- restore_item:下发抛错时 pcall 兜底返回 false
function TestCtrlConfigBackup:test_restore_item_rpc_error_caught()
    rpc_results.set_jbod_state = { 'rpc err' }
    local controller = make_controller()
    local ok = ctrl_backup:restore_item(controller, 'JBODState', 1)
    lu.assertFalse(ok)
end

-- sync_inband_config:值相对上次读取无变化时不写库、不发信号
function TestCtrlConfigBackup:test_sync_inband_config_no_diff()
    local controller = make_controller()
    ctrl_backup:upsert_backup(controller)
    controller.config_backed_up = true
    -- 首次刷新仅记录基线,不写库
    ctrl_backup:sync_inband_config(controller)
    -- 再次刷新读到相同值:与基线无差异,不更新、不发信号
    ctrl_backup:sync_inband_config(controller)
    local records = mock_tables['ControllerConfigBackup']
    lu.assertEquals(records[1].CopyBackState, 1)
    lu.assertEquals(records[1].ReadCachePercent, 30)
    lu.assertEquals(#signals, 0)
end

-- sync_inband_config:带内修改单个属性时仅更新该属性
function TestCtrlConfigBackup:test_sync_inband_config_updates_single_prop()
    local controller = make_controller({ CopyBackState = 1 })
    ctrl_backup:upsert_backup(controller)
    controller.config_backed_up = true
    -- 首次刷新记录基线
    ctrl_backup:sync_inband_config(controller)
    -- 带内修改 CopyBackState 后刷新:相对基线变化,仅更新该属性
    controller.CopyBackState = 0
    ctrl_backup:sync_inband_config(controller)
    local records = mock_tables['ControllerConfigBackup']
    lu.assertEquals(records[1].CopyBackState, 0)
    -- 未被修改的属性保持原值
    lu.assertEquals(records[1].ReadCachePercent, 30)
end

-- sync_inband_config:等待恢复期间(设备值=0、数据库=1)刷新读到相同值不写库,保留恢复基线
function TestCtrlConfigBackup:test_sync_inband_config_unchanged_keeps_db_baseline()
    -- 数据库中为旧卡配置:CopyBackState=1
    ctrl_backup:upsert_backup(make_controller({ CopyBackState = 1, ReadCachePercent = 30 }))
    -- 新卡带内读到的值:CopyBackState=0(等待恢复期间设备未恢复)
    local controller = make_controller({ CopyBackState = 0, ReadCachePercent = 30 })
    controller.config_backed_up = true
    -- 首次刷新仅记录基线(设备值 0),不写库
    ctrl_backup:sync_inband_config(controller)
    local records = mock_tables['ControllerConfigBackup']
    lu.assertEquals(records[1].CopyBackState, 1)
    -- 恢复等待期间循环刷新:读到相同值 0,相对基线无变化,仍不写库
    ctrl_backup:sync_inband_config(controller)
    ctrl_backup:sync_inband_config(controller)
    lu.assertEquals(records[1].CopyBackState, 1)
    lu.assertEquals(#signals, 0)
end

-- sync_inband_config:恢复执行中跳过(属性值处于逐项下发中途)
function TestCtrlConfigBackup:test_sync_inband_config_skipped_during_recover()
    local controller = make_controller({ CopyBackState = 1 })
    ctrl_backup:upsert_backup(controller)
    controller.config_backed_up = true
    ctrl_backup.recovering_ctrl_ids[controller.Id] = true
    controller.CopyBackState = 0
    ctrl_backup:sync_inband_config(controller)
    local records = mock_tables['ControllerConfigBackup']
    lu.assertEquals(records[1].CopyBackState, 1)
    lu.assertEquals(#signals, 0)
end

-- cleanup_controller_state:拔卡清理该卡全部恢复/备份状态
function TestCtrlConfigBackup:test_cleanup_controller_state()
    local controller = make_controller()
    ctrl_backup.recovering_ctrl_ids[controller.Id] = true
    ctrl_backup.last_read_ctrl_configs[controller.Id] = { CopyBackState = 0 }
    ctrl_backup:cleanup_controller_state(controller.Id)
    lu.assertNil(ctrl_backup.recovering_ctrl_ids[controller.Id])
    lu.assertNil(ctrl_backup.last_read_ctrl_configs[controller.Id])
end

-- cleanup_controller_state:空 id 不报错
function TestCtrlConfigBackup:test_cleanup_controller_state_nil_id()
    lu.assertNil(ctrl_backup:cleanup_controller_state(nil))
end

-- SlotId 修复:备份记录以固定物理槽位 SlotId 为键,而非不固定的控制器 Id
function TestCtrlConfigBackup:test_upsert_backup_uses_slotid_not_id()
    ctrl_backup:upsert_backup(make_controller({ Id = 3, SlotId = 7 }))
    local records = mock_tables['ControllerConfigBackup']
    lu.assertEquals(#records, 1)
    lu.assertEquals(records[1].ControllerSlotId, 7)
end

-- SlotId 修复:控制器重枚举(Id 变化)后仍能按固定 SlotId 命中持久化记录
function TestCtrlConfigBackup:test_on_first_config_loaded_matches_by_slotid_across_id_change()
    ctrl_backup:upsert_backup(make_controller({ Id = 3, SlotId = 7, CopyBackState = 1 }))
    local controller = make_controller({ Id = 9, SlotId = 7, CopyBackState = 0 })
    ctrl_backup:on_first_config_loaded(controller)
    -- 命中同槽位旧记录并判定差异,上发恢复信号,且不重复备份
    lu.assertEquals(signals[#signals].value, true)
    lu.assertEquals(#mock_tables['ControllerConfigBackup'], 1)
end

-- 无效槽位(SlotId=255):跳过备份检测,不落库、不上发信号
function TestCtrlConfigBackup:test_on_first_config_loaded_invalid_slot_skips()
    local controller = make_controller({ SlotId = common_def.INVALID_U8 })
    ctrl_backup:on_first_config_loaded(controller)
    lu.assertFalse(controller.config_backed_up)
    lu.assertNil(mock_tables['ControllerConfigBackup'])
    lu.assertEquals(#signals, 0)
end

-- upsert 更新分支保存失败:记录日志并返回 false
function TestCtrlConfigBackup:test_upsert_backup_update_failure_returns_false()
    ctrl_backup:upsert_backup(make_controller({ CopyBackState = 1 }))
    fail_save = true
    lu.assertFalse(ctrl_backup:upsert_backup(make_controller({ CopyBackState = 0 })))
end

-- update_backup_prop 保存失败:记录日志,不抛错
function TestCtrlConfigBackup:test_update_backup_prop_save_failure_returns_silently()
    ctrl_backup:upsert_backup(make_controller())
    fail_save = true
    ctrl_backup:update_backup_prop(make_controller({ CopyBackState = 0 }), 'CopyBackState')
    lu.assertEquals(#mock_tables['ControllerConfigBackup'], 1)
end

-- update_backup_timestamp 保存失败:记录日志,不抛错
function TestCtrlConfigBackup:test_update_backup_timestamp_save_failure_returns_silently()
    ctrl_backup:upsert_backup(make_controller())
    fail_save = true
    ctrl_backup:update_backup_timestamp(make_controller())
    lu.assertEquals(#mock_tables['ControllerConfigBackup'], 1)
end