|
| 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 | + |
| 5 | +/** |
| 6 | + * `pickQuantity`'s docstring on `MaterialTotalsPanel` promises: "Pick a |
| 7 | + * quantity value by candidate names (case-insensitive), else by type." The |
| 8 | + * volume call site implemented that fallback; the area and weight call |
| 9 | + * sites — built from the same `*ByName` maps in the same `qset.quantities` |
| 10 | + * loop — did not. An element whose only area (or weight) quantity carries a |
| 11 | + * vendor-specific name outside the IFC-standard candidate list (e.g. |
| 12 | + * "PerimeterArea", "TopArea") contributed zero to that material's total and |
| 13 | + * its row was hidden, while the identical situation for volume was counted. |
| 14 | + * This has been present since the file's first commit (#978). |
| 15 | + * |
| 16 | + * These tests drive `aggregateQuantitiesFromQsets` — the function extracted |
| 17 | + * from the three call sites so the "match a candidate name, else by type" |
| 18 | + * rule is applied identically to volume, area and weight instead of three |
| 19 | + * copies that can silently drift apart. |
| 20 | + * |
| 21 | + * PR #2777 review 1 (louistrue): proved on the app's own shipped sample |
| 22 | + * (`apps/viewer/public/samples/infra-bridge.ifc`) that the alphabetical |
| 23 | + * by-type fallback picks a beam's `CrossSectionArea` — a section property, |
| 24 | + * not a surface extent — as its Area, because no candidate name matched and |
| 25 | + * `crosssectionarea` sorts before the beam's other (non-candidate) area |
| 26 | + * names. Review 2: the fix is `AREA_CANDIDATES` recognising the standard |
| 27 | + * surface-area names by NAME (so beams/columns/members resolve without ever |
| 28 | + * reaching the fallback) plus `AREA_TYPE_FALLBACK_DENY` excluding |
| 29 | + * `crosssectionarea` from the fallback itself, so it can never be selected |
| 30 | + * even as a last resort. |
| 31 | + * |
| 32 | + * Review 1's MAJOR finding: every fixture in the original suite held exactly |
| 33 | + * one quantity of the kind under test, so the candidate-match path and the |
| 34 | + * type-fallback path returned the same value — deleting the candidate |
| 35 | + * matching entirely (`for (const c of candidates) ...` -> `void candidates;`) |
| 36 | + * left all 10 tests green. The "candidate wins over fallback" and |
| 37 | + * "case-insensitive match" tests below use TWO quantities per fixture, one |
| 38 | + * that only the candidate-priority / case-insensitive path picks correctly |
| 39 | + * and one that the (deny-filtered) alphabetical fallback would pick instead |
| 40 | + * if matching were skipped — see the mutation notes on each test. |
| 41 | + */ |
| 42 | + |
| 43 | +import { describe, it } from 'node:test'; |
| 44 | +import assert from 'node:assert/strict'; |
| 45 | +import { QuantityType } from '@ifc-lite/data'; |
| 46 | +import { aggregateQuantitiesFromQsets } from './MaterialTotalsPanel.js'; |
| 47 | + |
| 48 | +type Qty = { name: string; type: number; value: number }; |
| 49 | +type Qset = { quantities: readonly Qty[] }; |
| 50 | + |
| 51 | +function qsets(quantities: Qty[]): Qset[] { |
| 52 | + return [{ quantities }]; |
| 53 | +} |
| 54 | + |
| 55 | +describe('aggregateQuantitiesFromQsets', () => { |
| 56 | + describe('volume', () => { |
| 57 | + it('picks a candidate-named quantity (existing behaviour)', () => { |
| 58 | + const r = aggregateQuantitiesFromQsets( |
| 59 | + qsets([{ name: 'NetVolume', type: QuantityType.Volume, value: 12 }]), |
| 60 | + ); |
| 61 | + assert.equal(r.volume, 12); |
| 62 | + }); |
| 63 | + |
| 64 | + it('falls back to a non-candidate-named quantity of the same type (the fix)', () => { |
| 65 | + const r = aggregateQuantitiesFromQsets( |
| 66 | + qsets([{ name: 'CustomVendorVolume', type: QuantityType.Volume, value: 7 }]), |
| 67 | + ); |
| 68 | + assert.equal(r.volume, 7); |
| 69 | + }); |
| 70 | + |
| 71 | + it('is undefined when no volume quantity exists', () => { |
| 72 | + const r = aggregateQuantitiesFromQsets( |
| 73 | + qsets([{ name: 'NetArea', type: QuantityType.Area, value: 5 }]), |
| 74 | + ); |
| 75 | + assert.equal(r.volume, undefined); |
| 76 | + }); |
| 77 | + }); |
| 78 | + |
| 79 | + describe('area', () => { |
| 80 | + it('picks a candidate-named quantity (existing behaviour)', () => { |
| 81 | + const r = aggregateQuantitiesFromQsets( |
| 82 | + qsets([{ name: 'NetArea', type: QuantityType.Area, value: 30 }]), |
| 83 | + ); |
| 84 | + assert.equal(r.area, 30); |
| 85 | + }); |
| 86 | + |
| 87 | + it('falls back to a non-candidate-named quantity of the same type (the fix)', () => { |
| 88 | + // RED against pre-fix code: the area call site had no else-by-type |
| 89 | + // fallback, so this returned undefined and the total stayed 0. |
| 90 | + const r = aggregateQuantitiesFromQsets( |
| 91 | + qsets([{ name: 'PerimeterArea', type: QuantityType.Area, value: 18 }]), |
| 92 | + ); |
| 93 | + assert.equal(r.area, 18); |
| 94 | + }); |
| 95 | + |
| 96 | + it('is undefined when no area quantity exists', () => { |
| 97 | + const r = aggregateQuantitiesFromQsets( |
| 98 | + qsets([{ name: 'NetVolume', type: QuantityType.Volume, value: 9 }]), |
| 99 | + ); |
| 100 | + assert.equal(r.area, undefined); |
| 101 | + }); |
| 102 | + }); |
| 103 | + |
| 104 | + describe('weight', () => { |
| 105 | + it('picks a candidate-named quantity (existing behaviour)', () => { |
| 106 | + const r = aggregateQuantitiesFromQsets( |
| 107 | + qsets([{ name: 'NetWeight', type: QuantityType.Weight, value: 500 }]), |
| 108 | + ); |
| 109 | + assert.equal(r.weight, 500); |
| 110 | + }); |
| 111 | + |
| 112 | + it('falls back to a non-candidate-named quantity of the same type (the fix)', () => { |
| 113 | + // RED against pre-fix code: the weight call site had no else-by-type |
| 114 | + // fallback, so this returned undefined and the total stayed 0. |
| 115 | + const r = aggregateQuantitiesFromQsets( |
| 116 | + qsets([{ name: 'ShippingWeight', type: QuantityType.Weight, value: 250 }]), |
| 117 | + ); |
| 118 | + assert.equal(r.weight, 250); |
| 119 | + }); |
| 120 | + |
| 121 | + it('is undefined when no weight quantity exists', () => { |
| 122 | + const r = aggregateQuantitiesFromQsets( |
| 123 | + qsets([{ name: 'NetArea', type: QuantityType.Area, value: 4 }]), |
| 124 | + ); |
| 125 | + assert.equal(r.weight, undefined); |
| 126 | + }); |
| 127 | + }); |
| 128 | + |
| 129 | + it('breaks ties among several non-candidate names deterministically (alphabetical)', () => { |
| 130 | + const r = aggregateQuantitiesFromQsets( |
| 131 | + qsets([ |
| 132 | + { name: 'ZTopArea', type: QuantityType.Area, value: 99 }, |
| 133 | + { name: 'APerimeterArea', type: QuantityType.Area, value: 11 }, |
| 134 | + ]), |
| 135 | + ); |
| 136 | + assert.equal(r.area, 11); |
| 137 | + }); |
| 138 | + |
| 139 | + describe('CrossSectionArea must never be reported as a surface Area (PR #2777 review 1)', () => { |
| 140 | + it('is undefined when the only area quantity is CrossSectionArea (infra-bridge.ifc beam shape)', () => { |
| 141 | + // Real shape of apps/viewer/public/samples/infra-bridge.ifc's |
| 142 | + // Qto_BeamBaseQuantities (#385/#442/#493): NetVolume, Length, |
| 143 | + // CrossSectionArea — no surface-area quantity at all. Before this |
| 144 | + // fix, the alphabetical fallback had exactly one Area-typed name to |
| 145 | + // choose from and picked it, reporting 0.12 m2 (the beam's profile |
| 146 | + // area) as the material's surface area. Absence is the honest result. |
| 147 | + const r = aggregateQuantitiesFromQsets( |
| 148 | + qsets([ |
| 149 | + { name: 'NetVolume', type: QuantityType.Volume, value: 0.48 }, |
| 150 | + { name: 'Length', type: QuantityType.Length, value: 4000 }, |
| 151 | + { name: 'CrossSectionArea', type: QuantityType.Area, value: 0.12 }, |
| 152 | + ]), |
| 153 | + ); |
| 154 | + assert.equal(r.area, undefined); |
| 155 | + }); |
| 156 | + |
| 157 | + it('picks a real surface-area quantity over CrossSectionArea by name (the requested beam fixture)', () => { |
| 158 | + // The exact fixture louistrue's second review asked for: "a beam |
| 159 | + // carrying CrossSectionArea AND OuterSurfaceArea, asserting the |
| 160 | + // surface area is chosen." outersurfacearea is now in |
| 161 | + // AREA_CANDIDATES, so this resolves by name and never reaches the |
| 162 | + // fallback at all. |
| 163 | + const r = aggregateQuantitiesFromQsets( |
| 164 | + qsets([ |
| 165 | + { name: 'CrossSectionArea', type: QuantityType.Area, value: 0.06 }, |
| 166 | + { name: 'OuterSurfaceArea', type: QuantityType.Area, value: 11.9 }, |
| 167 | + ]), |
| 168 | + ); |
| 169 | + assert.equal(r.area, 11.9); |
| 170 | + }); |
| 171 | + |
| 172 | + it('excludes CrossSectionArea from the by-type fallback even when a non-candidate name is also present', () => { |
| 173 | + // Neither name matches AREA_CANDIDATES, so this exercises the |
| 174 | + // fallback path (not name matching). MUTATION: delete |
| 175 | + // AREA_TYPE_FALLBACK_DENY (or stop passing it to pickQuantity) and |
| 176 | + // this goes RED — alphabetical sort puts 'crosssectionarea' before |
| 177 | + // 'vendorsurfacearea', returning 0.06 instead of 11.9. |
| 178 | + const r = aggregateQuantitiesFromQsets( |
| 179 | + qsets([ |
| 180 | + { name: 'CrossSectionArea', type: QuantityType.Area, value: 0.06 }, |
| 181 | + { name: 'VendorSurfaceArea', type: QuantityType.Area, value: 11.9 }, |
| 182 | + ]), |
| 183 | + ); |
| 184 | + assert.equal(r.area, 11.9); |
| 185 | + }); |
| 186 | + }); |
| 187 | + |
| 188 | + describe('candidate priority is load-bearing, not incidental (PR #2777 review 1 MAJOR finding)', () => { |
| 189 | + it('prefers Net over Gross even though Gross sorts first alphabetically', () => { |
| 190 | + // MUTATION: delete the candidate-matching loop in pickQuantity |
| 191 | + // (`for (const c of candidates) ...` -> `void candidates;`) and this |
| 192 | + // goes RED — with matching skipped, both fixtures fall straight to |
| 193 | + // the alphabetical fallback, which prefers 'grossarea' (13) over |
| 194 | + // 'netarea' (7) purely by spelling. Every fixture in the original |
| 195 | + // suite held one quantity per kind, so this priority was unguarded: |
| 196 | + // the candidate path and the fallback path always agreed. |
| 197 | + const r = aggregateQuantitiesFromQsets( |
| 198 | + qsets([ |
| 199 | + { name: 'GrossArea', type: QuantityType.Area, value: 13 }, |
| 200 | + { name: 'NetArea', type: QuantityType.Area, value: 7 }, |
| 201 | + ]), |
| 202 | + ); |
| 203 | + assert.equal(r.area, 7); |
| 204 | + }); |
| 205 | + |
| 206 | + it('matches candidate names case-insensitively even when a differently-cased fallback name would sort first', () => { |
| 207 | + // MUTATION: change `q.name.toLowerCase()` to `q.name` in |
| 208 | + // aggregateQuantitiesFromQsets and this goes RED — the map key stays |
| 209 | + // 'NETAREA' (uppercase), which no longer matches the lowercase |
| 210 | + // candidate 'netarea', so it falls to the alphabetical fallback. |
| 211 | + // Case-sensitive sort puts 'AVendorArea' (5000) before 'NETAREA' |
| 212 | + // (10), returning the wrong value. |
| 213 | + const r = aggregateQuantitiesFromQsets( |
| 214 | + qsets([ |
| 215 | + { name: 'NETAREA', type: QuantityType.Area, value: 10 }, |
| 216 | + { name: 'AVendorArea', type: QuantityType.Area, value: 5000 }, |
| 217 | + ]), |
| 218 | + ); |
| 219 | + assert.equal(r.area, 10); |
| 220 | + }); |
| 221 | + }); |
| 222 | +}); |
0 commit comments