|
| 1 | +/* This Source Code Form is subject to the terms of the Mozilla Public |
| 2 | + * License, v. 2.0. If a copy of the MPL was not distributed with this |
| 3 | + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ |
| 4 | +import { existsSync, readFileSync } from 'node:fs'; |
| 5 | +import { deepStrictEqual } from 'node:assert/strict'; |
| 6 | +import { resolve } from 'node:path'; |
| 7 | +import { describe, expect, it } from 'vitest'; |
| 8 | +import { CompactEntityIndex, IfcParser } from '@ifc-lite/parser'; |
| 9 | +import { writeEntityIndex, readEntityIndex } from './entity-index.js'; |
| 10 | +import { BufferReader, BufferWriter } from '../utils/buffer-utils.js'; |
| 11 | +import type { CacheEntityIndex, CacheEntityRef } from '../types.js'; |
| 12 | + |
| 13 | +function encode(byId: CacheEntityIndex['byId']): ArrayBuffer { |
| 14 | + const writer = new BufferWriter(); |
| 15 | + writeEntityIndex(writer, { byId }); |
| 16 | + return writer.build(); |
| 17 | +} |
| 18 | +function iterableOnly(index: CompactEntityIndex): CacheEntityIndex['byId'] { |
| 19 | + return { [Symbol.iterator]: () => index[Symbol.iterator]() }; |
| 20 | +} |
| 21 | +function checkOracle(index: CompactEntityIndex) { |
| 22 | + const before = [...index]; |
| 23 | + const expected = encode(iterableOnly(index)); |
| 24 | + const actual = encode(index); |
| 25 | + // #3985: exact section bytes ARE the binary-cache compatibility contract. |
| 26 | + // Native strict comparison keeps the 65536-type boundary oracle affordable |
| 27 | + // under the full parallel suite without shortening its byte/object coverage. |
| 28 | + deepStrictEqual(new Uint8Array(actual), new Uint8Array(expected)); |
| 29 | + deepStrictEqual([...index], before); |
| 30 | + const restored = readEntityIndex(new BufferReader(actual)); |
| 31 | + deepStrictEqual(restored, readEntityIndex(new BufferReader(expected))); |
| 32 | + return restored; |
| 33 | +} |
| 34 | +function compact(ids: number[], types: number[], names: string[]) { |
| 35 | + return new CompactEntityIndex(Uint32Array.from(ids), |
| 36 | + Uint32Array.from(ids.map((_, i) => i * 19)), |
| 37 | + Uint32Array.from(ids.map((_, i) => i + 7)), Uint16Array.from(types), names); |
| 38 | +} |
| 39 | + |
| 40 | +describe('compact cache columns preserve iterable byte layout (#3985)', () => { |
| 41 | + it('keeps sparse/duplicate IDs, normalized first-row type order and source ownership', () => { |
| 42 | + const index = compact([0, 0, 2, 0xffffffff], [3, 1, 0, 2], |
| 43 | + ['ifcwall', 'IFCWALL', 'Ifcſpace', 'IFCSPACE', 'UNREFERENCED']); |
| 44 | + const columns = index.getColumns(); |
| 45 | + const beforeColumns = structuredClone(columns); |
| 46 | + const decoded = checkOracle(index); |
| 47 | + expect(decoded.ids).toEqual(new Uint32Array([0, 0, 2, 0xffffffff])); |
| 48 | + expect(decoded.typeNames).toEqual(['IFCSPACE', 'IFCWALL']); |
| 49 | + expect(decoded.typeIndices).toEqual(new Uint16Array([0, 1, 1, 0])); |
| 50 | + expect(columns).toEqual(beforeColumns); |
| 51 | + expect(index.get(0xffffffff)?.type).toBe('Ifcſpace'); |
| 52 | + // String lists returned to transport/cache cannot rewrite source type names. |
| 53 | + columns.typeStrings[2] = 'MUTATED_CONSUMER_TABLE'; |
| 54 | + expect([...index].at(-1)?.[1].type).toBe('Ifcſpace'); |
| 55 | + }); |
| 56 | + |
| 57 | + it('keeps empty and unused-only type tables empty on the wire', () => { |
| 58 | + expect(checkOracle(compact([], [], ['unused'])).typeNames).toEqual([]); |
| 59 | + }); |
| 60 | + |
| 61 | + it('retains stable sorting when a public constructor supplies unsorted IDs', () => { |
| 62 | + const decoded = checkOracle(compact([9, 2, 9, 0], [0, 1, 2, 0], ['IfcWall', 'IfcSlab', 'IfcDoor'])); |
| 63 | + expect(decoded.ids).toEqual(new Uint32Array([0, 2, 9, 9])); |
| 64 | + expect(decoded.byteOffsets).toEqual(new Uint32Array([57, 19, 0, 38])); |
| 65 | + expect(decoded.typeNames).toEqual(['IFCWALL', 'IFCSLAB', 'IFCDOOR']); |
| 66 | + }); |
| 67 | + |
| 68 | + it.each(['shortOffsets', 'longOffsets', 'shortLengths', 'longLengths', 'shortTypes', 'longTypes', 'badTypeIndex', 'nonStringType']) |
| 69 | + ('preserves iterable coercion for unusual constructor input: %s', kind => { |
| 70 | + const ids = new Uint32Array([1, 2]); |
| 71 | + const offsets = new Uint32Array(kind === 'shortOffsets' ? [10] : kind === 'longOffsets' ? [10, 20, 30] : [10, 20]); |
| 72 | + const lengths = new Uint32Array(kind === 'shortLengths' ? [3] : kind === 'longLengths' ? [3, 4, 5] : [3, 4]); |
| 73 | + const types = new Uint16Array(kind === 'shortTypes' ? [0] : kind === 'longTypes' ? [0, 0, 0] : kind === 'badTypeIndex' ? [0, 17] : [0, 0]); |
| 74 | + const names = kind === 'nonStringType' ? [42] as unknown as string[] : ['IfcWall']; |
| 75 | + checkOracle(new CompactEntityIndex(ids, offsets, lengths, types, names)); |
| 76 | + }); |
| 77 | + |
| 78 | + it('preserves all 65536 distinct referenced source type slots', () => { |
| 79 | + const n = 0x10000; |
| 80 | + const ids = Uint32Array.from({ length: n }, (_, i) => i); |
| 81 | + const types = Uint16Array.from({ length: n }, (_, i) => n - i - 1); |
| 82 | + const index = new CompactEntityIndex(ids, ids.slice(), ids.slice(), types, |
| 83 | + Array.from({ length: n }, (_, i) => `IfcVendor${i}`)); |
| 84 | + const restored = checkOracle(index); |
| 85 | + expect(restored.typeNames.length).toBe(n); |
| 86 | + expect(restored.typeNames[0]).toBe('IFCVENDOR65535'); |
| 87 | + expect(restored.typeNames[n - 1]).toBe('IFCVENDOR0'); |
| 88 | + }); |
| 89 | + |
| 90 | + it('retains generic iterable overflow rejection at the 65537th unique normalized type', () => { |
| 91 | + const byId: CacheEntityIndex['byId'] = { |
| 92 | + *[Symbol.iterator](): IterableIterator<[number, CacheEntityRef]> { |
| 93 | + for (let id = 0; id <= 0x10000; id++) { |
| 94 | + yield [id, { expressId: id, type: `IfcVendor${id}`, byteOffset: id, byteLength: 1 }]; |
| 95 | + } |
| 96 | + }, |
| 97 | + }; |
| 98 | + expect(() => encode(byId)).toThrow('more than 65535 unique IFC type names'); |
| 99 | + }); |
| 100 | +}); |
| 101 | + |
| 102 | +const realFixture = resolve(__dirname, '../../../../tests/models/ara3d/AC20-FZK-Haus.ifc'); |
| 103 | +const hasFixture = existsSync(realFixture); |
| 104 | +if (!hasFixture) console.warn('skip compact cache real IFC oracle: run pnpm fixtures'); |
| 105 | +it.skipIf(!hasFixture)('retains real Archicad cache section bytes and properties (#3985)', async () => { |
| 106 | + const source = Uint8Array.from(readFileSync(realFixture)).buffer; |
| 107 | + const parsed = await new IfcParser().parseColumnar(source, { disableWorkerScan: true }); |
| 108 | + expect(parsed.entityIndex.byId).toBeInstanceOf(CompactEntityIndex); |
| 109 | + const index = parsed.entityIndex.byId as CompactEntityIndex; |
| 110 | + const propertyIds = [...parsed.onDemandPropertyMap!.keys()]; |
| 111 | + expect(propertyIds.length).toBeGreaterThan(0); |
| 112 | + const sampleId = propertyIds[0]; |
| 113 | + const before = parsed.getProperties(sampleId); |
| 114 | + expect(before.length).toBeGreaterThan(0); |
| 115 | + checkOracle(index); |
| 116 | + expect(parsed.getProperties(sampleId)).toEqual(before); |
| 117 | +}); |
0 commit comments