createBaselineGate.mjs

import fs from 'fs'
import path from 'path'
import pixelmatch from 'pixelmatch'
import { PNG } from 'pngjs'
import findOrphanBaselines from './findOrphanBaselines.mjs'


//讀取命令列旗標之值: 旗標存在時須有值且值不得為另一個旗標(防 `--names --langs cht` 把 --langs 吃成名單)
function readFlag(argv, flag) {
    let i = argv.indexOf(flag)
    if (i < 0) {
        return null
    }
    let v = argv[i + 1]
    if (v === undefined || String(v).startsWith('--')) {
        throw new Error(`${flag} 缺少值 (其後須接以逗號分隔之清單)`)
    }
    let items = String(v).split(',').map((s) => s.trim()).filter(Boolean)
    if (items.length === 0) {
        throw new Error(`${flag} 之值為空`)
    }
    return items
}


/**
 * 建立標準圖產製之篩選器:解析--names、--langs、--write-mode與E2E_BASELINE_OUT_DIR,於「截圖之前」決定要跑哪些案例、寫哪些階段圖
 *
 * 案例宣告:cases為[{name,stages,compareOnly,langs}],name為案例鍵;stages為該案產出之階段圖鍵陣列(可省略,省略時單張案例之圖鍵即name,
 * 多階段案例則於執行後以編號前綴比對);compareOnly為true代表只比對不產圖(共用他案標準圖);langs限定語系(預設全部)。
 *
 * --names 每項之解析順序(每項可帶`<語系>-`前綴限定語系,不帶則套用全部已選語系):
 * ①完全等於某案例宣告之階段圖鍵 → 只寫該張(案例鍵與首張階段圖鍵同名時亦只寫該張);
 * ②完全等於案例鍵,或為案例鍵之邊界前綴(`E2E-005`命中`E2E-005-xxx`,不命中`E2E-0051-xxx`) → 寫該案全部階段;
 * ③為已宣告階段圖鍵之邊界前綴 → 寫命中之各張;
 * ④案例未宣告stages而編號相符 → 執行該案,只寫圖鍵等於該項或以「該項-」開頭者,執行後未產出即拋錯(不靜默略過);
 * 命中compareOnly案例、或不符任何案例與圖鍵者一律拋錯並列出可用鍵。--langs須完全等於已宣告語系。
 * 未篩選(全部案例)時casesFor含只比對案例:產製端照跑其流程與語意斷言、不寫圖(runBaselineCase於regen遇compareOnly即不寫),
 * 與mocha REGEN型專案一致(2026-09-28前直跑型專案之產製端從不執行只比對案例)。
 * 孤兒檢查:給pathOf(或由runBaselineCase經usePathOf告知)時,finalize()以findOrphanBaselines靜態比對宣告圖鍵與標準圖目錄,有孤兒即拋錯並列出。
 * --write-mode:all(預設,全寫)、missing(只寫標準圖不存在者,追加案例用)、changed(只寫與現行標準圖差異超過容差或尺寸不同者,避免重寫已審過之圖)。
 * E2E_BASELINE_OUT_DIR:有值時寫檔改導至該目錄(等價驗證用),比對端讀取之標準圖路徑不受影響
 *
 * @param {Object} opt 輸入設定物件
 * @param {Array} opt.langs 輸入已宣告之語系陣列
 * @param {Array} opt.cases 輸入案例宣告陣列(元素為物件或案例鍵字串)
 * @param {Array} [opt.argv=process.argv] 輸入命令列參數陣列,預設process.argv
 * @param {Object} [opt.env=process.env] 輸入環境變數物件,預設process.env
 * @param {RegExp} [opt.idPattern=/^(E2E-\d+)(?=-|$)/] 輸入自鍵取出案例編號之樣式,預設/^(E2E-\d+)(?=-|$)/
 * @param {String} [opt.outDirEnv='E2E_BASELINE_OUT_DIR'] 輸入輸出目錄之環境變數名稱字串,預設'E2E_BASELINE_OUT_DIR'
 * @param {Number} [opt.threshold=0.1] 輸入write-mode為changed時pixelmatch之色差門檻,預設0.1
 * @param {Number} [opt.maxDiffPixels=100] 輸入write-mode為changed時容許之真不同像素數,預設100
 * @param {Function} [opt.pathOf] 輸入標準圖路徑函數(lang,key)=>路徑字串,供孤兒檢查;省略時由runBaselineCase首次產製時經usePathOf告知,皆無則不檢查孤兒
 * @returns {Object} 回傳篩選器物件{filtered,langs,writeMode,outDir,casesFor(lang),shouldWrite(lang,caseName,stageKey),noteProduced(lang,caseName,stageKey),decideWrite(buf,baselinePath),outPath(baselinePath),usePathOf(fn),orphans(),finalize(),describe()}
 * @example
 *
 * import createBaselineGate from 'w-package-tools-e2e/src/createBaselineGate.mjs'
 *
 * let gate = createBaselineGate({
 *     langs: ['eng', 'cht'],
 *     cases: [
 *         { name: 'E2E-001-list-loaded' },
 *         { name: 'E2E-005-grant-perms-save-success', stages: ['E2E-005-1-click-perms', 'E2E-005-2-perms-list'] },
 *     ],
 * })
 * console.log(gate.describe())
 * for (let lang of gate.langs) {
 *     for (let c of gate.casesFor(lang)) {
 *         //...執行案例, 每張圖以 gate.shouldWrite(lang, c.name, key) 決定是否寫檔...
 *     }
 * }
 * gate.finalize()
 *
 */
function createBaselineGate(opt = {}) {
    let {
        langs,
        cases,
        argv = process.argv,
        env = process.env,
        idPattern = /^(E2E-\d+)(?=-|$)/,
        outDirEnv = 'E2E_BASELINE_OUT_DIR',
        threshold = 0.1,
        maxDiffPixels = 100,
    } = opt
    let pathOf = typeof opt.pathOf === 'function' ? opt.pathOf : null
    if (!Array.isArray(langs) || langs.length === 0) {
        throw new Error('createBaselineGate: langs 為必填陣列')
    }
    if (!Array.isArray(cases) || cases.length === 0) {
        throw new Error('createBaselineGate: cases 為必填陣列')
    }
    let decls = cases.map((c) => (typeof c === 'string' ? { name: c } : { ...c }))
    let seen = new Set()
    for (let c of decls) {
        if (!c.name) {
            throw new Error('createBaselineGate: 案例宣告缺 name')
        }
        if (seen.has(c.name)) {
            throw new Error(`createBaselineGate: 案例鍵重複 ${c.name}`)
        }
        seen.add(c.name)
    }
    let idOf = (key) => {
        let m = String(key).match(idPattern)
        return m ? m[1] : null
    }
    let caseLangs = (c) => (Array.isArray(c.langs) ? c.langs : langs)
    let boundary = (key, t) => key === t || key.startsWith(`${t}-`)

    //--langs
    let langItems = readFlag(argv, '--langs')
    if (langItems) {
        for (let l of langItems) {
            if (!langs.includes(l)) {
                throw new Error(`--langs 之「${l}」不是已宣告語系 (${langs.join(', ')})`)
            }
        }
    }
    let selLangs = langs.filter((l) => !langItems || langItems.includes(l))

    //--write-mode
    let wm = readFlag(argv, '--write-mode')
    let writeMode = wm ? wm[0] : 'all'
    if (!['all', 'missing', 'changed'].includes(writeMode)) {
        throw new Error(`--write-mode 須為 all、missing 或 changed,實得「${writeMode}」`)
    }

    let outDir = env[outDirEnv] ? String(env[outDirEnv]) : null

    //--names
    let tokens = readFlag(argv, '--names')
    let filtered = !!tokens
    //sel[lang][caseName] = { all, stages:Set, filters:[{fn,tokenIdx}], tokenIdxAll:[...], tokenIdxStage:Map(stage->[idx]) }
    let sel = {}
    let produced = [] //各 token 是否有產出
    let pick = (lang, c) => {
        sel[lang] = sel[lang] || {}
        sel[lang][c.name] = sel[lang][c.name] || { all: false, allTokens: [], stages: new Map(), filters: [] }
        return sel[lang][c.name]
    }
    let available = () => {
        let ks = []
        for (let c of decls) {
            ks.push(`  · ${c.compareOnly ? `${c.name}(只比對)` : c.name}`)
            for (let s of (c.stages || [])) {
                if (s !== c.name) {
                    ks.push(`      ‧ ${s}`)
                }
            }
        }
        return ks.join('\n')
    }

    if (tokens) {
        tokens.forEach((t, ti) => {
            produced[ti] = false
            //語系前綴
            let scope = null
            let rest = t
            for (let l of langs) {
                if (t.startsWith(`${l}-`)) {
                    scope = [l]
                    rest = t.slice(l.length + 1)
                    break
                }
            }
            if (scope && !selLangs.includes(scope[0])) {
                throw new Error(`--names 之「${t}」限定語系 ${scope[0]},與 --langs 衝突`)
            }
            let tLangs = scope || selLangs
            let hit = false
            for (let lang of tLangs) {
                let cs = decls.filter((c) => caseLangs(c).includes(lang))
                //①完全等於已宣告之階段圖鍵: 只比對之案例宣告之是「共用他案之圖鍵」, 不參與寫檔解析(由產出該圖之案例負責);
                //只命中只比對之案例者拋錯
                let exactAll = cs.filter((c) => (c.stages || []).includes(rest))
                let exactStage = exactAll.filter((c) => !c.compareOnly)
                if (exactStage.length > 1) {
                    throw new Error(`--names 之「${t}」同時是多個案例之階段圖鍵: ${exactStage.map((c) => c.name).join(', ')}`)
                }
                if (exactStage.length === 0 && exactAll.length > 0) {
                    throw new Error(`--names 之「${t}」屬只比對之案例 ${exactAll.map((c) => c.name).join(', ')},不產圖`)
                }
                if (exactStage.length === 1) {
                    let c = exactStage[0]
                    let s = pick(lang, c)
                    s.stages.set(rest, [...(s.stages.get(rest) || []), ti])
                    hit = true
                    continue
                }
                //②案例鍵(完全或邊界前綴)
                let byCase = cs.filter((c) => boundary(c.name, rest))
                if (byCase.length > 0) {
                    let co = byCase.filter((c) => c.compareOnly)
                    if (co.length > 0) {
                        throw new Error(`--names 之「${t}」命中只比對之案例 ${co.map((c) => c.name).join(', ')},不產圖(共用他案標準圖)`)
                    }
                    for (let c of byCase) {
                        let s = pick(lang, c)
                        s.all = true
                        s.allTokens.push(ti)
                    }
                    hit = true
                    continue
                }
                //③已宣告階段圖鍵之邊界前綴(只比對之案例之共用圖鍵同①不參與; 只命中只比對之案例者拋錯)
                let prefixAll = []
                for (let c of cs) {
                    for (let st of (c.stages || [])) {
                        if (boundary(st, rest)) {
                            prefixAll.push([c, st])
                        }
                    }
                }
                let byStagePrefix = prefixAll.filter(([c]) => !c.compareOnly)
                if (byStagePrefix.length === 0 && prefixAll.length > 0) {
                    throw new Error(`--names 之「${t}」屬只比對之案例 ${[...new Set(prefixAll.map(([c]) => c.name))].join(', ')},不產圖`)
                }
                if (byStagePrefix.length > 0) {
                    for (let [c, st] of byStagePrefix) {
                        let s = pick(lang, c)
                        s.stages.set(st, [...(s.stages.get(st) || []), ti])
                    }
                    hit = true
                    continue
                }
                //④未宣告 stages 之案例, 以編號相符執行, 執行後驗證有產出
                let id = idOf(rest)
                let byId = id ? cs.filter((c) => !c.stages && idOf(c.name) === id) : []
                if (byId.length > 0) {
                    for (let c of byId) {
                        if (c.compareOnly) {
                            throw new Error(`--names 之「${t}」屬只比對之案例 ${c.name},不產圖`)
                        }
                        let s = pick(lang, c)
                        s.filters.push({ fn: (k) => boundary(k, rest), ti })
                    }
                    hit = true
                    continue
                }
            }
            if (!hit) {
                throw new Error(`--names 之「${t}」不符合任何案例或階段圖鍵;可用之鍵:\n${available()}`)
            }
        })
    }

    function casesFor(lang) {
        if (!selLangs.includes(lang)) {
            return []
        }
        if (!filtered) {
            //含只比對案例: 產製端照跑流程與語意斷言, 由 runBaselineCase 不寫圖(技能 §7.9「產製時照跑流程與語意斷言」)
            return decls.filter((c) => caseLangs(c).includes(lang))
        }
        let s = sel[lang] || {}
        return decls.filter((c) => s[c.name])
    }

    //回傳命中此圖之 token 索引陣列(未篩選時回 null 代表全寫)
    function matchTokens(lang, caseName, stageKey) {
        let s = sel[lang] && sel[lang][caseName]
        if (!s) {
            return []
        }
        let idx = []
        if (s.all) {
            idx.push(...s.allTokens)
        }
        if (s.stages.has(stageKey)) {
            idx.push(...s.stages.get(stageKey))
        }
        for (let f of s.filters) {
            if (f.fn(stageKey)) {
                idx.push(f.ti)
            }
        }
        return idx
    }

    function shouldWrite(lang, caseName, stageKey) {
        if (!selLangs.includes(lang)) {
            return false
        }
        if (!filtered) {
            return true
        }
        return matchTokens(lang, caseName, stageKey).length > 0
    }

    function noteProduced(lang, caseName, stageKey) {
        if (!filtered) {
            return
        }
        for (let ti of matchTokens(lang, caseName, stageKey)) {
            produced[ti] = true
        }
    }

    //依 write-mode 決定是否寫出(比對對象為正式標準圖路徑)
    function decideWrite(buf, baselinePath) {
        if (writeMode === 'all') {
            return { write: true, reason: 'all' }
        }
        if (!fs.existsSync(baselinePath)) {
            return { write: true, reason: 'missing' }
        }
        if (writeMode === 'missing') {
            return { write: false, reason: 'exists' }
        }
        let a = PNG.sync.read(buf)
        let b = PNG.sync.read(fs.readFileSync(baselinePath))
        if (a.width !== b.width || a.height !== b.height) {
            return { write: true, reason: 'sizeChanged' }
        }
        let n = a.data.equals(b.data) ? 0 : pixelmatch(a.data, b.data, null, a.width, a.height, { threshold, includeAA: false })
        if (n > maxDiffPixels) {
            return { write: true, reason: `diff=${n}px` }
        }
        return { write: false, reason: `withinTolerance(diff=${n}px)` }
    }

    function outPath(baselinePath) {
        return outDir ? path.join(outDir, path.basename(baselinePath)) : baselinePath
    }

    //由 runBaselineCase 告知標準圖路徑函數(建立篩選器時未給 pathOf 者)
    function usePathOf(fn) {
        if (!pathOf && typeof fn === 'function') {
            pathOf = fn
        }
    }

    //孤兒標準圖(靜態比對宣告, 見 findOrphanBaselines); 無 pathOf 時回 []
    function orphans() {
        if (!pathOf) {
            return []
        }
        return findOrphanBaselines({ cases: decls, langs, pathOf, idPattern })
    }

    //全部案例執行完後呼叫: 任何 --names 項目未產出即拋錯(不靜默略過); 標準圖目錄有孤兒圖亦拋錯(列出供確認後刪除)
    function finalize() {
        let problems = []
        if (filtered) {
            let miss = tokens.filter((t, i) => !produced[i])
            if (miss.length > 0) {
                problems.push(`--names 之下列項目執行後未產出任何圖(圖鍵不符或案例未執行): ${miss.join(', ')}`)
            }
        }
        let orph = orphans()
        if (orph.length > 0) {
            problems.push(`標準圖目錄有 ${orph.length} 張孤兒圖(不是任何案例之宣告圖鍵; 多為改名後之舊圖, 確認後刪除, git 追蹤檔可 git restore 復原): ${orph.join(', ')}`)
        }
        if (problems.length > 0) {
            throw new Error(problems.join('\n'))
        }
    }

    function describe() {
        let lines = [`[baseline-gate] langs=${selLangs.join(',')} writeMode=${writeMode}${outDir ? ` outDir=${outDir}` : ''}${filtered ? '' : ' (全部案例)'}`]
        if (filtered) {
            for (let lang of selLangs) {
                for (let c of casesFor(lang)) {
                    let s = sel[lang][c.name]
                    let what = s.all ? '全部階段' : [...s.stages.keys(), ...s.filters.map(() => '(依編號前綴)')].join(', ')
                    lines.push(`  ${lang}-${c.name} → ${what}`)
                }
            }
        }
        return lines.join('\n')
    }

    return {
        filtered,
        langs: selLangs,
        writeMode,
        outDir,
        casesFor,
        shouldWrite,
        noteProduced,
        decideWrite,
        outPath,
        usePathOf,
        orphans,
        finalize,
        describe,
    }
}


export default createBaselineGate