assertBaselineMatch.mjs

import fs from 'fs'
import path from 'path'
import pixelmatch from 'pixelmatch'
import { PNG } from 'pngjs'


/**
 * 截圖與標準圖比對(反鋸齒感知之像素比對加容差),不一致時保留三聯組證據後拋錯
 *
 * 比對:pixelmatch之includeAA:false自動忽略反鋸齒邊緣像素,threshold預設0.1,真不同像素數超過maxDiffPixels(預設100)即失敗;
 * 尺寸不同必為真差異,直接失敗;標準圖不存在直接拋錯。像素層為補強,呼叫前每案仍須先有語意斷言。
 * 證據:失敗時把當次截圖、標準圖與差異圖存到pendingDir(預設'./testPending'),檔名為`<label>__<毫秒時間戳>__{capture,baseline,diff}.png`,撞名加-N,永不覆蓋、不自動刪除。
 * 餘裕告警:通過但真不同像素數超過maxDiffPixels之headroomRatio(預設0.5)時印告警——計數貼近上限之標準圖為零餘裕之flake源(2026-09-28 實測兩張恰為100),應重產或查因。
 * 本函數只比對不寫檔:標準圖之寫檔一律經runBaselineCase(全部斷言通過後才寫);原opt.regen寫檔旁路(第二條寫檔路徑、先寫圖後斷言)已於2026-09-28移除。
 *
 * 本函數為同步函數
 *
 * @param {Buffer} buf 輸入當次截圖PNG之Buffer
 * @param {String} baselinePath 輸入標準圖路徑字串
 * @param {String} [label=''] 輸入證據檔名用之可讀標籤字串,預設''代表使用標準圖檔名
 * @param {Object} [opt={}] 輸入設定物件,預設{}
 * @param {Number} [opt.maxDiffPixels=100] 輸入容許之真不同像素數,預設100
 * @param {Number} [opt.threshold=0.1] 輸入pixelmatch之色差門檻,預設0.1
 * @param {String} [opt.pendingDir='./testPending'] 輸入失敗證據目錄字串,預設'./testPending'
 * @param {Number} [opt.headroomRatio=0.5] 輸入餘裕告警比例,通過但真不同像素數大於maxDiffPixels乘此比例時告警,預設0.5;設為1以上即不告警
 * @param {Function} [opt.warn=console.warn] 輸入告警輸出函數,預設console.warn
 * @returns {undefined} 通過時無回傳,不一致時拋出錯誤
 * @example
 *
 * import assertBaselineMatch from 'w-package-tools-e2e/src/assertBaselineMatch.mjs'
 *
 * assertBaselineMatch(buf, './test/pics/tokens/tokens-eng-E2E-001-list-loaded.png', 'tokens-eng-E2E-001-list-loaded')
 *
 */
function assertBaselineMatch(buf, baselinePath, label = '', opt = {}) {
    let { maxDiffPixels = 100, threshold = 0.1, pendingDir = './testPending', headroomRatio = 0.5, warn = console.warn } = opt
    if (opt.regen !== undefined) {
        throw new Error('assertBaselineMatch: opt.regen 已移除(2026-09-28), 標準圖寫檔請經 runBaselineCase')
    }

    if (!fs.existsSync(baselinePath)) {
        throw new Error(`標準圖不存在: ${baselinePath} (請先執行對應 e2e --baseline 產製)`)
    }
    let baselineBuf = fs.readFileSync(baselinePath)

    //解碼PNG為RGBA(pngjs同步, 保持本函數同步)
    let capPng = PNG.sync.read(buf)
    let basePng = PNG.sync.read(baselineBuf)

    //失敗: 保留capture/baseline(/diff)至pendingDir(不覆蓋, 帶毫秒時間戳)後拋錯
    let dump = (reason, diffPng) => {
        if (!fs.existsSync(pendingDir)) {
            fs.mkdirSync(pendingDir, { recursive: true })
        }
        let safe = (label || path.basename(baselinePath, '.png')).replace(/[^\w.-]/g, '_')
        let ts = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 23)
        let stem = `${pendingDir}/${safe}__${ts}`
        let n = 0
        while (fs.existsSync(`${stem}__capture.png`) || fs.existsSync(`${stem}__baseline.png`)) {
            n += 1
            stem = `${pendingDir}/${safe}__${ts}-${n}`
        }
        fs.writeFileSync(`${stem}__capture.png`, buf)
        fs.writeFileSync(`${stem}__baseline.png`, baselineBuf)
        if (diffPng) {
            fs.writeFileSync(`${stem}__diff.png`, PNG.sync.write(diffPng))
        }
        throw new Error(`截圖與標準圖不一致 (${reason}): ${safe} — capture/baseline${diffPng ? '/diff' : ''} 已存 ${stem}__*.png 供 diff`)
    }

    //尺寸不同必為真差異(版面/裁切變)
    if (capPng.width !== basePng.width || capPng.height !== basePng.height) {
        dump(`尺寸不同 cap=${capPng.width}x${capPng.height} base=${basePng.width}x${basePng.height}`)
    }

    //pixelmatch: 反鋸齒感知比對, 回傳真不同像素數
    let { width, height } = basePng
    let diffPng = new PNG({ width, height })
    let numDiff = pixelmatch(capPng.data, basePng.data, diffPng.data, width, height, { threshold, includeAA: false })
    if (numDiff <= maxDiffPixels) {
        if (numDiff > maxDiffPixels * headroomRatio) {
            warn(`[baseline-headroom] ${label || path.basename(baselinePath, '.png')}: 通過但 diff=${numDiff}px 已超過 maxDiffPixels=${maxDiffPixels} 之 ${Math.round(headroomRatio * 100)}%,餘裕不足(再多 ${maxDiffPixels - numDiff + 1}px 即失敗),應查因或重產`)
        }
        return
    }
    dump(`diff=${numDiff}px > maxDiffPixels=${maxDiffPixels}`, diffPng)
}


export default assertBaselineMatch