243 lines
12 KiB
JavaScript
243 lines
12 KiB
JavaScript
const fs = require('fs')
|
||
const path = require('path')
|
||
const JSZip = require('jszip')
|
||
const { DOMParser } = require('@xmldom/xmldom')
|
||
const { PNG } = require('pngjs')
|
||
|
||
const root = path.resolve(__dirname, '..', '..')
|
||
const docxPath = process.env.MES_MANUAL_DOCX
|
||
? path.resolve(process.env.MES_MANUAL_DOCX)
|
||
: path.resolve(root, 'doc', '大连元利流体技术有限公司MES系统用户操作手册_V1.1_2026-07-23.docx')
|
||
const catalog = JSON.parse(fs.readFileSync(path.resolve(__dirname, 'full-page-catalog.json'), 'utf8'))
|
||
const dialogAudit = JSON.parse(fs.readFileSync(path.resolve(__dirname, 'manual-dialog-audit.json'), 'utf8'))
|
||
const screenshotsDir = path.resolve(__dirname, 'full-screenshots')
|
||
|
||
function localName(node) {
|
||
return node && (node.localName || String(node.nodeName || '').split(':').pop())
|
||
}
|
||
|
||
function elementChildren(node, name) {
|
||
if (!node || !node.childNodes) return []
|
||
return [...node.childNodes].filter(child => child.nodeType === 1 && (!name || localName(child) === name))
|
||
}
|
||
|
||
function descendants(node, name, output = []) {
|
||
for (const child of elementChildren(node)) {
|
||
if (!name || localName(child) === name) output.push(child)
|
||
descendants(child, name, output)
|
||
}
|
||
return output
|
||
}
|
||
|
||
function textOf(node) {
|
||
return descendants(node, 't').map(item => item.textContent || '').join('').trim()
|
||
}
|
||
|
||
function firstDescendant(node, name) {
|
||
return descendants(node, name, [])[0] || null
|
||
}
|
||
|
||
function fail(message) {
|
||
throw new Error(message)
|
||
}
|
||
|
||
function imagesMatch(actual, expected) {
|
||
if (actual.equals(expected)) return true
|
||
const actualPng = PNG.sync.read(actual)
|
||
const expectedPng = PNG.sync.read(expected)
|
||
if (actualPng.width === expectedPng.width && actualPng.height === expectedPng.height) {
|
||
return actualPng.data.equals(expectedPng.data)
|
||
}
|
||
const actualRatio = actualPng.width / actualPng.height
|
||
const expectedRatio = expectedPng.width / expectedPng.height
|
||
if (Math.abs(actualRatio - expectedRatio) > 0.002) return false
|
||
|
||
// WPS downsamples embedded PNGs to their displayed size when saving. Compare
|
||
// sampled RGB values against a bilinear resize of the captured source image.
|
||
let difference = 0
|
||
let samples = 0
|
||
const stride = 2
|
||
for (let y = 0; y < actualPng.height; y += stride) {
|
||
const sourceY = (y + 0.5) * expectedPng.height / actualPng.height - 0.5
|
||
const y0 = Math.max(0, Math.floor(sourceY))
|
||
const y1 = Math.min(expectedPng.height - 1, y0 + 1)
|
||
const fractionY = sourceY - y0
|
||
for (let x = 0; x < actualPng.width; x += stride) {
|
||
const sourceX = (x + 0.5) * expectedPng.width / actualPng.width - 0.5
|
||
const x0 = Math.max(0, Math.floor(sourceX))
|
||
const x1 = Math.min(expectedPng.width - 1, x0 + 1)
|
||
const fractionX = sourceX - x0
|
||
for (let channel = 0; channel < 3; channel++) {
|
||
const topLeft = expectedPng.data[(y0 * expectedPng.width + x0) * 4 + channel]
|
||
const topRight = expectedPng.data[(y0 * expectedPng.width + x1) * 4 + channel]
|
||
const bottomLeft = expectedPng.data[(y1 * expectedPng.width + x0) * 4 + channel]
|
||
const bottomRight = expectedPng.data[(y1 * expectedPng.width + x1) * 4 + channel]
|
||
const expectedValue = (topLeft * (1 - fractionX) + topRight * fractionX) * (1 - fractionY) +
|
||
(bottomLeft * (1 - fractionX) + bottomRight * fractionX) * fractionY
|
||
difference += Math.abs(actualPng.data[(y * actualPng.width + x) * 4 + channel] - expectedValue)
|
||
samples++
|
||
}
|
||
}
|
||
}
|
||
return difference / samples < 2
|
||
}
|
||
|
||
function main() {
|
||
const zip = new JSZip(fs.readFileSync(docxPath))
|
||
const parser = new DOMParser()
|
||
const document = parser.parseFromString(zip.file('word/document.xml').asText(), 'application/xml')
|
||
const relationships = parser.parseFromString(zip.file('word/_rels/document.xml.rels').asText(), 'application/xml')
|
||
const body = firstDescendant(document.documentElement, 'body')
|
||
const bodyChildren = elementChildren(body)
|
||
const fullText = textOf(document.documentElement)
|
||
|
||
const relationshipTargets = new Map()
|
||
for (const relationship of elementChildren(relationships.documentElement, 'Relationship')) {
|
||
relationshipTargets.set(relationship.getAttribute('Id'), relationship.getAttribute('Target').replace(/^\.\//, ''))
|
||
}
|
||
|
||
const chapterByModule = {
|
||
'设备管理': 7,
|
||
'研发管理': 8,
|
||
'工艺管理': 9,
|
||
'计划排产': 10,
|
||
'生产管理': 11,
|
||
'异常提醒': 12,
|
||
'质量管理': 13,
|
||
'销售订单管理': 14
|
||
}
|
||
|
||
let verifiedScreenshots = 0
|
||
let verifiedActionTables = 0
|
||
for (const page of catalog) {
|
||
const section = `${chapterByModule[page.module]}.${page.pageIndex}`
|
||
const pageStart = bodyChildren.findIndex(node => localName(node) === 'p' && textOf(node) === `${section} ${page.title}`)
|
||
if (pageStart < 0) fail(`页面标题缺失:${section} ${page.title}`)
|
||
let pageEnd = bodyChildren.findIndex((node, index) => index > pageStart && localName(node) === 'p' && /^\d+\.\d+ /.test(textOf(node)))
|
||
if (pageEnd < 0) pageEnd = bodyChildren.length
|
||
const imageParagraph = bodyChildren.slice(pageStart, pageEnd).find(node => localName(node) === 'p' && firstDescendant(node, 'blip'))
|
||
if (!imageParagraph) fail(`页面截图缺失:${section} ${page.title}`)
|
||
const blip = firstDescendant(imageParagraph, 'blip')
|
||
const relationshipId = blip.getAttribute('r:embed') || blip.getAttributeNS('http://schemas.openxmlformats.org/officeDocument/2006/relationships', 'embed')
|
||
const target = relationshipTargets.get(relationshipId)
|
||
if (!target || !zip.file(`word/${target}`)) fail(`页面截图关系缺失:${section} ${page.title}`)
|
||
const embedded = zip.file(`word/${target}`).asNodeBuffer()
|
||
const expected = fs.readFileSync(path.resolve(screenshotsDir, page.screenshot))
|
||
if (!imagesMatch(embedded, expected)) fail(`页面截图未更新:${section} ${page.title}(${target})`)
|
||
verifiedScreenshots++
|
||
|
||
const actionHeadingIndex = bodyChildren.findIndex((node, index) =>
|
||
index > pageStart && index < pageEnd && localName(node) === 'p' && textOf(node) === `${section}.3 功能按钮与操作`
|
||
)
|
||
const actionTable = actionHeadingIndex >= 0
|
||
? bodyChildren.slice(actionHeadingIndex + 1, pageEnd).find(node => localName(node) === 'tbl')
|
||
: null
|
||
const actionHeaders = actionTable ? elementChildren(elementChildren(actionTable, 'tr')[0], 'tc').map(textOf) : []
|
||
if (actionHeaders.join('|') !== '操作入口|执行结果|业务影响与注意事项') {
|
||
const subsectionHeadings = bodyChildren.slice(pageStart, pageEnd)
|
||
.filter(node => localName(node) === 'p' && textOf(node).startsWith(`${section}.`))
|
||
.map(textOf)
|
||
fail(`操作结果与影响表缺失:${section} ${page.title};子节:${subsectionHeadings.join(' | ')}`)
|
||
}
|
||
verifiedActionTables++
|
||
}
|
||
|
||
let verifiedDialogPages = 0
|
||
for (const item of dialogAudit.filter(page => page.dialogs.length)) {
|
||
const page = catalog.find(entry => entry.module === item.module && entry.title === item.title)
|
||
if (!page) fail(`弹窗审计页未纳入目录:${item.module}/${item.title}`)
|
||
const section = `${chapterByModule[item.module]}.${page.pageIndex}`
|
||
const pageStart = bodyChildren.findIndex(node => localName(node) === 'p' && textOf(node) === `${section} ${item.title}`)
|
||
let pageEnd = bodyChildren.findIndex((node, index) => index > pageStart && localName(node) === 'p' && /^\d+\.\d+ /.test(textOf(node)))
|
||
if (pageEnd < 0) pageEnd = bodyChildren.length
|
||
const pageText = bodyChildren.slice(pageStart, pageEnd).map(textOf).join('\n')
|
||
const hasDialogCoverage = pageText.includes('弹窗') || (section === '9.3' && pageText.includes('编辑生产工艺与保存时机'))
|
||
if (!hasDialogCoverage) fail(`弹窗说明缺失:${section} ${item.title}`)
|
||
verifiedDialogPages++
|
||
}
|
||
|
||
const requiredPhrases = [
|
||
'9.3.4 编辑生产工艺与保存时机',
|
||
'左侧工序列表',
|
||
'上移/下移',
|
||
'保存为标准工艺',
|
||
'替换该物料原有的标准工艺路线',
|
||
'不会自动生成生产任务',
|
||
'下发任务(单工序行)',
|
||
'操作入口',
|
||
'执行结果',
|
||
'业务影响与注意事项',
|
||
'10.3.4 可编辑字段与编辑弹窗',
|
||
'计划开始',
|
||
'计划完成',
|
||
'外协分组',
|
||
'修改后立即保存',
|
||
'编辑备注信息弹窗',
|
||
'上游业务数据',
|
||
'本页维护结果',
|
||
'下游业务影响'
|
||
]
|
||
for (const phrase of requiredPhrases) if (!fullText.includes(phrase)) fail(`关键内容缺失:${phrase}`)
|
||
|
||
const forbiddenPhrases = [
|
||
'页面路由',
|
||
'只读查询过程/接口',
|
||
'写入过程/接口',
|
||
'技术核对说明',
|
||
'本文档编制未实际执行',
|
||
'未产生数据',
|
||
'不产生数据',
|
||
'正式环境只读截图',
|
||
'后台过程',
|
||
'存储过程',
|
||
'核对内容后使用弹窗中的',
|
||
'保存提交'
|
||
]
|
||
const presentForbiddenPhrases = forbiddenPhrases.filter(phrase => fullText.includes(phrase))
|
||
if (presentForbiddenPhrases.length) fail(`仍含不应出现的措辞:${presentForbiddenPhrases.join('、')}`)
|
||
|
||
const implementationNames = [...new Set(catalog.flatMap(page => [
|
||
page.route,
|
||
...(page.readProcedures || []),
|
||
...(page.writeProcedures || [])
|
||
]).filter(Boolean))]
|
||
const leakedNames = implementationNames.filter(name => fullText.includes(name))
|
||
if (leakedNames.length) fail(`仍含内部路由或过程名称:${leakedNames.join('、')}`)
|
||
|
||
const processPopupCaptionIndex = bodyChildren.findIndex(node => localName(node) === 'p' && /^图 9\.3-2/.test(textOf(node)))
|
||
const processPopupImageParagraph = bodyChildren.slice(0, processPopupCaptionIndex).reverse()
|
||
.find(node => localName(node) === 'p' && firstDescendant(node, 'blip'))
|
||
if (processPopupCaptionIndex < 0 || !processPopupImageParagraph) fail('9.3 编辑生产工艺弹窗截图未嵌入文档')
|
||
const processPopupBlip = firstDescendant(processPopupImageParagraph, 'blip')
|
||
const processPopupRelationshipId = processPopupBlip.getAttribute('r:embed') || processPopupBlip.getAttributeNS('http://schemas.openxmlformats.org/officeDocument/2006/relationships', 'embed')
|
||
const processPopupTarget = relationshipTargets.get(processPopupRelationshipId)
|
||
const processPopupEntry = processPopupTarget && zip.file(`word/${processPopupTarget}`)
|
||
const processPopupExpected = fs.readFileSync(path.resolve(screenshotsDir, '03-03-process-edit-dialog.png'))
|
||
if (!processPopupEntry || !imagesMatch(processPopupEntry.asNodeBuffer(), processPopupExpected)) fail('9.3 编辑生产工艺弹窗截图内容不正确')
|
||
|
||
const popupCaptionIndex = bodyChildren.findIndex(node => localName(node) === 'p' && /^图 10\.3-2/.test(textOf(node)))
|
||
const popupImageParagraph = bodyChildren.slice(0, popupCaptionIndex).reverse().find(node => localName(node) === 'p' && firstDescendant(node, 'blip'))
|
||
if (popupCaptionIndex < 0 || !popupImageParagraph) fail('10.3 编辑弹窗截图未嵌入文档')
|
||
const popupBlip = firstDescendant(popupImageParagraph, 'blip')
|
||
const popupRelationshipId = popupBlip.getAttribute('r:embed') || popupBlip.getAttributeNS('http://schemas.openxmlformats.org/officeDocument/2006/relationships', 'embed')
|
||
const popupTarget = relationshipTargets.get(popupRelationshipId)
|
||
const popupEntry = popupTarget && zip.file(`word/${popupTarget}`)
|
||
const popupExpected = fs.readFileSync(path.resolve(screenshotsDir, '04-03-plan-edit-dialog.png'))
|
||
if (!popupEntry || !imagesMatch(popupEntry.asNodeBuffer(), popupExpected)) fail('10.3 编辑弹窗截图内容不正确')
|
||
|
||
console.log(`Verified screenshots: ${verifiedScreenshots}`)
|
||
console.log(`Verified action tables: ${verifiedActionTables}`)
|
||
console.log(`Verified dialog pages: ${verifiedDialogPages}`)
|
||
console.log('Forbidden wording: 0')
|
||
console.log('Internal routes/procedures: 0')
|
||
console.log('Section 9.3: complete')
|
||
console.log('Section 10.3: complete')
|
||
}
|
||
|
||
try {
|
||
main()
|
||
} catch (error) {
|
||
console.error(error.stack || error.message)
|
||
process.exitCode = 1
|
||
}
|