779 lines
26 KiB
TypeScript
779 lines
26 KiB
TypeScript
import { expect, test } from '@playwright/test'
|
||
import fs from 'node:fs'
|
||
import path from 'node:path'
|
||
import {
|
||
buildExplodeOffsets,
|
||
createBomCsv,
|
||
createEngineeringSheetHtml,
|
||
createSnapshotDataUrl,
|
||
detectPanelHoles,
|
||
filterManufacturingBodies,
|
||
getOverallBounds,
|
||
groupBodiesIntoBom,
|
||
localizePartName,
|
||
parseManufacturingLengthInMillimeters,
|
||
reconcileManufacturingDimensions,
|
||
type BodyMeasurement,
|
||
type CircularEdgeSample,
|
||
type ManufacturingLengthParameter,
|
||
} from '../../vendor/zoo-modeling-app/manufacturing'
|
||
|
||
const bodies: BodyMeasurement[] = [
|
||
{
|
||
id: 'left-leg',
|
||
name: 'leg001',
|
||
center: { x: -50, y: 0, z: 20 },
|
||
dimensions: { x: 10, y: 10, z: 80 },
|
||
volume: 8_000,
|
||
surfaceArea: 3_400,
|
||
},
|
||
{
|
||
id: 'right-leg',
|
||
name: 'leg002',
|
||
center: { x: 50, y: 0, z: 20 },
|
||
dimensions: { x: 10, y: 10, z: 80 },
|
||
volume: 8_000,
|
||
surfaceArea: 3_400,
|
||
},
|
||
{
|
||
id: 'top',
|
||
name: 'tableTop',
|
||
center: { x: 0, y: 0, z: 65 },
|
||
dimensions: { x: 120, y: 60, z: 12 },
|
||
volume: 86_400,
|
||
surfaceArea: 18_720,
|
||
},
|
||
]
|
||
|
||
function circularEdge(
|
||
centerX: number,
|
||
centerY: number,
|
||
z: number,
|
||
radius: number
|
||
): CircularEdgeSample {
|
||
return {
|
||
controlPoints: [
|
||
{ x: centerX + radius, y: centerY, z },
|
||
{ x: centerX, y: centerY + radius, z },
|
||
{ x: centerX - radius, y: centerY, z },
|
||
{ x: centerX, y: centerY - radius, z },
|
||
],
|
||
length: Math.PI * radius * 2,
|
||
}
|
||
}
|
||
|
||
function hardwareAssemblyFixture() {
|
||
const holeCenters = [
|
||
[-160, -100],
|
||
[160, -100],
|
||
[-160, 100],
|
||
[160, 100],
|
||
] as const
|
||
const panel: BodyMeasurement = {
|
||
id: 'mounting-panel',
|
||
name: 'mountingPanel',
|
||
center: { x: 0, y: 0, z: 9 },
|
||
dimensions: { x: 400, y: 300, z: 18 },
|
||
holes: detectPanelHoles(
|
||
{
|
||
id: 'mounting-panel',
|
||
name: 'mountingPanel',
|
||
center: { x: 0, y: 0, z: 9 },
|
||
dimensions: { x: 400, y: 300, z: 18 },
|
||
},
|
||
holeCenters.flatMap(([x, y]) => [
|
||
circularEdge(x, y, 0, 4),
|
||
circularEdge(x, y, 18, 4),
|
||
])
|
||
),
|
||
}
|
||
const plate: BodyMeasurement = {
|
||
id: 'connector-plate',
|
||
name: 'connectorPlate',
|
||
center: { x: 0, y: 0, z: 19.5 },
|
||
dimensions: { x: 360, y: 260, z: 3 },
|
||
holes: detectPanelHoles(
|
||
{
|
||
id: 'connector-plate',
|
||
name: 'connectorPlate',
|
||
center: { x: 0, y: 0, z: 19.5 },
|
||
dimensions: { x: 360, y: 260, z: 3 },
|
||
},
|
||
holeCenters.flatMap(([x, y]) => [
|
||
circularEdge(x, y, 18, 4),
|
||
circularEdge(x, y, 21, 4),
|
||
])
|
||
),
|
||
}
|
||
const bolts = holeCenters.map(
|
||
([x, y], index): BodyMeasurement => ({
|
||
id: `connector-bolt-${index + 1}`,
|
||
name: `connectorBolt${index + 1}`,
|
||
center: { x, y, z: 11 },
|
||
dimensions: { x: 8, y: 8, z: 30 },
|
||
})
|
||
)
|
||
const washers = holeCenters.map(
|
||
([x, y], index): BodyMeasurement => ({
|
||
id: `washer-${index + 1}`,
|
||
name: `washer${index + 1}`,
|
||
center: { x, y, z: 22 },
|
||
dimensions: { x: 18, y: 18, z: 2 },
|
||
holes: [
|
||
{
|
||
center: { x, y, z: 22 },
|
||
diameter: 8,
|
||
axis: 'z',
|
||
type: 'through-hole',
|
||
depth: 2,
|
||
},
|
||
],
|
||
})
|
||
)
|
||
|
||
return [panel, plate, ...bolts, ...washers]
|
||
}
|
||
|
||
test.describe('HiCAD manufacturing extension', () => {
|
||
test('keeps the extension isolated behind one official registration point', () => {
|
||
const projectRoot = process.cwd()
|
||
const vendorScript = fs.readFileSync(
|
||
path.join(projectRoot, 'scripts/vendor-zoo-modeling-app.mjs'),
|
||
'utf8'
|
||
)
|
||
|
||
expect(vendorScript).toContain(
|
||
'process.env.HICAD_MANUFACTURING_EXTENSION === "1"'
|
||
)
|
||
expect(vendorScript).toContain(
|
||
'vendor/zoo-modeling-app/HicadManufacturingLauncher.tsx'
|
||
.split('/')
|
||
.at(-1)
|
||
)
|
||
expect(vendorScript).toContain(
|
||
"import { HicadManufacturingSlot } from '@src/components/HicadManufacturingSlot'"
|
||
)
|
||
expect(vendorScript).toContain(
|
||
"<HicadManufacturingSlot projectName={projectName ?? 'HiCAD 模型'} />"
|
||
)
|
||
const slot = fs.readFileSync(
|
||
path.join(
|
||
projectRoot,
|
||
'vendor/zoo-modeling-app/HicadManufacturingSlot.tsx.template'
|
||
),
|
||
'utf8'
|
||
)
|
||
expect(slot).toContain('lazy(() =>')
|
||
expect(slot).toContain('ManufacturingSlotBoundary')
|
||
const launcher = fs.readFileSync(
|
||
path.join(
|
||
projectRoot,
|
||
'vendor/zoo-modeling-app/HicadManufacturingLauncher.tsx.template'
|
||
),
|
||
'utf8'
|
||
)
|
||
expect(launcher).toContain("document.querySelectorAll('canvas')")
|
||
expect(launcher).toContain('left: Math.round(canvas.rect.left + 16)')
|
||
expect(launcher).toContain('className="fixed z-20')
|
||
expect(launcher).toContain("lazy(() =>")
|
||
expect(launcher).toContain('ManufacturingBoundary')
|
||
|
||
const panel = fs.readFileSync(
|
||
path.join(
|
||
projectRoot,
|
||
'vendor/zoo-modeling-app/HicadManufacturingPanel.tsx.template'
|
||
),
|
||
'utf8'
|
||
)
|
||
expect(panel).toContain("type: 'scene_get_entity_ids'")
|
||
expect(panel).toContain("filter: ['solid3d']")
|
||
expect(panel).toContain("readBoundingBox([])")
|
||
expect(panel).toContain('engineFailureMessage(response)')
|
||
})
|
||
|
||
test('creates non-zero reversible offsets for every independent body', () => {
|
||
const offsets = buildExplodeOffsets(bodies)
|
||
|
||
expect(offsets).toHaveLength(3)
|
||
expect(new Set(offsets.map((offset) => offset.id)).size).toBe(3)
|
||
expect(
|
||
offsets.every(
|
||
({ offset }) => Math.hypot(offset.x, offset.y, offset.z) > 0
|
||
)
|
||
).toBe(true)
|
||
expect(buildExplodeOffsets([bodies[0]])).toEqual([])
|
||
})
|
||
|
||
test('converts evaluated KCL length units to millimeters', () => {
|
||
expect(parseManufacturingLengthInMillimeters('120 cm')).toBe(1200)
|
||
expect(parseManufacturingLengthInMillimeters('1.2m')).toBe(1200)
|
||
expect(parseManufacturingLengthInMillimeters('47.24409449 in')).toBeCloseTo(
|
||
1200,
|
||
5
|
||
)
|
||
expect(parseManufacturingLengthInMillimeters('1200')).toBeUndefined()
|
||
expect(parseManufacturingLengthInMillimeters('90 deg')).toBeUndefined()
|
||
})
|
||
|
||
test('groups matching parts and exports a UTF-8 BOM', () => {
|
||
const rows = groupBodiesIntoBom(bodies)
|
||
const legRow = rows.find((row) => row.name === '支腿')
|
||
|
||
expect(rows).toHaveLength(2)
|
||
expect(legRow?.quantity).toBe(2)
|
||
expect(legRow?.bodyIds).toEqual(['left-leg', 'right-leg'])
|
||
|
||
const csv = createBomCsv('工作台', rows)
|
||
expect(csv.startsWith('\uFEFF')).toBe(true)
|
||
expect(csv).toContain('"工作台"')
|
||
expect(csv).toContain('"生产工艺"')
|
||
})
|
||
|
||
test('filters helper solids and groups directional leg names', () => {
|
||
const stoolBodies: BodyMeasurement[] = [
|
||
{
|
||
id: 'template',
|
||
name: 'unitBox',
|
||
center: { x: 0, y: 0, z: 0 },
|
||
dimensions: { x: 1, y: 1, z: 1 },
|
||
},
|
||
{
|
||
id: 'seat',
|
||
name: 'seatBody',
|
||
center: { x: 0, y: 0, z: 310 },
|
||
dimensions: { x: 300, y: 220, z: 20 },
|
||
},
|
||
...['frontLeftLeg', 'frontRightLeg', 'backLeftLeg', 'backRightLeg'].map(
|
||
(name, index): BodyMeasurement => ({
|
||
id: `leg-${index}`,
|
||
name,
|
||
center: { x: index % 2 ? 100 : -100, y: index < 2 ? -70 : 70, z: 150 },
|
||
dimensions: { x: 30, y: 30, z: 300 },
|
||
})
|
||
),
|
||
]
|
||
|
||
const filtered = filterManufacturingBodies(stoolBodies)
|
||
const rows = groupBodiesIntoBom(filtered)
|
||
|
||
expect(filtered).toHaveLength(5)
|
||
expect(rows).toHaveLength(2)
|
||
expect(rows.find((row) => row.name === '支腿')?.quantity).toBe(4)
|
||
expect(rows.find((row) => row.name === '座板')?.quantity).toBe(1)
|
||
expect(rows.some((row) => row.name === 'unitBox')).toBe(false)
|
||
})
|
||
|
||
test('reconciles transformed Safari measurements and groups source instances', async ({
|
||
page,
|
||
}, testInfo) => {
|
||
const transformedBodies: BodyMeasurement[] = [
|
||
...[
|
||
['rearRightStudSource', 202.52],
|
||
['rearLeftStudSource', 188.56],
|
||
['frontRightStudSource', 165.4],
|
||
['frontLeftStud', 178.53],
|
||
].map(
|
||
([name, volume], index): BodyMeasurement => ({
|
||
id: `stud-${index}`,
|
||
name: String(name),
|
||
center: { x: index % 2 ? 500 : -500, y: index < 2 ? -200 : 200, z: 760 },
|
||
dimensions: { x: 6, y: 6, z: 10 },
|
||
volume: Number(volume),
|
||
})
|
||
),
|
||
...[
|
||
'rearRightLegSource',
|
||
'rearLeftLegSource',
|
||
'frontRightLegSource',
|
||
'frontLeftLeg',
|
||
].map(
|
||
(name, index): BodyMeasurement => ({
|
||
id: `leg-${index}`,
|
||
name,
|
||
center: { x: index % 2 ? 500 : -500, y: index < 2 ? -200 : 200, z: 375 },
|
||
dimensions: { x: 60, y: 60, z: 750 },
|
||
volume: 1_463_300 + index * 12,
|
||
})
|
||
),
|
||
{
|
||
id: 'tabletop',
|
||
name: 'tableTop',
|
||
center: { x: 0, y: 0, z: 762.5 },
|
||
dimensions: { x: 1440, y: 720, z: 25.1 },
|
||
volume: 17_976_779.67,
|
||
},
|
||
]
|
||
|
||
const reconciled = reconcileManufacturingDimensions(transformedBodies)
|
||
const rows = groupBodiesIntoBom(reconciled)
|
||
|
||
expect(rows).toHaveLength(3)
|
||
expect(rows.find((row) => row.name === '连接螺柱')).toMatchObject({
|
||
quantity: 4,
|
||
dimensions: { x: 5, y: 5, z: 10 },
|
||
})
|
||
expect(rows.find((row) => row.name === '支腿')).toMatchObject({
|
||
quantity: 4,
|
||
dimensions: { x: 50, y: 50, z: 750 },
|
||
})
|
||
expect(rows.find((row) => row.name === '台面板')).toMatchObject({
|
||
quantity: 1,
|
||
dimensions: { x: 1200, y: 600, z: 25 },
|
||
})
|
||
|
||
const html = createEngineeringSheetHtml({
|
||
projectName: '圆腿桌',
|
||
generatedAt: '2026-07-28 10:00',
|
||
overallDimensions: { x: 1200, y: 600, z: 775 },
|
||
bodies: reconciled,
|
||
bomRows: rows,
|
||
})
|
||
expect(html).toContain('data-cross-section="circular"')
|
||
expect(html).toContain('class="circular-part-profile"')
|
||
expect(html).toContain('data-profile="circular"')
|
||
expect(html).toContain('直径 Ø50 mm')
|
||
expect(html).toContain('Ø50 mm')
|
||
|
||
await page.setViewportSize({ width: 1440, height: 1000 })
|
||
await page.setContent(html)
|
||
await expect(page.locator('.circular-part-profile')).toHaveCount(2)
|
||
const legProfile = page
|
||
.locator('.part-detail[data-cross-section="circular"]')
|
||
.filter({ hasText: '支腿' })
|
||
.locator('.circular-part-profile')
|
||
const profileBounds = await legProfile.boundingBox()
|
||
expect(profileBounds).not.toBeNull()
|
||
expect(Math.abs((profileBounds?.width ?? 0) - (profileBounds?.height ?? 0))).toBeLessThan(
|
||
1
|
||
)
|
||
await page.screenshot({
|
||
fullPage: true,
|
||
path: testInfo.outputPath('safari-round-leg-engineering-sheet.png'),
|
||
})
|
||
})
|
||
|
||
test('reconciles mixed assembly bounds without requiring one shared scale', () => {
|
||
const mixedBrowserBodies: BodyMeasurement[] = [
|
||
...[
|
||
'rearRightStudSource',
|
||
'rearLeftStudSource',
|
||
'frontRightStudSource',
|
||
'frontLeftStudSource',
|
||
].map(
|
||
(name, index): BodyMeasurement => ({
|
||
id: `mixed-stud-${index}`,
|
||
name,
|
||
center: { x: index % 2 ? 500 : -500, y: index < 2 ? -200 : 200, z: 760 },
|
||
dimensions: { x: 5, y: 5, z: 10 },
|
||
volume: 196.35,
|
||
})
|
||
),
|
||
...[
|
||
'rearRightLegSource',
|
||
'rearLeftLegSource',
|
||
'frontRightLegSource',
|
||
'frontLeftLegSource',
|
||
].map(
|
||
(name, index): BodyMeasurement => ({
|
||
id: `mixed-leg-${index}`,
|
||
name,
|
||
center: { x: index % 2 ? 500 : -500, y: index < 2 ? -200 : 200, z: 375 },
|
||
dimensions: { x: 49, y: 49, z: 750 },
|
||
volume: 1_463_167.94,
|
||
})
|
||
),
|
||
{
|
||
id: 'mixed-tabletop',
|
||
name: 'tableTopAssembly',
|
||
center: { x: 0, y: 0, z: 762.5 },
|
||
dimensions: { x: 1440, y: 720, z: 25.2 },
|
||
volume: 17_976_787.12,
|
||
},
|
||
]
|
||
|
||
const reconciled = reconcileManufacturingDimensions(mixedBrowserBodies)
|
||
const rows = groupBodiesIntoBom(reconciled)
|
||
|
||
expect(rows).toHaveLength(3)
|
||
expect(rows.find((row) => row.name === '连接螺柱')).toMatchObject({
|
||
quantity: 4,
|
||
dimensions: { x: 5, y: 5, z: 10 },
|
||
})
|
||
expect(rows.find((row) => row.name === '支腿')).toMatchObject({
|
||
quantity: 4,
|
||
dimensions: { x: 50, y: 50, z: 750 },
|
||
})
|
||
expect(rows.find((row) => row.name === '台面板')).toMatchObject({
|
||
quantity: 1,
|
||
dimensions: { x: 1200, y: 600, z: 25 },
|
||
})
|
||
expect(
|
||
reconciled.find((body) => body.id === 'mixed-tabletop')?.holes
|
||
).toMatchObject([
|
||
{ type: 'blind-hole', diameter: 5, depth: 10 },
|
||
{ type: 'blind-hole', diameter: 5, depth: 10 },
|
||
{ type: 'blind-hole', diameter: 5, depth: 10 },
|
||
{ type: 'blind-hole', diameter: 5, depth: 10 },
|
||
])
|
||
expect(rows.some((row) => row.name === 'tableTopAssembly')).toBe(false)
|
||
})
|
||
|
||
test('does not reinterpret a solid square leg as a round rod', () => {
|
||
const [leg] = reconcileManufacturingDimensions([
|
||
{
|
||
id: 'square-leg',
|
||
name: 'leg',
|
||
center: { x: 0, y: 0, z: 375 },
|
||
dimensions: { x: 50, y: 50, z: 750 },
|
||
volume: 1_875_000,
|
||
},
|
||
])
|
||
|
||
expect(leg.dimensions).toEqual({ x: 50, y: 50, z: 750 })
|
||
expect(leg.dimensionSource).toBe('engine')
|
||
})
|
||
|
||
test('uses evaluated KCL parameters when a cold browser has no physical measurements', () => {
|
||
const coldBrowserBodies: BodyMeasurement[] = [
|
||
...[
|
||
'rearRightStudSource',
|
||
'rearLeftStudSource',
|
||
'frontRightStudSource',
|
||
'frontLeftStud',
|
||
].map(
|
||
(name, index): BodyMeasurement => ({
|
||
id: `cold-stud-${index}`,
|
||
name,
|
||
center: { x: index % 2 ? 500 : -500, y: index < 2 ? -200 : 200, z: 760 },
|
||
dimensions: { x: 6, y: 6, z: 10 },
|
||
})
|
||
),
|
||
...[
|
||
'rearRightLegSource',
|
||
'rearLeftLegSource',
|
||
'frontRightLegSource',
|
||
'frontLeftLeg',
|
||
].map(
|
||
(name, index): BodyMeasurement => ({
|
||
id: `cold-leg-${index}`,
|
||
name,
|
||
center: { x: index % 2 ? 500 : -500, y: index < 2 ? -200 : 200, z: 375 },
|
||
dimensions: { x: 60, y: 60, z: 750 },
|
||
})
|
||
),
|
||
{
|
||
id: 'cold-tabletop',
|
||
name: 'tableTop',
|
||
center: { x: 0, y: 0, z: 762.5 },
|
||
dimensions: { x: 1440, y: 720, z: 25.1 },
|
||
},
|
||
]
|
||
const parameters: ManufacturingLengthParameter[] = [
|
||
{ name: 'tableLength', valueMm: 1200 },
|
||
{ name: 'tableWidth', valueMm: 600 },
|
||
{ name: 'tableTopThickness', valueMm: 25 },
|
||
{ name: 'legDiameter', valueMm: 50 },
|
||
{ name: 'legHeight', valueMm: 750 },
|
||
{ name: 'studDiameter', valueMm: 5 },
|
||
{ name: 'studHeight', valueMm: 10 },
|
||
{ name: 'holeDiameter', valueMm: 5 },
|
||
{ name: 'holeDepth', valueMm: 10 },
|
||
{ name: 'holeInset', valueMm: 50 },
|
||
{ name: 'unrelatedCabinetWidth', valueMm: 900 },
|
||
]
|
||
|
||
const reconciled = reconcileManufacturingDimensions(
|
||
coldBrowserBodies,
|
||
parameters
|
||
)
|
||
const rows = groupBodiesIntoBom(reconciled)
|
||
|
||
expect(rows.find((row) => row.name === '连接螺柱')).toMatchObject({
|
||
quantity: 4,
|
||
dimensions: { x: 5, y: 5, z: 10 },
|
||
})
|
||
expect(rows.find((row) => row.name === '支腿')).toMatchObject({
|
||
quantity: 4,
|
||
dimensions: { x: 50, y: 50, z: 750 },
|
||
})
|
||
expect(rows.find((row) => row.name === '台面板')).toMatchObject({
|
||
quantity: 1,
|
||
dimensions: { x: 1200, y: 600, z: 25 },
|
||
})
|
||
const tabletop = reconciled.find((body) => body.id === 'cold-tabletop')
|
||
expect(tabletop?.holes).toHaveLength(4)
|
||
expect(
|
||
tabletop?.holes?.every(
|
||
(hole) =>
|
||
hole.type === 'blind-hole' &&
|
||
hole.diameter === 5 &&
|
||
hole.depth === 10
|
||
)
|
||
).toBe(true)
|
||
expect(
|
||
tabletop?.holes
|
||
?.map((hole) => [
|
||
Math.abs(hole.center.x - (tabletop?.center.x ?? 0)),
|
||
Math.abs(hole.center.y - (tabletop?.center.y ?? 0)),
|
||
])
|
||
.every(([fromCenterX, fromCenterY]) => fromCenterX === 550 && fromCenterY === 250)
|
||
).toBe(true)
|
||
expect(
|
||
reconciled.every(
|
||
(body) =>
|
||
body.dimensionSource === 'kcl-parameters' &&
|
||
body.engineDimensions !== undefined &&
|
||
body.dimensionReferences &&
|
||
body.dimensionReferences.length >= 2
|
||
)
|
||
).toBe(true)
|
||
|
||
const html = createEngineeringSheetHtml({
|
||
projectName: '冷启动孔位桌',
|
||
generatedAt: '2026-07-28 12:00',
|
||
overallDimensions: { x: 1200, y: 600, z: 775 },
|
||
bodies: reconciled,
|
||
bomRows: rows,
|
||
})
|
||
expect(html).toContain('4 × Ø5')
|
||
expect(html).toContain('Ø5 盲孔,深 10 mm')
|
||
})
|
||
|
||
test('keeps a square leg end view rectangular', () => {
|
||
const squareLeg: BodyMeasurement = {
|
||
id: 'square-leg',
|
||
name: 'leg',
|
||
center: { x: 0, y: 0, z: 375 },
|
||
dimensions: { x: 50, y: 50, z: 750 },
|
||
volume: 1_875_000,
|
||
}
|
||
const html = createEngineeringSheetHtml({
|
||
projectName: '方腿桌',
|
||
generatedAt: '2026-07-28 10:00',
|
||
overallDimensions: { x: 50, y: 50, z: 750 },
|
||
bodies: [squareLeg],
|
||
bomRows: groupBodiesIntoBom([squareLeg]),
|
||
})
|
||
|
||
expect(html).toContain('data-cross-section="rectangular"')
|
||
expect(html).not.toContain('class="circular-part-profile"')
|
||
})
|
||
|
||
test('localizes the hardware validation assembly for a Chinese BOM', () => {
|
||
expect(localizePartName('seatBody')).toBe('座板')
|
||
expect(localizePartName('tableBody')).toBe('桌体')
|
||
expect(localizePartName('mountingPanel')).toBe('安装基板')
|
||
expect(localizePartName('connectorPlate')).toBe('连接片')
|
||
expect(localizePartName('connectorBolt4')).toBe('连接螺栓')
|
||
expect(localizePartName('washer3')).toBe('垫圈')
|
||
})
|
||
|
||
test('builds a production-ready hardware BOM and detailed hole schedule', async ({
|
||
page,
|
||
}, testInfo) => {
|
||
const assembly = hardwareAssemblyFixture()
|
||
const rows = groupBodiesIntoBom(assembly)
|
||
|
||
expect(assembly).toHaveLength(10)
|
||
expect(rows).toHaveLength(4)
|
||
expect(rows.find((row) => row.name === '安装基板')?.holes).toHaveLength(4)
|
||
expect(rows.find((row) => row.name === '连接片')?.holes).toHaveLength(4)
|
||
expect(rows.find((row) => row.name === '连接螺栓')?.quantity).toBe(4)
|
||
expect(rows.find((row) => row.name === '垫圈')?.quantity).toBe(4)
|
||
|
||
const html = createEngineeringSheetHtml({
|
||
projectName: '五金连接安装板',
|
||
generatedAt: '2026-07-27 22:30',
|
||
overallDimensions: { x: 400, y: 300, z: 30 },
|
||
bodies: assembly,
|
||
bomRows: rows,
|
||
})
|
||
expect(html).toContain('安装基板')
|
||
expect(html).toContain('连接片')
|
||
expect(html).toContain('连接螺栓')
|
||
expect(html).toContain('垫圈')
|
||
expect(html).toContain('H1:Ø8 通孔')
|
||
expect(html).toContain('横向中心孔距 320 mm')
|
||
expect(html).toContain('纵向中心孔距 200 mm')
|
||
expect(html.match(/class="part-detail"/g)).toHaveLength(4)
|
||
expect(html).toContain('data-generated-exploded-view="true"')
|
||
expect(html.match(/class="explode-part"/g)).toHaveLength(10)
|
||
expect(html).not.toContain('暂无爆炸图快照')
|
||
expect(html.match(/class="part-front-view"/g)).toHaveLength(4)
|
||
expect(html.match(/class="part-side-view"/g)).toHaveLength(4)
|
||
expect(html).toContain('正面图')
|
||
expect(html).toContain('侧面图')
|
||
|
||
await page.setViewportSize({ width: 1600, height: 900 })
|
||
await page.setContent(html)
|
||
expect(
|
||
await page.locator('.part-detail').evaluateAll((parts) =>
|
||
parts.map((part) => part.textContent)
|
||
)
|
||
).toEqual(
|
||
expect.arrayContaining([
|
||
expect.stringContaining('安装基板'),
|
||
expect.stringContaining('连接片'),
|
||
expect.stringContaining('连接螺栓'),
|
||
expect.stringContaining('垫圈'),
|
||
])
|
||
)
|
||
await page.screenshot({
|
||
path: testInfo.outputPath('hardware-engineering-sheet.png'),
|
||
fullPage: true,
|
||
})
|
||
})
|
||
|
||
test('renders every BOM part without limiting the number of process drawings', () => {
|
||
const manyParts: BodyMeasurement[] = Array.from(
|
||
{ length: 7 },
|
||
(_, index) => ({
|
||
id: `custom-part-${index + 1}`,
|
||
name: `自定义零件${index + 1}`,
|
||
center: { x: index * 30, y: 0, z: 0 },
|
||
dimensions: { x: 20 + index, y: 12 + index, z: 4 + index },
|
||
})
|
||
)
|
||
const rows = groupBodiesIntoBom(manyParts)
|
||
const html = createEngineeringSheetHtml({
|
||
projectName: '多配件生产图',
|
||
generatedAt: '2026-07-27 23:00',
|
||
overallDimensions: getOverallBounds(manyParts).dimensions,
|
||
bodies: manyParts,
|
||
bomRows: rows,
|
||
})
|
||
|
||
expect(rows).toHaveLength(7)
|
||
expect(html.match(/class="part-detail"/g)).toHaveLength(7)
|
||
expect(html.match(/class="part-front-view"/g)).toHaveLength(7)
|
||
expect(html.match(/class="part-side-view"/g)).toHaveLength(7)
|
||
})
|
||
|
||
test('normalizes URL-safe engine snapshots for browser image rendering', () => {
|
||
expect(createSnapshotDataUrl('iVBORw0KGgo_8A')).toBe(
|
||
'data:image/png;base64,iVBORw0KGgo/8A=='
|
||
)
|
||
expect(
|
||
createSnapshotDataUrl('data:image/png;base64,iVBORw0KGgo_8A')
|
||
).toBe('data:image/png;base64,iVBORw0KGgo/8A==')
|
||
})
|
||
|
||
test('detects through holes on a panel and deduplicates both face edges', () => {
|
||
const panel: BodyMeasurement = {
|
||
id: 'side-panel',
|
||
name: 'sidePanel',
|
||
center: { x: 0, y: 0, z: 9 },
|
||
dimensions: { x: 400, y: 300, z: 18 },
|
||
}
|
||
const holeCenters = [
|
||
[-160, -100],
|
||
[160, -100],
|
||
[-160, 100],
|
||
[160, 100],
|
||
]
|
||
const edgeSamples = holeCenters.flatMap(([x, y]) => [
|
||
circularEdge(x, y, 0, 4),
|
||
circularEdge(x, y, 18, 4),
|
||
])
|
||
const holes = detectPanelHoles(panel, edgeSamples)
|
||
|
||
expect(holes).toHaveLength(4)
|
||
expect(holes.every((hole) => hole.axis === 'z')).toBe(true)
|
||
expect(holes.every((hole) => hole.diameter === 8)).toBe(true)
|
||
expect(holes.every((hole) => hole.depth === 18)).toBe(true)
|
||
|
||
const rows = groupBodiesIntoBom([{ ...panel, holes }])
|
||
const html = createEngineeringSheetHtml({
|
||
projectName: '五金连接侧板',
|
||
generatedAt: '2026-07-27 22:00',
|
||
overallDimensions: panel.dimensions,
|
||
bodies: [{ ...panel, holes }],
|
||
bomRows: rows,
|
||
})
|
||
expect(html).toContain('零件加工详图、孔径与孔位尺寸')
|
||
expect(html).toContain('4 × Ø8')
|
||
expect(html).toContain('H1:Ø8 通孔')
|
||
expect(html).toContain('距左 40')
|
||
expect(html).toContain('距下 50')
|
||
expect(html).toContain('横向中心孔距 320 mm')
|
||
expect(html).toContain('纵向中心孔距 200 mm')
|
||
})
|
||
|
||
test('builds a printable dimensioned engineering sheet safely', () => {
|
||
const rows = groupBodiesIntoBom(bodies)
|
||
const html = createEngineeringSheetHtml({
|
||
projectName: '<工作台>',
|
||
generatedAt: '2026-07-27 20:00',
|
||
overallDimensions: { x: 120, y: 60, z: 92 },
|
||
bodies,
|
||
brandName: '由壹点AI',
|
||
brandLogoUrl: '/api/favicon?surface=hicad',
|
||
snapshotDataUrl: 'data:image/png;base64,abc',
|
||
bomRows: rows,
|
||
})
|
||
|
||
expect(html).toContain('<工作台>')
|
||
expect(html).not.toContain('<工作台>')
|
||
expect(html).toContain('正视图')
|
||
expect(html).toContain('俯视图')
|
||
expect(html).toContain('右视图')
|
||
expect(html.match(/class="projection-part"/g)).toHaveLength(9)
|
||
expect(html).toContain('data-part-name="支腿"')
|
||
expect(html).toContain('data-part-name="台面板"')
|
||
expect(html).toContain('120 mm')
|
||
expect(html).toContain('配件明细表(BOM)')
|
||
expect(html).toContain('零件加工详图、孔径与孔位尺寸')
|
||
expect(html.match(/class="part-detail"/g)).toHaveLength(2)
|
||
expect(html).toContain('由壹点AI')
|
||
expect(html).toContain('/api/favicon?surface=hicad')
|
||
expect(html).toContain('@page { size: A3 landscape;')
|
||
expect(html).toContain(
|
||
'grid-template-columns: minmax(180px, 1fr) minmax(0, 2fr) minmax(180px, 1fr)'
|
||
)
|
||
expect(html).toContain('h1 { overflow: hidden; margin: 0; font-size: 18px;')
|
||
})
|
||
|
||
test('renders the engineering sheet without horizontal clipping', async ({
|
||
page,
|
||
}, testInfo) => {
|
||
const rows = groupBodiesIntoBom(bodies)
|
||
const html = createEngineeringSheetHtml({
|
||
projectName: '工作台生产图',
|
||
generatedAt: '2026-07-27 20:00',
|
||
overallDimensions: { x: 120, y: 60, z: 92 },
|
||
bodies,
|
||
brandName: '由壹点AI',
|
||
brandLogoUrl:
|
||
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="80" height="80"%3E%3Crect width="80" height="80" fill="%23111827"/%3E%3Cpath d="M20 20h40v40H20z" fill="white"/%3E%3C/svg%3E',
|
||
bomRows: rows,
|
||
notes:
|
||
'材料、表面处理、公差、连接方式和检验标准须在投产前由工程人员复核。',
|
||
})
|
||
|
||
await page.setViewportSize({ width: 1600, height: 900 })
|
||
await page.setContent(html)
|
||
|
||
const layout = await page.evaluate(() => ({
|
||
scrollWidth: document.documentElement.scrollWidth,
|
||
clientWidth: document.documentElement.clientWidth,
|
||
tableCount: document.querySelectorAll('table').length,
|
||
dimensionViewCount: document.querySelectorAll('.dimension-view').length,
|
||
partDetailCount: document.querySelectorAll('.part-detail').length,
|
||
projectionPartCount:
|
||
document.querySelectorAll('.projection-part').length,
|
||
brandCount: document.querySelectorAll('.brand-name').length,
|
||
}))
|
||
|
||
expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth)
|
||
expect(layout.tableCount).toBe(1)
|
||
expect(layout.dimensionViewCount).toBe(3)
|
||
expect(layout.partDetailCount).toBe(rows.length)
|
||
expect(layout.projectionPartCount).toBe(bodies.length * 3)
|
||
expect(layout.brandCount).toBe(1)
|
||
await page.screenshot({
|
||
path: testInfo.outputPath('engineering-sheet.png'),
|
||
fullPage: true,
|
||
})
|
||
})
|
||
})
|