@itwin/core-backend 5.14.0-dev.21 → 5.14.0-dev.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/lib/cjs/ECDb.d.ts +11 -6
  2. package/lib/cjs/ECDb.d.ts.map +1 -1
  3. package/lib/cjs/ECDb.js +11 -6
  4. package/lib/cjs/ECDb.js.map +1 -1
  5. package/lib/cjs/ECSqlStatement.d.ts +2 -2
  6. package/lib/cjs/ECSqlStatement.js +2 -2
  7. package/lib/cjs/ECSqlStatement.js.map +1 -1
  8. package/lib/cjs/IModelDb.d.ts +12 -10
  9. package/lib/cjs/IModelDb.d.ts.map +1 -1
  10. package/lib/cjs/IModelDb.js +19 -12
  11. package/lib/cjs/IModelDb.js.map +1 -1
  12. package/lib/cjs/annotations/ElementDrivesTextAnnotation.d.ts +90 -5
  13. package/lib/cjs/annotations/ElementDrivesTextAnnotation.d.ts.map +1 -1
  14. package/lib/cjs/annotations/ElementDrivesTextAnnotation.js +59 -1
  15. package/lib/cjs/annotations/ElementDrivesTextAnnotation.js.map +1 -1
  16. package/lib/cjs/internal/annotations/fieldSpecs.d.ts +82 -0
  17. package/lib/cjs/internal/annotations/fieldSpecs.d.ts.map +1 -0
  18. package/lib/cjs/internal/annotations/fieldSpecs.js +187 -0
  19. package/lib/cjs/internal/annotations/fieldSpecs.js.map +1 -0
  20. package/lib/cjs/internal/annotations/fields.d.ts +39 -2
  21. package/lib/cjs/internal/annotations/fields.d.ts.map +1 -1
  22. package/lib/cjs/internal/annotations/fields.js +140 -13
  23. package/lib/cjs/internal/annotations/fields.js.map +1 -1
  24. package/lib/esm/ECDb.d.ts +11 -6
  25. package/lib/esm/ECDb.d.ts.map +1 -1
  26. package/lib/esm/ECDb.js +11 -6
  27. package/lib/esm/ECDb.js.map +1 -1
  28. package/lib/esm/ECSqlStatement.d.ts +2 -2
  29. package/lib/esm/ECSqlStatement.js +2 -2
  30. package/lib/esm/ECSqlStatement.js.map +1 -1
  31. package/lib/esm/IModelDb.d.ts +12 -10
  32. package/lib/esm/IModelDb.d.ts.map +1 -1
  33. package/lib/esm/IModelDb.js +19 -12
  34. package/lib/esm/IModelDb.js.map +1 -1
  35. package/lib/esm/annotations/ElementDrivesTextAnnotation.d.ts +90 -5
  36. package/lib/esm/annotations/ElementDrivesTextAnnotation.d.ts.map +1 -1
  37. package/lib/esm/annotations/ElementDrivesTextAnnotation.js +60 -2
  38. package/lib/esm/annotations/ElementDrivesTextAnnotation.js.map +1 -1
  39. package/lib/esm/internal/annotations/fieldSpecs.d.ts +82 -0
  40. package/lib/esm/internal/annotations/fieldSpecs.d.ts.map +1 -0
  41. package/lib/esm/internal/annotations/fieldSpecs.js +179 -0
  42. package/lib/esm/internal/annotations/fieldSpecs.js.map +1 -0
  43. package/lib/esm/internal/annotations/fields.d.ts +39 -2
  44. package/lib/esm/internal/annotations/fields.d.ts.map +1 -1
  45. package/lib/esm/internal/annotations/fields.js +140 -13
  46. package/lib/esm/internal/annotations/fields.js.map +1 -1
  47. package/lib/esm/test/AnnotationTestUtils.d.ts +9 -0
  48. package/lib/esm/test/AnnotationTestUtils.d.ts.map +1 -1
  49. package/lib/esm/test/AnnotationTestUtils.js +17 -0
  50. package/lib/esm/test/AnnotationTestUtils.js.map +1 -1
  51. package/lib/esm/test/annotations/FieldFormat.test.d.ts +2 -0
  52. package/lib/esm/test/annotations/FieldFormat.test.d.ts.map +1 -0
  53. package/lib/esm/test/annotations/FieldFormat.test.js +738 -0
  54. package/lib/esm/test/annotations/FieldFormat.test.js.map +1 -0
  55. package/lib/esm/test/annotations/FieldFormattingLifecycle.test.d.ts +2 -0
  56. package/lib/esm/test/annotations/FieldFormattingLifecycle.test.d.ts.map +1 -0
  57. package/lib/esm/test/annotations/FieldFormattingLifecycle.test.js +323 -0
  58. package/lib/esm/test/annotations/FieldFormattingLifecycle.test.js.map +1 -0
  59. package/lib/esm/test/annotations/FieldSpecs.test.d.ts +2 -0
  60. package/lib/esm/test/annotations/FieldSpecs.test.d.ts.map +1 -0
  61. package/lib/esm/test/annotations/FieldSpecs.test.js +54 -0
  62. package/lib/esm/test/annotations/FieldSpecs.test.js.map +1 -0
  63. package/lib/esm/test/annotations/Fields.test.js +221 -27
  64. package/lib/esm/test/annotations/Fields.test.js.map +1 -1
  65. package/lib/esm/test/ecdb/QueryReaders.test.js +105 -30
  66. package/lib/esm/test/ecdb/QueryReaders.test.js.map +1 -1
  67. package/package.json +15 -13
@@ -0,0 +1,738 @@
1
+ /*---------------------------------------------------------------------------------------------
2
+ * Copyright (c) Bentley Systems, Incorporated. All rights reserved.
3
+ * See LICENSE.md in the project root for license terms and full copyright notice.
4
+ *--------------------------------------------------------------------------------------------*/
5
+ import { expect } from "chai";
6
+ import * as sinon from "sinon";
7
+ import { Code, FieldRun, SubCategoryAppearance, TextBlock } from "@itwin/core-common";
8
+ import { FormatterSpec } from "@itwin/core-quantity";
9
+ import { Logger } from "@itwin/core-bentley";
10
+ import { Point3d, YawPitchRollAngles } from "@itwin/core-geometry";
11
+ import { StandaloneDb } from "../../IModelDb";
12
+ import { IModelTestUtils } from "../IModelTestUtils";
13
+ import { SpatialCategory } from "../../Category";
14
+ import { Schema, Schemas } from "../../Schema";
15
+ import { ClassRegistry } from "../../ClassRegistry";
16
+ import { PhysicalElement } from "../../Element";
17
+ import { ElementDrivesTextAnnotation } from "../../annotations/ElementDrivesTextAnnotation";
18
+ import { decimalFormat, toFormatSet } from "../AnnotationTestUtils";
19
+ import { withEditTxn } from "../../EditTxn";
20
+ /**
21
+ * How a [FieldRun]'s quantity format is resolved.
22
+ *
23
+ * Each test varies the three inputs on `formatOptions.quantity` -- `formatSet`,
24
+ * `kindOfQuantity`, and `persistenceUnit` -- and applies them to properties chosen so that some
25
+ * carry their own KindOfQuantity and some do not. That split is the dominant axis: a failed
26
+ * override is silently rescued by the property-side candidate when the property carries a KoQ,
27
+ * and only falls through to the raw string when it does not.
28
+ *
29
+ * Formats resolve in this order, and the tests below walk it from the bottom up: the FormatSet a
30
+ * field names, then the FormatSet adopted for the iModel, then the schema's own presentation
31
+ * format for the property's KindOfQuantity, then the raw value.
32
+ *
33
+ * Each test registers the FormatSets it depends on inline, so what produced a given string is
34
+ * visible without cross-referencing a table.
35
+ */
36
+ // cspell: ignore koqs
37
+ const ADOPTED_SET = "adopted-set";
38
+ const ALT_SET = "alternate-set";
39
+ /** Deliberately never registered, so lookup falls back to the adopted FormatSet. */
40
+ const UNREGISTERED_SET = "unregistered-set";
41
+ // ---------------------------------------------------------------------------------------------
42
+ // Test schema
43
+ // ---------------------------------------------------------------------------------------------
44
+ /**
45
+ * Two families of KindOfQuantity live here:
46
+ *
47
+ * - `*_PROP` are attached to properties. They give the property-side candidate something to
48
+ * resolve, and are left undefined by the FormatSets in most tests below, so a field that names
49
+ * nothing exercises the schema. "routes a field with no KindOfQuantity override through a
50
+ * FormatSet keyed on the property's own KindOfQuantity" is the deliberate exception.
51
+ * - `SCHEMA_*` are declared but never attached. Tests name them to force the
52
+ * FormatSet -> SchemaFormatsProvider fallthrough.
53
+ *
54
+ * Anything else a field names (`Example.LENGTH`, `Example.AREA`, ...) is defined by the FormatSet
55
+ * that test registers, and exists nowhere in the schema.
56
+ */
57
+ const exampleSchemaXml = `<?xml version="1.0" encoding="UTF-8"?>
58
+ <ECSchema schemaName="FieldExample" alias="fex" version="01.00.00" xmlns="http://www.bentley.com/schemas/Bentley.ECXML.3.2">
59
+ <ECSchemaReference name="BisCore" version="01.00.04" alias="bis"/>
60
+ <ECSchemaReference name="Formats" version="01.00.00" alias="f"/>
61
+ <ECSchemaReference name="Units" version="01.00.09" alias="u"/>
62
+
63
+ <KindOfQuantity typeName="LENGTH_PROP" displayLabel="Length" persistenceUnit="u:M" relativeError="0.0001" presentationUnits="f:DefaultRealU(4)[u:M]"/>
64
+ <KindOfQuantity typeName="AREA_PROP" displayLabel="Area" persistenceUnit="u:SQ_M" relativeError="0.0001" presentationUnits="f:DefaultRealU(4)[u:SQ_M]"/>
65
+ <KindOfQuantity typeName="SLOPE_PROP" displayLabel="Slope" persistenceUnit="u:M_PER_M" relativeError="0.0001" presentationUnits="f:DefaultRealU(4)[u:M_PER_M]"/>
66
+
67
+ <KindOfQuantity typeName="INT_LENGTH_PROP" displayLabel="Integer Length" persistenceUnit="u:MM" relativeError="0.0001" presentationUnits="f:DefaultRealU(4)[u:M]"/>
68
+
69
+ <Unit typeName="DOUBLE_M" displayLabel="dm" phenomenon="u:LENGTH" unitSystem="u:SI" definition="u:M" numerator="2"/>
70
+ <KindOfQuantity typeName="CUSTOM_UNIT_PROP" displayLabel="Custom-unit Length" persistenceUnit="DOUBLE_M" relativeError="0.0001" presentationUnits="f:DefaultRealU(4)[u:M]"/>
71
+
72
+ <KindOfQuantity typeName="SCHEMA_LENGTH" displayLabel="Schema Length" persistenceUnit="u:M" relativeError="0.0001" presentationUnits="f:DefaultRealU(2)[u:M]"/>
73
+ <KindOfQuantity typeName="SCHEMA_AREA" displayLabel="Schema Area" persistenceUnit="u:SQ_M" relativeError="0.0001" presentationUnits="f:DefaultRealU(2)[u:SQ_M]"/>
74
+ <KindOfQuantity typeName="SCHEMA_ANGLE" displayLabel="Schema Angle" persistenceUnit="u:ARC_DEG" relativeError="0.0001" presentationUnits="f:DefaultRealU(2)[u:ARC_DEG]"/>
75
+ <KindOfQuantity typeName="SCHEMA_SLOPE" displayLabel="Schema Slope" persistenceUnit="u:M_PER_M" relativeError="0.0001" presentationUnits="f:DefaultRealU(2)[u:M_PER_M]"/>
76
+ <KindOfQuantity typeName="SCHEMA_RATIO" displayLabel="Schema Ratio" persistenceUnit="u:ONE" relativeError="0.0001" presentationUnits="f:DefaultRealU(2)[u:ONE]"/>
77
+
78
+ <ECEntityClass typeName="ExampleElement" modifier="None">
79
+ <BaseClass>bis:PhysicalElement</BaseClass>
80
+ <ECProperty propertyName="lengthProp" typeName="double" kindOfQuantity="LENGTH_PROP"/>
81
+ <ECProperty propertyName="areaProp" typeName="double" kindOfQuantity="AREA_PROP"/>
82
+ <ECProperty propertyName="slopeProp" typeName="double" kindOfQuantity="SLOPE_PROP"/>
83
+ <ECProperty propertyName="angleProp" typeName="double"/>
84
+ <ECProperty propertyName="ratioProp" typeName="double"/>
85
+ <ECProperty propertyName="point" typeName="point3d"/>
86
+ <ECProperty propertyName="intLengthProp" typeName="int" kindOfQuantity="INT_LENGTH_PROP"/>
87
+ <ECProperty propertyName="intCountProp" typeName="int"/>
88
+ <ECProperty propertyName="customUnitProp" typeName="double" kindOfQuantity="CUSTOM_UNIT_PROP"/>
89
+ </ECEntityClass>
90
+ </ECSchema>
91
+ `;
92
+ class ExampleElement extends PhysicalElement {
93
+ static get className() { return "ExampleElement"; }
94
+ }
95
+ class FieldExampleSchema extends Schema {
96
+ static get schemaName() { return "FieldExample"; }
97
+ }
98
+ describe("Field format resolution example", () => {
99
+ let imodel;
100
+ let elementId;
101
+ before(async () => {
102
+ const iModelPath = IModelTestUtils.prepareOutputFile("FieldFormatExample", "test.bim");
103
+ imodel = StandaloneDb.createEmpty(iModelPath, { rootSubject: { name: "FieldFormatExample" }, enableTransactions: true });
104
+ if (!Schemas.getRegisteredSchema("FieldExample")) {
105
+ Schemas.registerSchema(FieldExampleSchema);
106
+ ClassRegistry.register(ExampleElement, FieldExampleSchema);
107
+ }
108
+ await imodel.importSchemaStrings([exampleSchemaXml]);
109
+ await withEditTxn(imodel, async (txn) => {
110
+ const model = IModelTestUtils.createAndInsertPhysicalPartitionAndModel(txn, Code.createEmpty(), true)[1];
111
+ const category = SpatialCategory.insert(txn, StandaloneDb.dictionaryId, "FieldFormatExampleCategory", new SubCategoryAppearance());
112
+ const props = {
113
+ classFullName: "FieldExample:ExampleElement",
114
+ model,
115
+ category,
116
+ code: Code.createEmpty(),
117
+ // Seed values chosen to convert cleanly, so a wrong conversion is obvious by inspection.
118
+ // Every test restates the properties it uses in a "Persisted on the element:" comment, so its
119
+ // expected strings can be checked without scrolling back here. Changing a value below
120
+ // means updating those comments too -- grep "Persisted on the element" to find them all.
121
+ lengthProp: 2.5, // m -> 2500 mm, 250 cm, 8.202 ft
122
+ areaProp: 100, // m2 -> 1076.391 ft2
123
+ slopeProp: 0.01, // m/m -> 100 :1
124
+ angleProp: 90, // deg -> 1.5708 rad
125
+ ratioProp: 0.9, // one
126
+ point: { x: 1, y: 2, z: 3 },
127
+ intLengthProp: 2500, // mm, int-typed -> 2.5 m
128
+ intCountProp: 42, // no KoQ; a count, not something with units
129
+ customUnitProp: 1.25, // DOUBLE_M, a unit only this schema defines -> would be 2.5 m
130
+ placement: { origin: new Point3d(0, 0, 0), angles: new YawPitchRollAngles() },
131
+ };
132
+ elementId = txn.insertElement(props);
133
+ });
134
+ });
135
+ after(() => {
136
+ imodel.close();
137
+ });
138
+ afterEach(() => {
139
+ sinon.restore();
140
+ logWarningSpy = undefined;
141
+ });
142
+ // Sinon refuses to wrap a method twice, so tests spying on both messages share one spy.
143
+ // Reset alongside `sinon.restore()` in afterEach.
144
+ let logWarningSpy;
145
+ function spyOnWarnings(messagePrefix) {
146
+ const spy = logWarningSpy ??= sinon.spy(Logger, "logWarning");
147
+ return () => spy.getCalls()
148
+ .filter((call) => typeof call.args[1] === "string" && call.args[1].startsWith(messagePrefix))
149
+ .map((call) => call.args[2]());
150
+ }
151
+ /** Spies on the warning [[render]] logs for each field it could not resolve a format for.
152
+ * Returns the (KindOfQuantity, persistence unit) pairs those warnings report as tried.
153
+ */
154
+ function spyOnFormatWarnings() {
155
+ return spyOnWarnings("No format resolved for text annotation field");
156
+ }
157
+ /** Warnings that a `persistenceUnit` override disagreed with the property's unit and was ignored. */
158
+ function spyOnUnitOverrideWarnings() {
159
+ return spyOnWarnings("Ignoring persistenceUnit override");
160
+ }
161
+ /** Appends a field on `propertyName` of the seeded element, and hands it back so the test can
162
+ * read its content after [[render]].
163
+ */
164
+ function appendField(block, propertyName, quantity) {
165
+ const field = FieldRun.create({
166
+ propertyHost: { elementId, schemaName: "FieldExample", className: "ExampleElement" },
167
+ propertyPath: { propertyName },
168
+ formatOptions: quantity ? { quantity } : undefined,
169
+ cachedContent: "unevaluated",
170
+ });
171
+ block.appendRun(field);
172
+ return field;
173
+ }
174
+ /** Registers a provider with the given FormatSets, then evaluates `block`'s fields in place.
175
+ * Each call replaces the previous registration, so a test's FormatSets never leak into the
176
+ * next one.
177
+ */
178
+ function render(block, formatSets = {}) {
179
+ ElementDrivesTextAnnotation.registerFieldFormatting({
180
+ iModel: imodel,
181
+ formatSet: formatSets.adopted,
182
+ formatSets: formatSets.byId,
183
+ });
184
+ ElementDrivesTextAnnotation.evaluateFields({ iModel: imodel, block });
185
+ }
186
+ it("formats integer-valued properties as quantities, leaving those that resolve no format raw", async () => {
187
+ // Classifying a numeric property as "quantity" only decides whether the KoQ/units pipeline is
188
+ // consulted; it is not a claim that the value has units. A count that names no
189
+ // KindOfQuantity anywhere resolves no spec and falls back to the same bare `toString()` the
190
+ // "string" formatter would have produced, so the classification costs it nothing -- while
191
+ // leaving the field-level override hatch open for callers who do declare one.
192
+ //
193
+ // Persisted on the element: intLengthProp 2500 (mm, int), intCountProp 42 (no KoQ)
194
+ const block = TextBlock.create();
195
+ const measured = appendField(block, "intLengthProp");
196
+ const counted = appendField(block, "intCountProp");
197
+ const overridden = appendField(block, "intCountProp", { kindOfQuantity: "FieldExample.SCHEMA_LENGTH", persistenceUnit: "Units.M" });
198
+ render(block);
199
+ // The property's own KoQ persists millimeters and presents meters: converted, not relabelled.
200
+ expect(measured.cachedContent).to.equal("2.5 m");
201
+ // Nothing names a KindOfQuantity, so no spec resolves and the count renders with no stray unit.
202
+ expect(counted.cachedContent).to.equal("42");
203
+ // Same property, but the field declares both halves of the override, which is the documented
204
+ // way to format a property that carries no KindOfQuantity of its own.
205
+ expect(overridden.cachedContent).to.equal("42.0 m");
206
+ });
207
+ it("renders a field with no format options from the property's own KindOfQuantity, and raw where it has none", async () => {
208
+ // Persisted on the element: lengthProp 2.5 m, areaProp 100 m², slopeProp 0.01 m/m, angleProp 90°,
209
+ // ratioProp 0.9 (dimensionless), point (1, 2, 3) m
210
+ const block = TextBlock.create();
211
+ const length = appendField(block, "lengthProp");
212
+ const area = appendField(block, "areaProp");
213
+ const slope = appendField(block, "slopeProp");
214
+ const angle = appendField(block, "angleProp");
215
+ const ratio = appendField(block, "ratioProp");
216
+ const point = appendField(block, "point");
217
+ render(block);
218
+ // lengthProp, areaProp and slopeProp each declare a KindOfQuantity, so with no FormatSet
219
+ // adopted the schema's own presentation format supplies the unit and precision.
220
+ expect(length.cachedContent).to.equal("2.5 m");
221
+ expect(area.cachedContent).to.equal("100.0 m²");
222
+ expect(slope.cachedContent).to.equal("0.01 m/m");
223
+ // angleProp, ratioProp and point declare none, and the field names none either, so there is
224
+ // nothing to resolve against and the persisted value is rendered as-is.
225
+ expect(angle.cachedContent).to.equal("90");
226
+ expect(ratio.cachedContent).to.equal("0.9");
227
+ expect(point.cachedContent).to.equal("(1, 2, 3)");
228
+ });
229
+ it("resolves a schema-defined KindOfQuantity for every property when the field supplies both the format and the unit", async () => {
230
+ // The only row in this group that resolves anything the baseline did not. Naming a KoQ the
231
+ // schema declares but no FormatSet defines reaches the SchemaFormatsProvider, and because the
232
+ // field also names the persistence unit, the properties that carry no KoQ of their own
233
+ // resolve too -- both halves of the key came from the field.
234
+ // Persisted on the element: lengthProp 2.5 m, areaProp 100 m², slopeProp 0.01 m/m, angleProp 90°,
235
+ // ratioProp 0.9 (dimensionless), point (1, 2, 3) m
236
+ const block = TextBlock.create();
237
+ const length = appendField(block, "lengthProp", { kindOfQuantity: "FieldExample.SCHEMA_LENGTH", persistenceUnit: "Units.M" });
238
+ const area = appendField(block, "areaProp", { kindOfQuantity: "FieldExample.SCHEMA_AREA", persistenceUnit: "Units.SQ_M" });
239
+ const slope = appendField(block, "slopeProp", { kindOfQuantity: "FieldExample.SCHEMA_SLOPE", persistenceUnit: "Units.M_PER_M" });
240
+ const angle = appendField(block, "angleProp", { kindOfQuantity: "FieldExample.SCHEMA_ANGLE", persistenceUnit: "Units.ARC_DEG" });
241
+ const ratio = appendField(block, "ratioProp", { kindOfQuantity: "FieldExample.SCHEMA_RATIO", persistenceUnit: "Units.ONE" });
242
+ const point = appendField(block, "point", { kindOfQuantity: "FieldExample.SCHEMA_LENGTH", persistenceUnit: "Units.M" });
243
+ render(block);
244
+ // The SCHEMA_* KoQs declare the same units as the properties do, at precision 2 rather than 4,
245
+ // so the KoQ-bearing properties look unchanged from the baseline.
246
+ expect(length.cachedContent).to.equal("2.5 m");
247
+ expect(area.cachedContent).to.equal("100.0 m²");
248
+ expect(slope.cachedContent).to.equal("0.01 m/m");
249
+ // These three are the ones that moved: raw in the baseline, formatted here.
250
+ expect(angle.cachedContent).to.equal("90.0 °");
251
+ expect(ratio.cachedContent).to.equal("0.9 one");
252
+ expect(point.cachedContent).to.equal("(1.0 m, 2.0 m, 3.0 m)");
253
+ });
254
+ it("ignores a KindOfQuantity named without a persistence unit unless the property supplies one", async () => {
255
+ // Same KoQs as above with the unit leg dropped. A format alone cannot be bound to a value --
256
+ // something has to say what unit the persisted number is in. The property's own KoQ answers
257
+ // that for the first three; nothing answers it for the rest.
258
+ // Persisted on the element: lengthProp 2.5 m, areaProp 100 m², slopeProp 0.01 m/m, angleProp 90°,
259
+ // ratioProp 0.9 (dimensionless), point (1, 2, 3) m
260
+ const block = TextBlock.create();
261
+ const length = appendField(block, "lengthProp", { kindOfQuantity: "FieldExample.SCHEMA_LENGTH" });
262
+ const area = appendField(block, "areaProp", { kindOfQuantity: "FieldExample.SCHEMA_AREA" });
263
+ const slope = appendField(block, "slopeProp", { kindOfQuantity: "FieldExample.SCHEMA_SLOPE" });
264
+ const angle = appendField(block, "angleProp", { kindOfQuantity: "FieldExample.SCHEMA_ANGLE" });
265
+ const ratio = appendField(block, "ratioProp", { kindOfQuantity: "FieldExample.SCHEMA_RATIO" });
266
+ const point = appendField(block, "point", { kindOfQuantity: "FieldExample.SCHEMA_LENGTH" });
267
+ render(block);
268
+ expect(length.cachedContent).to.equal("2.5 m");
269
+ expect(area.cachedContent).to.equal("100.0 m²");
270
+ expect(slope.cachedContent).to.equal("0.01 m/m");
271
+ // Back to raw, unlike the test above -- dropping the unit cost these three their formatting.
272
+ expect(angle.cachedContent).to.equal("90");
273
+ expect(ratio.cachedContent).to.equal("0.9");
274
+ expect(point.cachedContent).to.equal("(1, 2, 3)");
275
+ });
276
+ it("treats an empty persistenceUnit like an absent one", async () => {
277
+ // `persistenceUnit: ""` used to silently discard the kindOfQuantity override.
278
+ // Persisted on the element: lengthProp 2.5 m
279
+ const block = TextBlock.create();
280
+ const emptyUnit = appendField(block, "lengthProp", { kindOfQuantity: "FieldExample.SCHEMA_LENGTH", persistenceUnit: "" });
281
+ render(block);
282
+ expect(emptyUnit.cachedContent).to.equal("2.5 m");
283
+ });
284
+ it("accepts any case and either separator for names the iModel's schemas define", async () => {
285
+ // Override names are normalized to the schema's spelling before anything compares or looks
286
+ // them up. Without that, `units:m` on a meters property would be flagged as disagreeing with
287
+ // `Units.M`, and `fieldexample:schema_angle` would fail the exact-spelling unit lookup.
288
+ // Persisted on the element: lengthProp 2.5 m, angleProp 90° (no KindOfQuantity)
289
+ const block = TextBlock.create();
290
+ const respelledUnit = appendField(block, "lengthProp", { persistenceUnit: "units:m" });
291
+ const respelledBoth = appendField(block, "lengthProp", { kindOfQuantity: "fieldexample:schema_length", persistenceUnit: "UNITS.M" });
292
+ const respelledKoq = appendField(block, "angleProp", { kindOfQuantity: "fieldexample:schema_angle", persistenceUnit: "units:arc_deg" });
293
+ const ignored = spyOnUnitOverrideWarnings();
294
+ render(block);
295
+ expect(respelledUnit.cachedContent).to.equal("2.5 m");
296
+ expect(respelledBoth.cachedContent).to.equal("2.5 m");
297
+ expect(respelledKoq.cachedContent).to.equal("90.0 °");
298
+ expect(ignored()).to.be.empty;
299
+ });
300
+ it("ignores a persistence unit named without a KindOfQuantity", async () => {
301
+ // The mirror image: a unit with no format to apply to it. Nothing names a format, so the
302
+ // property-side candidate is all that is left and the result is the baseline exactly.
303
+ // Persisted on the element: lengthProp 2.5 m, areaProp 100 m², slopeProp 0.01 m/m, angleProp 90°,
304
+ // ratioProp 0.9 (dimensionless), point (1, 2, 3) m
305
+ const block = TextBlock.create();
306
+ const length = appendField(block, "lengthProp", { persistenceUnit: "Units.M" });
307
+ const area = appendField(block, "areaProp", { persistenceUnit: "Units.SQ_M" });
308
+ const slope = appendField(block, "slopeProp", { persistenceUnit: "Units.M_PER_M" });
309
+ const angle = appendField(block, "angleProp", { persistenceUnit: "Units.ARC_DEG" });
310
+ const ratio = appendField(block, "ratioProp", { persistenceUnit: "Units.ONE" });
311
+ const point = appendField(block, "point", { persistenceUnit: "Units.M" });
312
+ render(block);
313
+ expect(length.cachedContent).to.equal("2.5 m");
314
+ expect(area.cachedContent).to.equal("100.0 m²");
315
+ expect(slope.cachedContent).to.equal("0.01 m/m");
316
+ expect(angle.cachedContent).to.equal("90");
317
+ expect(ratio.cachedContent).to.equal("0.9");
318
+ expect(point.cachedContent).to.equal("(1, 2, 3)");
319
+ });
320
+ it("logs a warning when the persistence unit override does not exist", async () => {
321
+ // The format leg is fine and the unit leg is garbage. A property that carries its own unit
322
+ // ignores the override (and says so), so its KoQ override still formats. A property with no
323
+ // unit of its own has nothing else to try, so it goes raw and the shortfall is logged.
324
+ // Persisted on the element: lengthProp 2.5 m, areaProp 100 m², slopeProp 0.01 m/m, angleProp 90°,
325
+ // ratioProp 0.9 (dimensionless), point (1, 2, 3) m
326
+ const block = TextBlock.create();
327
+ const length = appendField(block, "lengthProp", { kindOfQuantity: "FieldExample.SCHEMA_LENGTH", persistenceUnit: "Units.NOT_A_UNIT" });
328
+ const area = appendField(block, "areaProp", { kindOfQuantity: "FieldExample.SCHEMA_AREA", persistenceUnit: "Units.NOT_A_UNIT" });
329
+ const slope = appendField(block, "slopeProp", { kindOfQuantity: "FieldExample.SCHEMA_SLOPE", persistenceUnit: "Units.NOT_A_UNIT" });
330
+ const angle = appendField(block, "angleProp", { kindOfQuantity: "FieldExample.SCHEMA_ANGLE", persistenceUnit: "Units.NOT_A_UNIT" });
331
+ const ratio = appendField(block, "ratioProp", { kindOfQuantity: "FieldExample.SCHEMA_RATIO", persistenceUnit: "Units.NOT_A_UNIT" });
332
+ const point = appendField(block, "point", { kindOfQuantity: "FieldExample.SCHEMA_LENGTH", persistenceUnit: "Units.NOT_A_UNIT" });
333
+ const warnings = spyOnFormatWarnings();
334
+ const ignored = spyOnUnitOverrideWarnings();
335
+ render(block);
336
+ expect(length.cachedContent).to.equal("2.5 m");
337
+ expect(area.cachedContent).to.equal("100.0 m²");
338
+ expect(slope.cachedContent).to.equal("0.01 m/m");
339
+ expect(angle.cachedContent).to.equal("90");
340
+ expect(ratio.cachedContent).to.equal("0.9");
341
+ expect(point.cachedContent).to.equal("(1, 2, 3)");
342
+ expect(ignored().map((w) => w.propertyName)).to.deep.equal(["lengthProp", "areaProp", "slopeProp"]);
343
+ expect(ignored()[0]).to.deep.equal({
344
+ elementId,
345
+ propertyName: "lengthProp",
346
+ persistenceUnit: "Units.NOT_A_UNIT",
347
+ propertyPersistenceUnit: "Units.M",
348
+ });
349
+ expect(warnings()).to.have.length(3);
350
+ expect(warnings().every((w) => w.tried.some((t) => t.endsWith(" in Units.NOT_A_UNIT")))).to.be.true;
351
+ // The metadata identifies the field so a host can find it, and lists exactly what was tried.
352
+ expect(warnings()[0]).to.deep.equal({
353
+ elementId,
354
+ propertyName: "angleProp",
355
+ formatSet: undefined,
356
+ tried: ["FieldExample.SCHEMA_ANGLE in Units.NOT_A_UNIT"],
357
+ });
358
+ });
359
+ it("ignores a valid persistence-unit override that disagrees with the property's own unit", async () => {
360
+ // The field claims the 2.5 stored on lengthProp is 2.5 *feet*. The schema says the property
361
+ // persists meters, and the schema is the authority on what a stored magnitude means: the
362
+ // override is a formatting hint, not a reinterpretation. The property's unit is used, and the
363
+ // disagreement is logged so the author can find the mistake.
364
+ // Persisted on the element: lengthProp 2.5 m
365
+ const block = TextBlock.create();
366
+ const claimsFeet = appendField(block, "lengthProp", { persistenceUnit: "Units.FT" });
367
+ const warnings = spyOnFormatWarnings();
368
+ const ignored = spyOnUnitOverrideWarnings();
369
+ render(block);
370
+ expect(claimsFeet.cachedContent).to.equal("2.5 m");
371
+ expect(warnings()).to.be.empty;
372
+ expect(ignored()).to.deep.equal([{
373
+ elementId,
374
+ propertyName: "lengthProp",
375
+ persistenceUnit: "Units.FT",
376
+ propertyPersistenceUnit: "Units.M",
377
+ }]);
378
+ });
379
+ it("falls back to the property's own format when the KindOfQuantity does not exist", async () => {
380
+ // The other leg fails this time -- a KoQ no FormatSet and no schema defines, paired with a
381
+ // perfectly good unit. The counterpart to the failed-unit case above, and deliberately not
382
+ // the same outcome: a unit override makes a claim about the stored magnitude, so failing it
383
+ // forfeits the property-side pair, while a KoQ override only chooses a presentation, so
384
+ // failing it still falls back to the property's own format.
385
+ // Persisted on the element: lengthProp 2.5 m, areaProp 100 m², slopeProp 0.01 m/m, angleProp 90°,
386
+ // ratioProp 0.9 (dimensionless), point (1, 2, 3) m
387
+ const block = TextBlock.create();
388
+ const length = appendField(block, "lengthProp", { kindOfQuantity: "Example.DOES_NOT_EXIST", persistenceUnit: "Units.M" });
389
+ const area = appendField(block, "areaProp", { kindOfQuantity: "Example.DOES_NOT_EXIST", persistenceUnit: "Units.SQ_M" });
390
+ const slope = appendField(block, "slopeProp", { kindOfQuantity: "Example.DOES_NOT_EXIST", persistenceUnit: "Units.M_PER_M" });
391
+ const angle = appendField(block, "angleProp", { kindOfQuantity: "Example.DOES_NOT_EXIST", persistenceUnit: "Units.ARC_DEG" });
392
+ const ratio = appendField(block, "ratioProp", { kindOfQuantity: "Example.DOES_NOT_EXIST", persistenceUnit: "Units.ONE" });
393
+ const point = appendField(block, "point", { kindOfQuantity: "Example.DOES_NOT_EXIST", persistenceUnit: "Units.M" });
394
+ render(block);
395
+ expect(length.cachedContent).to.equal("2.5 m");
396
+ expect(area.cachedContent).to.equal("100.0 m²");
397
+ expect(slope.cachedContent).to.equal("0.01 m/m");
398
+ expect(angle.cachedContent).to.equal("90");
399
+ expect(ratio.cachedContent).to.equal("0.9");
400
+ expect(point.cachedContent).to.equal("(1, 2, 3)");
401
+ });
402
+ it("falls back to the property's own pair when both legs of the override fail", async () => {
403
+ // The most degenerate row: nothing the field says can be resolved. The bad persistence unit
404
+ // is ignored in favor of the property's, and the unknown KoQ fails, so the property's own pair
405
+ // is the only candidate left. The three KoQ-bearing properties render their baseline; the
406
+ // three KoQ-less ones have nothing to fall back to and go raw. Asserted against baselines
407
+ // rendered in the same pass rather than captured literals, so the claim survives any change
408
+ // to the seed values or the schema.
409
+ // Persisted on the element: lengthProp 2.5 m, areaProp 100 m², slopeProp 0.01 m/m, angleProp 90°,
410
+ // ratioProp 0.9 (dimensionless), point (1, 2, 3) m
411
+ const block = TextBlock.create();
412
+ const bothLegsFail = { kindOfQuantity: "Example.DOES_NOT_EXIST", persistenceUnit: "Units.NOT_A_UNIT" };
413
+ const length = appendField(block, "lengthProp", bothLegsFail);
414
+ const area = appendField(block, "areaProp", bothLegsFail);
415
+ const slope = appendField(block, "slopeProp", bothLegsFail);
416
+ const angle = appendField(block, "angleProp", bothLegsFail);
417
+ const ratio = appendField(block, "ratioProp", bothLegsFail);
418
+ const point = appendField(block, "point", bothLegsFail);
419
+ const lengthBaseline = appendField(block, "lengthProp");
420
+ const areaBaseline = appendField(block, "areaProp");
421
+ const slopeBaseline = appendField(block, "slopeProp");
422
+ const angleBaseline = appendField(block, "angleProp");
423
+ const ratioBaseline = appendField(block, "ratioProp");
424
+ const pointBaseline = appendField(block, "point");
425
+ render(block);
426
+ expect(length.cachedContent).to.equal(lengthBaseline.cachedContent); // both "2.5 m"
427
+ expect(area.cachedContent).to.equal(areaBaseline.cachedContent); // both "100.0 m²"
428
+ expect(slope.cachedContent).to.equal(slopeBaseline.cachedContent); // both "0.01 m/m"
429
+ expect(angle.cachedContent).to.equal(angleBaseline.cachedContent); // both "90"
430
+ expect(ratio.cachedContent).to.equal(ratioBaseline.cachedContent); // both "0.9"
431
+ expect(point.cachedContent).to.equal(pointBaseline.cachedContent); // both "(1, 2, 3)"
432
+ });
433
+ it("prefers the adopted FormatSet's format over the property's own schema format", async () => {
434
+ // The happy path. Every field names a KoQ the adopted set defines and the unit its value is
435
+ // persisted in, so the FormatSet answers for all six -- including the three properties that
436
+ // carry no KoQ of their own, which the field has now supplied both halves of the key for.
437
+ // Persisted on the element: lengthProp 2.5 m, areaProp 100 m², slopeProp 0.01 m/m, angleProp 90°,
438
+ // ratioProp 0.9 (dimensionless), point (1, 2, 3) m
439
+ const block = TextBlock.create();
440
+ const length = appendField(block, "lengthProp", { kindOfQuantity: "Example.LENGTH", persistenceUnit: "Units.M" });
441
+ const area = appendField(block, "areaProp", { kindOfQuantity: "Example.AREA", persistenceUnit: "Units.SQ_M" });
442
+ const slope = appendField(block, "slopeProp", { kindOfQuantity: "Example.SLOPE", persistenceUnit: "Units.M_PER_M" });
443
+ const angle = appendField(block, "angleProp", { kindOfQuantity: "Example.ANGLE", persistenceUnit: "Units.ARC_DEG" });
444
+ const ratio = appendField(block, "ratioProp", { kindOfQuantity: "Example.RATIO", persistenceUnit: "Units.ONE" });
445
+ const point = appendField(block, "point", { kindOfQuantity: "Example.LENGTH", persistenceUnit: "Units.M" });
446
+ render(block, {
447
+ adopted: toFormatSet("Adopted", {
448
+ "Example.LENGTH": decimalFormat("Units.CM", "cm", 2),
449
+ "Example.AREA": decimalFormat("Units.SQ_FT", "ft2", 2),
450
+ "Example.SLOPE": decimalFormat("Units.HORIZONTAL_PER_VERTICAL", ":1", 2),
451
+ "Example.ANGLE": decimalFormat("Units.RAD", "rad", 4),
452
+ "Example.RATIO": decimalFormat("Units.ONE", "r", 4),
453
+ }),
454
+ });
455
+ // Each value is converted out of its persistence unit into the adopted format's unit -- the
456
+ // schema's own meter/square-meter/degree presentations no longer apply.
457
+ expect(length.cachedContent).to.equal("250 cm");
458
+ expect(area.cachedContent).to.equal("1076.39 ft2");
459
+ expect(slope.cachedContent).to.equal("100 :1");
460
+ expect(angle.cachedContent).to.equal("1.5708 rad");
461
+ expect(ratio.cachedContent).to.equal("0.9 r");
462
+ expect(point.cachedContent).to.equal("(100 cm, 200 cm, 300 cm)");
463
+ });
464
+ it("applies an adopted FormatSet format without a persistence unit only where the property declares one", async () => {
465
+ // Same adopted set, unit leg dropped. The property's KoQ supplies the missing half for the
466
+ // first three; the rest have nothing to convert from and stay raw. Note this is not the
467
+ // schema's *format* being used -- only its persistence unit, with the FormatSet's format.
468
+ // Persisted on the element: lengthProp 2.5 m, areaProp 100 m², slopeProp 0.01 m/m, angleProp 90°,
469
+ // ratioProp 0.9 (dimensionless), point (1, 2, 3) m
470
+ const block = TextBlock.create();
471
+ const length = appendField(block, "lengthProp", { kindOfQuantity: "Example.LENGTH" });
472
+ const area = appendField(block, "areaProp", { kindOfQuantity: "Example.AREA" });
473
+ const slope = appendField(block, "slopeProp", { kindOfQuantity: "Example.SLOPE" });
474
+ const angle = appendField(block, "angleProp", { kindOfQuantity: "Example.ANGLE" });
475
+ const ratio = appendField(block, "ratioProp", { kindOfQuantity: "Example.RATIO" });
476
+ const point = appendField(block, "point", { kindOfQuantity: "Example.LENGTH" });
477
+ render(block, {
478
+ adopted: toFormatSet("Adopted", {
479
+ "Example.LENGTH": decimalFormat("Units.CM", "cm", 2),
480
+ "Example.AREA": decimalFormat("Units.SQ_FT", "ft2", 2),
481
+ "Example.SLOPE": decimalFormat("Units.HORIZONTAL_PER_VERTICAL", ":1", 2),
482
+ "Example.ANGLE": decimalFormat("Units.RAD", "rad", 4),
483
+ "Example.RATIO": decimalFormat("Units.ONE", "r", 4),
484
+ }),
485
+ });
486
+ expect(length.cachedContent).to.equal("250 cm");
487
+ expect(area.cachedContent).to.equal("1076.39 ft2");
488
+ expect(slope.cachedContent).to.equal("100 :1");
489
+ // Unchanged from the baseline: no persistence unit from either side.
490
+ expect(angle.cachedContent).to.equal("90");
491
+ expect(ratio.cachedContent).to.equal("0.9");
492
+ expect(point.cachedContent).to.equal("(1, 2, 3)");
493
+ });
494
+ it("routes to the adopted FormatSet whether the field names its id, names nothing, or names an id that was never registered", async () => {
495
+ // Three ways of addressing the default formats, which must be indistinguishable: the adopted
496
+ // set is what a field gets when it asks for nothing, and an id absent from the registration
497
+ // falls back to it rather than failing or searching the other sets. Asserted against each
498
+ // other rather than against literals so it cannot drift with the formats above.
499
+ // Persisted on the element: lengthProp 2.5 m, angleProp 90°
500
+ const block = TextBlock.create();
501
+ const namesNothing = appendField(block, "lengthProp", { kindOfQuantity: "Example.LENGTH", persistenceUnit: "Units.M" });
502
+ const namesAdopted = appendField(block, "lengthProp", { formatSet: ADOPTED_SET, kindOfQuantity: "Example.LENGTH", persistenceUnit: "Units.M" });
503
+ const namesUnregistered = appendField(block, "lengthProp", { formatSet: UNREGISTERED_SET, kindOfQuantity: "Example.LENGTH", persistenceUnit: "Units.M" });
504
+ // Repeated on a property with no KoQ of its own, where a routing failure would fall all the
505
+ // way to raw rather than being masked by the property-side format.
506
+ const koqLessNamesNothing = appendField(block, "angleProp", { kindOfQuantity: "Example.ANGLE", persistenceUnit: "Units.ARC_DEG" });
507
+ const koqLessNamesAdopted = appendField(block, "angleProp", { formatSet: ADOPTED_SET, kindOfQuantity: "Example.ANGLE", persistenceUnit: "Units.ARC_DEG" });
508
+ const koqLessNamesUnregistered = appendField(block, "angleProp", { formatSet: UNREGISTERED_SET, kindOfQuantity: "Example.ANGLE", persistenceUnit: "Units.ARC_DEG" });
509
+ const adopted = toFormatSet("Adopted", {
510
+ "Example.LENGTH": decimalFormat("Units.CM", "cm", 2),
511
+ "Example.ANGLE": decimalFormat("Units.RAD", "rad", 4),
512
+ });
513
+ render(block, { adopted, byId: [{ id: ADOPTED_SET, formatSet: adopted }] });
514
+ expect(namesNothing.cachedContent).to.equal("250 cm");
515
+ expect(namesAdopted.cachedContent).to.equal(namesNothing.cachedContent); // both "250 cm"
516
+ expect(namesUnregistered.cachedContent).to.equal(namesNothing.cachedContent); // both "250 cm"
517
+ expect(koqLessNamesNothing.cachedContent).to.equal("1.5708 rad");
518
+ expect(koqLessNamesAdopted.cachedContent).to.equal(koqLessNamesNothing.cachedContent); // both "1.5708 rad"
519
+ expect(koqLessNamesUnregistered.cachedContent).to.equal(koqLessNamesNothing.cachedContent); // both "1.5708 rad"
520
+ });
521
+ it("uses an alternate FormatSet's format when the field names it, in preference to the adopted one", async () => {
522
+ // Mixing presentations within one iModel -- imperial callouts on an otherwise metric drawing.
523
+ // Both sets define every key here, so the alternate's redefinition is what must win.
524
+ // Persisted on the element: lengthProp 2.5 m, areaProp 100 m², slopeProp 0.01 m/m, angleProp 90°,
525
+ // ratioProp 0.9 (dimensionless), point (1, 2, 3) m
526
+ const block = TextBlock.create();
527
+ const length = appendField(block, "lengthProp", { formatSet: ALT_SET, kindOfQuantity: "Example.LENGTH", persistenceUnit: "Units.M" });
528
+ const area = appendField(block, "areaProp", { formatSet: ALT_SET, kindOfQuantity: "Example.AREA", persistenceUnit: "Units.SQ_M" });
529
+ const slope = appendField(block, "slopeProp", { formatSet: ALT_SET, kindOfQuantity: "Example.SLOPE", persistenceUnit: "Units.M_PER_M" });
530
+ const angle = appendField(block, "angleProp", { formatSet: ALT_SET, kindOfQuantity: "Example.ANGLE", persistenceUnit: "Units.ARC_DEG" });
531
+ const ratio = appendField(block, "ratioProp", { formatSet: ALT_SET, kindOfQuantity: "Example.RATIO", persistenceUnit: "Units.ONE" });
532
+ const point = appendField(block, "point", { formatSet: ALT_SET, kindOfQuantity: "Example.LENGTH", persistenceUnit: "Units.M" });
533
+ // The same field routed to the default formats, to prove the alternate set actually displaced
534
+ // the adopted one rather than both happening to agree.
535
+ const adoptedLength = appendField(block, "lengthProp", { kindOfQuantity: "Example.LENGTH", persistenceUnit: "Units.M" });
536
+ render(block, {
537
+ adopted: toFormatSet("Adopted", {
538
+ "Example.LENGTH": decimalFormat("Units.CM", "cm", 2),
539
+ "Example.AREA": decimalFormat("Units.SQ_M", "m2", 2),
540
+ "Example.SLOPE": decimalFormat("Units.M_PER_M", "m/m", 4),
541
+ "Example.ANGLE": decimalFormat("Units.ARC_DEG", "deg", 2),
542
+ "Example.RATIO": decimalFormat("Units.ONE", "ratio", 2),
543
+ }),
544
+ byId: [{
545
+ id: ALT_SET, formatSet: toFormatSet("Alternate", {
546
+ "Example.LENGTH": decimalFormat("Units.FT", "[alt]ft", 3),
547
+ "Example.AREA": decimalFormat("Units.SQ_FT", "[alt]ft2", 3),
548
+ "Example.SLOPE": decimalFormat("Units.HORIZONTAL_PER_VERTICAL", "[alt]:1", 2),
549
+ "Example.ANGLE": decimalFormat("Units.ARC_DEG", "[alt]deg", 1),
550
+ "Example.RATIO": decimalFormat("Units.ONE", "[alt]ratio", 1),
551
+ }),
552
+ }],
553
+ });
554
+ expect(length.cachedContent).to.equal("8.202 [alt]ft");
555
+ expect(area.cachedContent).to.equal("1076.391 [alt]ft2");
556
+ expect(slope.cachedContent).to.equal("100 [alt]:1");
557
+ expect(angle.cachedContent).to.equal("90 [alt]deg");
558
+ expect(ratio.cachedContent).to.equal("0.9 [alt]ratio");
559
+ expect(point.cachedContent).to.equal("(3.281 [alt]ft, 6.562 [alt]ft, 9.843 [alt]ft)");
560
+ expect(adoptedLength.cachedContent).to.equal("250 cm");
561
+ });
562
+ it("falls through from the named alternate FormatSet to the adopted one when the alternate omits the key", async () => {
563
+ // The alternate set is a partial overlay, not a replacement: a field routed to it still sees
564
+ // every key the adopted set defines.
565
+ // Persisted on the element: lengthProp 2.5 m, areaProp 100 m²
566
+ const block = TextBlock.create();
567
+ const redefined = appendField(block, "lengthProp", { formatSet: ALT_SET, kindOfQuantity: "Example.LENGTH", persistenceUnit: "Units.M" });
568
+ const omitted = appendField(block, "areaProp", { formatSet: ALT_SET, kindOfQuantity: "Example.AREA", persistenceUnit: "Units.SQ_M" });
569
+ render(block, {
570
+ adopted: toFormatSet("Adopted", {
571
+ "Example.LENGTH": decimalFormat("Units.CM", "cm", 2),
572
+ "Example.AREA": decimalFormat("Units.SQ_FT", "ft2", 2),
573
+ }),
574
+ byId: [{
575
+ id: ALT_SET, formatSet: toFormatSet("Alternate", {
576
+ // Redefines the length key and says nothing about area.
577
+ "Example.LENGTH": decimalFormat("Units.FT", "[alt]ft", 3),
578
+ }),
579
+ }],
580
+ });
581
+ expect(redefined.cachedContent).to.equal("8.202 [alt]ft");
582
+ expect(omitted.cachedContent).to.equal("1076.39 ft2");
583
+ });
584
+ it("routes a colon-separated KindOfQuantity name to the FormatSet that defines it in dot-separated form", async () => {
585
+ // Pins a normalization in another package. `FormatSetFormatsProvider.getFormatSync` in
586
+ // ecschema-metadata normalizes the name it is asked for via `SchemaItem.parseFullName`, which
587
+ // accepts `schemaName:itemName` (the form the node addon emits) as well as
588
+ // `schemaName.itemName`, so a set keyed in dot form must answer a field that names its KoQ in
589
+ // colon form.
590
+ //
591
+ // If that normalization ever regresses, the alternate set misses a key it does define and the
592
+ // field silently falls through to the *adopted* set instead -- no throw, no warning, because a
593
+ // spec did resolve. This test is the tripwire: it fails with the adopted set's "250 cm" rather
594
+ // than the alternate's "[alt]ft".
595
+ // Persisted on the element: lengthProp 2.5 m
596
+ const block = TextBlock.create();
597
+ const colonNamed = appendField(block, "lengthProp", { formatSet: ALT_SET, kindOfQuantity: "Example:LENGTH", persistenceUnit: "Units.M" });
598
+ const dotNamed = appendField(block, "lengthProp", { formatSet: ALT_SET, kindOfQuantity: "Example.LENGTH", persistenceUnit: "Units.M" });
599
+ render(block, {
600
+ // Defines the same key, so a regressed alternate set falls through to here and produces a
601
+ // plausible-looking string instead of an obvious failure.
602
+ adopted: toFormatSet("Adopted", { "Example.LENGTH": decimalFormat("Units.CM", "cm", 2) }),
603
+ byId: [{ id: ALT_SET, formatSet: toFormatSet("Alternate", { "Example.LENGTH": decimalFormat("Units.FT", "[alt]ft", 3) }) }],
604
+ });
605
+ expect(colonNamed.cachedContent).to.equal("8.202 [alt]ft");
606
+ expect(colonNamed.cachedContent).to.equal(dotNamed.cachedContent); // both "8.202 [alt]ft"
607
+ });
608
+ it("resolves a key only the alternate FormatSet defines only when the field names that set", async () => {
609
+ // The complement of the fallthrough above: the adopted set cannot see into the alternate
610
+ // one. An unregistered id lands on the adopted set, so it fails the same way naming
611
+ // nothing does -- the fallback is one specific set, not a search of every registered set.
612
+ // Persisted on the element: lengthProp 2.5 m
613
+ const block = TextBlock.create();
614
+ const namesAlternate = appendField(block, "lengthProp", { formatSet: ALT_SET, kindOfQuantity: "Example.ALT_ONLY_LENGTH", persistenceUnit: "Units.M" });
615
+ const namesNothing = appendField(block, "lengthProp", { kindOfQuantity: "Example.ALT_ONLY_LENGTH", persistenceUnit: "Units.M" });
616
+ const namesUnregistered = appendField(block, "lengthProp", { formatSet: UNREGISTERED_SET, kindOfQuantity: "Example.ALT_ONLY_LENGTH", persistenceUnit: "Units.M" });
617
+ render(block, {
618
+ adopted: toFormatSet("Adopted", { "Example.LENGTH": decimalFormat("Units.CM", "cm", 2) }),
619
+ byId: [{
620
+ id: ALT_SET, formatSet: toFormatSet("Alternate", {
621
+ "Example.ALT_ONLY_LENGTH": decimalFormat("Units.IN", "[alt-only]in", 2),
622
+ }),
623
+ }],
624
+ });
625
+ expect(namesAlternate.cachedContent).to.equal("98.43 [alt-only]in");
626
+ // Neither the adopted set nor the schema defines this key, so the property's own KoQ decides.
627
+ expect(namesNothing.cachedContent).to.equal("2.5 m");
628
+ expect(namesUnregistered.cachedContent).to.equal(namesNothing.cachedContent); // both "2.5 m"
629
+ });
630
+ it("reaches the schema from a named alternate FormatSet when neither set defines the key", async () => {
631
+ // The full chain in one field: alternate -> adopted -> SchemaFormatsProvider. Asserted on a
632
+ // property with no KoQ of its own, so a break anywhere in the chain shows up as a raw value
633
+ // rather than being masked by the property-side format.
634
+ // Persisted on the element: angleProp 90°
635
+ const block = TextBlock.create();
636
+ const angle = appendField(block, "angleProp", { formatSet: ALT_SET, kindOfQuantity: "FieldExample.SCHEMA_ANGLE", persistenceUnit: "Units.ARC_DEG" });
637
+ render(block, {
638
+ adopted: toFormatSet("Adopted", { "Example.LENGTH": decimalFormat("Units.CM", "cm", 2) }),
639
+ byId: [{ id: ALT_SET, formatSet: toFormatSet("Alternate", { "Example.LENGTH": decimalFormat("Units.FT", "[alt]ft", 3) }) }],
640
+ });
641
+ expect(angle.cachedContent).to.equal("90.0 °");
642
+ });
643
+ it("renders raw and logs a warning when the persistence unit is defined only by the iModel's schemas", async () => {
644
+ // Synchronous resolution goes through the bundled BIS units alone; SchemaUnitProvider has no
645
+ // synchronous path. A property persisted in a unit its own schema defines is therefore a
646
+ // known limitation: it renders raw and is logged, rather than being silently formatted
647
+ // through the wrong unit.
648
+ // Persisted on the element: customUnitProp 1.25 DOUBLE_M (= 2.5 m)
649
+ const block = TextBlock.create();
650
+ const custom = appendField(block, "customUnitProp");
651
+ const warnings = spyOnFormatWarnings();
652
+ render(block);
653
+ expect(custom.cachedContent).to.equal("1.25");
654
+ expect(warnings()).to.deep.equal([{ elementId, propertyName: "customUnitProp", formatSet: undefined, tried: ["FieldExample.CUSTOM_UNIT_PROP in FieldExample.DOUBLE_M"] }]);
655
+ });
656
+ it("routes a field with no KindOfQuantity override through a FormatSet keyed on the property's own KindOfQuantity", async () => {
657
+ // The shape a deployment is most likely to take: FormatSets keyed on the KindOfQuantities the
658
+ // schemas already declare, and fields that carry no overrides at all -- at most a FormatSet id
659
+ // to pick a presentation. Nothing here names a KoQ, so every candidate's `name` is the
660
+ // property's own `FieldExample.LENGTH_PROP`, supplied by the property-side pair. A FormatSet
661
+ // that defines that key is therefore reached without the field mentioning it.
662
+ //
663
+ // This is what makes `FieldSpecBucket.definesOwnFormat` load-bearing rather than incidental:
664
+ // it is asked about the property's KoQ, not only about an override.
665
+ // Persisted on the element: lengthProp 2.5 m
666
+ const block = TextBlock.create();
667
+ const namesAlternate = appendField(block, "lengthProp", { formatSet: ALT_SET });
668
+ const namesNothing = appendField(block, "lengthProp");
669
+ render(block, {
670
+ adopted: toFormatSet("Adopted", { "FieldExample.LENGTH_PROP": decimalFormat("Units.CM", "cm", 2) }),
671
+ byId: [{ id: ALT_SET, formatSet: toFormatSet("Alternate", { "FieldExample.LENGTH_PROP": decimalFormat("Units.FT", "[alt]ft", 3) }) }],
672
+ });
673
+ // Both are routed by their FormatSet, and neither falls back to the schema's own
674
+ // presentation format for LENGTH_PROP, which would render "2.5 m".
675
+ expect(namesAlternate.cachedContent).to.equal("8.202 [alt]ft");
676
+ expect(namesNothing.cachedContent).to.equal("250 cm");
677
+ });
678
+ it("does nothing when a field names a FormatSet whose formats it shares no key with", async () => {
679
+ // The complement of the test above. Naming a FormatSet does not by itself select anything:
680
+ // routing happens on the candidate's KoQ name, which here is the property's own
681
+ // `FieldExample.LENGTH_PROP` -- a key neither set defines. So both fields land on the
682
+ // schema's presentation format, and naming the alternate set changes nothing.
683
+ // Persisted on the element: lengthProp 2.5 m
684
+ const block = TextBlock.create();
685
+ const namesAlternate = appendField(block, "lengthProp", { formatSet: ALT_SET });
686
+ const noOptions = appendField(block, "lengthProp");
687
+ render(block, {
688
+ adopted: toFormatSet("Adopted", { "Example.LENGTH": decimalFormat("Units.CM", "cm", 2) }),
689
+ byId: [{ id: ALT_SET, formatSet: toFormatSet("Alternate", { "Example.LENGTH": decimalFormat("Units.FT", "[alt]ft", 3) }) }],
690
+ });
691
+ expect(namesAlternate.cachedContent).to.equal(noOptions.cachedContent); // both "2.5 m"
692
+ expect(namesAlternate.cachedContent).to.equal("2.5 m");
693
+ });
694
+ it("declines a format whose unit belongs to a different phenomenon than the persisted value", async () => {
695
+ // A format can only be applied if its unit is convertible from the persistence unit. An angle
696
+ // format cannot present a length, so the override is refused and the field falls back exactly
697
+ // as it would for any other unresolved format -- here, to the schema's own presentation format.
698
+ // Without that check the magnitude would be relabelled rather than converted ("2.5 deg"),
699
+ // because UnitsProvider.getConversion reports the mismatch by returning the *identity*
700
+ // conversion tagged `error: true`. See buildFieldFormatterSpec in fieldSpecs.ts.
701
+ // Persisted on the element: lengthProp 2.5 m
702
+ const block = TextBlock.create();
703
+ const crossed = appendField(block, "lengthProp", { kindOfQuantity: "Example.ANGLE", persistenceUnit: "Units.M" });
704
+ const baseline = appendField(block, "lengthProp");
705
+ render(block, {
706
+ adopted: toFormatSet("Adopted", { "Example.ANGLE": decimalFormat("Units.ARC_DEG", "deg", 2) }),
707
+ });
708
+ expect(baseline.cachedContent).to.equal("2.5 m");
709
+ expect(crossed.cachedContent).to.equal("2.5 m");
710
+ // Stated as an equality too, so the test keeps meaning "fell back to the baseline" rather
711
+ // than "happens to equal this literal" if the seed value ever changes.
712
+ expect(crossed.cachedContent).to.equal(baseline.cachedContent); // both "2.5 m"
713
+ });
714
+ it("degrades only the offending field when applying a format throws", async () => {
715
+ // FormatterSpec.applyFormatting is a much larger exception surface than the `toString()` it
716
+ // replaced. A throw used to escape updateField and updateFields, get swallowed by
717
+ // doUpdateFields, and abandon the whole element -- earlier fields mutated in memory, nothing
718
+ // persisted, one log line as the only trace. It must degrade this field alone.
719
+ // Persisted on the element: lengthProp 2.5 m, areaProp 100 m², slopeProp 0.01 m/m
720
+ const block = TextBlock.create();
721
+ const before = appendField(block, "areaProp");
722
+ const thrower = appendField(block, "lengthProp");
723
+ const after = appendField(block, "slopeProp");
724
+ ElementDrivesTextAnnotation.registerFieldFormatting({ iModel: imodel });
725
+ const applyFormatting = sinon.stub(FormatterSpec.prototype, "applyFormatting");
726
+ applyFormatting.callsFake(function (magnitude) {
727
+ if (2.5 === magnitude)
728
+ throw new Error("malformed format");
729
+ return applyFormatting.wrappedMethod.call(this, magnitude);
730
+ });
731
+ expect(() => ElementDrivesTextAnnotation.evaluateFields({ iModel: imodel, block })).to.not.throw();
732
+ expect(thrower.cachedContent).to.equal(FieldRun.invalidContentIndicator); // "####"
733
+ // The fields on either side of the failure still resolved and still persisted.
734
+ expect(before.cachedContent).to.equal("100.0 m²");
735
+ expect(after.cachedContent).to.equal("0.01 m/m");
736
+ });
737
+ });
738
+ //# sourceMappingURL=FieldFormat.test.js.map