'use strict'; const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const vm = require('vm'); const mainPath = process.argv[2] || path.resolve(__dirname, '../src/timecard-main.actions.js'); const repairPath = path.resolve(__dirname, '../src/timecard-repair.oyd.jsx'); const evidence = process.argv[3] || '/Users/malk/Desktop/Tech/1. malk/宜搭/阿科德斯/TimeCard/project/.cache/openyida/overtime-evaluation-20261023/样本.json'; /** @param {string} source @param {string} name @returns {string} */ function extract(source, name) { const found = new RegExp('export function ' + name + '\\([^]*?\\n}').exec(source); assert.ok(found, '缺少 ' + name); return found[0].replace('export ', ''); } /** @param {string} file @returns {object} */ function methods(file) { const source = fs.readFileSync(file, 'utf8'); const names = ['resolveHourValue', 'getDayTotal', 'isWeekend', 'isDayOff', 'getOvertimeDisplayTotals', 'resolveOvertimeRecord', 'buildOvertimeBaseline', 'getOvertimeDayTotal', 'validateMonthlyOvertime', 'processTimecardRecords']; if (source.includes('export function getOvertimeChangeAdjustment(')) names.push('getOvertimeChangeAdjustment'); const fields = {draft:'draft',submitted:'submitted',approved:'approved',change:'change',projectCode:'key'}; const context = {VALIDATION:{maxHoursPerDay:8,maxOvertimePerMonth:36}, BILLABLE_SUB:{...fields,table:'billable'}, NON_BILLABLE_SUB:{...fields,table:'nonBillable'}, OTHER_SUB:{...fields,table:'other',projectCode:undefined,activity:'key'},TIME_FIELD_IDS:{dayText:'day'}}; const snapshot = JSON.parse(fs.readFileSync(evidence,'utf8')); context.OTHER_CATEGORIES = [...new Set(snapshot.people.flatMap(p=>p.originalOctober.flatMap(r=> r.lines.filter(x=>x.section==='other').map(x=>x.key))))].map(name=>({name})); vm.createContext(context); vm.runInContext(names.map(n=>extract(source,n)).join('\n'), context); return context; } /** @param {object} row @param {string} source @returns {object} */ function adapt(row, source) { const data = {day:row.day,radioField_rtc81we6k:row.fillType}; for (const section of ['billable','nonBillable','other']) data[section] = row.lines.filter(x=>x.section===section); return {formData:data,_timecardRecordSource:source}; } /** @param {object} lib @param {Array} originals @param {Array} repairs @param {string} month @returns {object} */ function setup(lib, originals, repairs, month='202610') { const rows = originals.map(x=>adapt(x,'original')); const state = {selectedYear:+month.slice(0,4),selectedMonth:+month.slice(4),isExternalStaff:false, billableData:{},nonBillableData:{},otherData:{},holidayMap:{}}; for (const r of originals) for (const line of r.lines) { const key = line.section + 'Data'; state[key][line.key] ||= {}; state[key][line.key][+r.day.slice(-2)] = lib.resolveHourValue(+line.approved||0,+line.submitted||0,+line.draft||0,+line.change||0,true).value; } const env = {...lib,state,isBeforeEmploymentDate:()=>false,getTexts:()=>({overtimeExceeded:n=>String(n)}), utils:{toast:()=>{}},showTimecardError:()=>{}}; state.overtimeDisplayDayTotals = env.getOvertimeDisplayTotals(state.billableData,state.nonBillableData,state.otherData); state.overtimeBaseDayTotals = env.buildOvertimeBaseline(rows.concat(repairs.map(x=>adapt(x,'repair'))), state.selectedYear,state.selectedMonth,state.overtimeDisplayDayTotals,false); return env; } /** @returns {Promise} */ async function main() { const snapshot = JSON.parse(fs.readFileSync(evidence,'utf8')); const outcomes = []; for (const file of [mainPath,repairPath]) { const lib = methods(file); const values = []; for (const p of snapshot.people) { const env = setup(lib,p.originalOctober,p.repairAll.filter(r=>r.day.startsWith('202610'))); snapshot.holidays.filter(x=>x.day.startsWith('202610')).forEach(x=>{env.state.holidayMap[+x.day.slice(-2)]=x.type;}); let total = 0; for(let d=1;d<=31;d++) total += env.isDayOff(2026,10,d) ? env.getOvertimeDayTotal(d) : Math.max(0,env.getOvertimeDayTotal(d)-8); values.push(total); assert.equal(env.validateMonthlyOvertime(),p.name!=='周璐'); const integrated = {...lib,state:{selectedYear:2026,selectedMonth:10}, getDayApprovalLock:()=>'',applyDefaultWorkdayHours:()=>({}), isBeforeEmploymentDate:()=>false,getTexts:()=>({overtimeExceeded:n=>String(n)}), utils:{toast:()=>{}},showTimecardError:()=>{}}; integrated.setState = update=>Object.assign(integrated.state,update); const records = p.originalOctober.map(r=>adapt(r,'original')); const projects = [...new Set(p.originalOctober.flatMap(r=>r.lines.filter(x=>x.section!=='other').map(x=>x.key)))].map(code=>({code,name:code})); integrated.processTimecardRecords(records,2026,10,env.state.holidayMap,projects,false,false,false); assert.equal(integrated.validateMonthlyOvertime(),p.name!=='周璐','完整processTimecardRecords链路回放'); if(p.name==='王赏') { env.state.billableData.extra={11:4}; assert.equal(env.getOvertimeDayTotal(11),4,'未保存周日4h必须参与校验'); } } assert.deepEqual(values,[40,0,0]); const env = setup(lib,[],[]); const row = {day:'20261022',fillType:'',lines:[{section:'billable',key:'P',draft:8,approved:0,submitted:0,change:168}]}; const restored = setup(lib,[row],[]); assert.equal(restored.getOvertimeDayTotal(22),8); restored.state.billableData.P[22]=172; assert.equal(restored.getOvertimeDayTotal(22),12,'新增输入必须在还原基础上计算'); const repair = structuredClone(row); repair.fillType='补填';repair.lines[0].approved=8;repair.lines[0].change=0; assert.equal(env.buildOvertimeBaseline([adapt(row,'original'),adapt(repair,'repair')],2026,10,{},false)[22],8); repair.lines[0].key='另一项目'; assert.equal(env.buildOvertimeBaseline([adapt(row,'original'),adapt(repair,'repair')],2026,10,{},false)[22],8); repair.fillType='变更';repair.lines[0].approved=168; assert.equal(Object.keys(env.buildOvertimeBaseline([adapt(repair,'repair')],2026,10,{},false)).length,0); row.lines[0].change=-168; assert.equal(setup(lib,[row],[]).getOvertimeDayTotal(22),8,'负变更同样排除'); assert.throws(()=>env.buildOvertimeBaseline([adapt(row,'original'),adapt(row,'original')],2026,10,{},false),/重复/); row.lines[0].submitted=8;repair.fillType='补填';repair.lines[0].approved=8; assert.throws(()=>env.buildOvertimeBaseline([adapt(row,'original'),adapt(repair,'repair')],2026,10,{},false),/均已提交/); for(const hours of [36,36.5]) { const boundary = setup(lib,[],[]); boundary.state.billableData.P={11:hours/2,18:hours/2}; assert.equal(boundary.validateMonthlyOvertime(),hours===36); } outcomes.push({file:path.basename(file),hours:values}); } const source = fs.readFileSync(repairPath,'utf8'); const scope = {FORM_IDS:{timeSummary:'original',repairSummary:'repair'},TIME_FIELD_IDS:{submitter:'user',monthText:'month'}, REPAIR_FILL_TYPE_FIELD:'radioField_rtc81we6k'}; vm.createContext(scope); vm.runInContext(['loadTimecardData','prepareTimecardRecords'].map(n=>extract(source,n)).join('\n'),scope); const calls=[]; const env={...scope,state:{isExternalStaff:true},getRepairMonthText:()=> '202609', queryAllTimecardRecords:async (...args)=>{calls.push(args[4]);return[];}}; await env.loadTimecardData('external',2026,9); assert.deepEqual(calls,['original','repair'],'外部漏填必须核对正式与补填两表'); calls.length=0; const original = await env.loadTimecardData('external',2026,9,{staffType:'外部',summaryReadMode:'original-and-repair',originalRecords:[{formData:{}}],repairRecords:[]}); assert.equal(calls.length,0); assert.equal(original.length,1,'外部已提交正式工时不得丢弃'); env.state.isExternalStaff=false; await env.loadTimecardData('internal',2026,9); assert.deepEqual(calls,['original','repair']); const change={formData:{radioField_rtc81we6k:'变更'}}; assert.equal(env.prepareTimecardRecords([], [change]).length,0); console.log(JSON.stringify({status:'passed',outcomes,externalFallback:'two-tables',internalFallback:'two-tables'})); } main().catch(error=>{console.error(error);process.exitCode=1;});