import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { inspectPointcloudMetadata, MAX_PLY_HEADER_BYTES, parsePlyHeader, PointcloudMetadataParseError, } from "./pointcloudMetadata"; const temporaryDirectories: string[] = []; afterEach(() => { while (temporaryDirectories.length > 0) { rmSync(temporaryDirectories.pop()!, { recursive: true, force: true }); } }); function header(lines: string[], newline = "\n") { return Buffer.from([...lines, "end_header", ""].join(newline), "ascii"); } describe("parsePlyHeader", () => { it("解析 ASCII PLY,并以 element vertex 作为精确点数", () => { const result = parsePlyHeader(header([ "ply", "format ascii 1.0", "element vertex 1000000000", "property float x", "property float y", "property double z", "element face 999", "property list uchar int vertex_indices", ])); expect(result).toEqual({ pointCount: 1_000_000_000, pointDataFormat: "ascii", bytesPerPoint: 16, pointAttributes: [ { name: "x", type: "float32", bytes: 4 }, { name: "y", type: "float32", bytes: 4 }, { name: "z", type: "float64", bytes: 8 }, ], }); }); it.each([ ["binary_little_endian", "binary_little_endian"], ["binary_big_endian", "binary_big_endian"], ] as const)("解析 %s PLY", (format, expected) => { const result = parsePlyHeader(header([ "ply", `format ${format} 1.0`, "element vertex 42", "property int16 x", "property ushort intensity", "property uchar red", ])); expect(result.pointDataFormat).toBe(expected); expect(result.bytesPerPoint).toBe(5); }); it("支持 CRLF 文件头和类型别名", () => { const result = parsePlyHeader(header([ "ply", "format ascii 1.0", "comment generated", "element vertex 3", "property char classification", "property uint confidence", "property float32 temperature", ], "\r\n")); expect(result.pointAttributes).toEqual([ { name: "classification", type: "int8", bytes: 1 }, { name: "confidence", type: "uint32", bytes: 4 }, { name: "temperature", type: "float32", bytes: 4 }, ]); }); it("拒绝无效文件头", () => { expect(() => parsePlyHeader(Buffer.from("not-ply\n"))).toThrow(PointcloudMetadataParseError); }); it("拒绝未知的顶点属性类型", () => { expect(() => parsePlyHeader(header([ "ply", "format ascii 1.0", "element vertex 1", "property half x", ]))).toThrow("未知的顶点属性类型"); }); it("拒绝列表型顶点属性,但忽略其他 element 的列表属性", () => { expect(() => parsePlyHeader(header([ "ply", "format ascii 1.0", "element vertex 1", "property list uchar float samples", ]))).toThrow("列表型顶点属性"); }); it("非 PLY 格式直接标记为 unsupported,不读取文件", () => { expect(inspectPointcloudMetadata("/path/does/not/exist.txt", "cloud.txt")).toEqual({ metadataStatus: "unsupported", pointCount: null, pointAttributes: null, pointDataFormat: null, bytesPerPoint: null, metadataError: null, }); }); it("只读取 16KB 文件头,超长文件头标记为解析失败", () => { const directory = mkdtempSync(join(tmpdir(), "pointcloud-metadata-")); temporaryDirectories.push(directory); const filePath = join(directory, "oversized.ply"); writeFileSync(filePath, `ply\nformat ascii 1.0\ncomment ${"x".repeat(MAX_PLY_HEADER_BYTES)}\nend_header\n`); const result = inspectPointcloudMetadata(filePath, "oversized.ply"); expect(result.metadataStatus).toBe("error"); expect(result.metadataError).toContain("16KB"); }); });