@invarn/cibuild 2.3.1 → 2.3.3
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.
- package/dist/cli.cjs +196 -18
- package/dist/src/yaml/steps/structural-facts.d.ts +87 -0
- package/dist/src/yaml/steps/structural-facts.d.ts.map +1 -1
- package/dist/src/yaml/steps/structural-facts.js +0 -0
- package/dist/src/yaml/steps/structural-facts.test.js +644 -2
- package/dist/src/yaml/steps/ui-fidelity-render-android.d.ts.map +1 -1
- package/dist/src/yaml/steps/ui-fidelity-render-android.js +16 -1
- package/dist/src/yaml/steps/ui-fidelity-render-android.test.js +49 -1
- package/package.json +1 -1
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* overflowing voucher tag; the deriver must surface the bottom clip as a
|
|
9
9
|
* fact so it is never again skipped by eye.
|
|
10
10
|
*/
|
|
11
|
-
import { deriveStructuralFacts, derivePresenceFacts, deriveCopyFacts, deriveSpanFacts, deriveLineFacts, deriveColorFacts, ciede2000, hexToLab, diffKeyGeometry, buildStructuralBlock, buildReviewScaffold, appearanceReferenceFromPreviousRender, buildAppearanceReferenceFromParams, getStructuralFactsStageInternals, getGeometryStageInternals, } from './structural-facts.js';
|
|
11
|
+
import { deriveStructuralFacts, derivePresenceFacts, deriveCopyFacts, deriveSpanFacts, deriveLineFacts, deriveColorFacts, deriveTypefaceFacts, ciede2000, hexToLab, diffKeyGeometry, buildStructuralBlock, buildReviewScaffold, appearanceReferenceFromPreviousRender, buildAppearanceReferenceFromParams, getStructuralFactsStageInternals, getGeometryStageInternals, } from './structural-facts.js';
|
|
12
12
|
// 200x400 canvas in px for all fixtures unless noted.
|
|
13
13
|
const CANVAS = { left: 0, top: 0, right: 200, bottom: 400 };
|
|
14
14
|
describe('deriveStructuralFacts — containment', () => {
|
|
@@ -223,6 +223,18 @@ describe('embedded render-side stage parity', () => {
|
|
|
223
223
|
{ id: 'chip', bounds: { left: 150, top: 30, right: 195, bottom: 80 }, unclippedBounds: { left: 150, top: 30, right: 195, bottom: 80 }, parentId: 'row', clipsChildren: false },
|
|
224
224
|
],
|
|
225
225
|
},
|
|
226
|
+
// A duplicated testTag: the collision guard must withhold the child's
|
|
227
|
+
// (mispairable) overflow and emit the duplicate_tag fact identically.
|
|
228
|
+
duplicateTag: {
|
|
229
|
+
canvas: CANVAS,
|
|
230
|
+
nodes: [
|
|
231
|
+
{ id: 'list', bounds: { left: 0, top: 0, right: 200, bottom: 400 }, parentId: null, clipsChildren: false },
|
|
232
|
+
{ id: 'copy_button', bounds: { left: 20, top: 20, right: 180, bottom: 60 }, parentId: 'list', clipsChildren: false },
|
|
233
|
+
{ id: 'copy_button', bounds: { left: 20, top: 80, right: 180, bottom: 120 }, parentId: 'list', clipsChildren: false },
|
|
234
|
+
{ id: 'copy_button', bounds: { left: 20, top: 140, right: 180, bottom: 180 }, parentId: 'list', clipsChildren: false },
|
|
235
|
+
{ id: 'copy_label', bounds: { left: 30, top: 30, right: 120, bottom: 50 }, parentId: 'copy_button', clipsChildren: false },
|
|
236
|
+
],
|
|
237
|
+
},
|
|
226
238
|
};
|
|
227
239
|
for (const [name, dump] of Object.entries(fixtures)) {
|
|
228
240
|
test(`embedded port matches the module for the ${name} fixture`, () => {
|
|
@@ -256,6 +268,45 @@ describe('deriveStructuralFacts — motivating regression (voucher tag bottom-cl
|
|
|
256
268
|
});
|
|
257
269
|
});
|
|
258
270
|
});
|
|
271
|
+
describe('deriveStructuralFacts — duplicate testTag collision guard', () => {
|
|
272
|
+
// Repeated rows tagging their pill with one shared testTag: parent lookups
|
|
273
|
+
// resolve through a last-wins id map, so overflow/clip derived through the
|
|
274
|
+
// duplicated tag compares arbitrary instances (a child of the first row
|
|
275
|
+
// measured against the last row's box reads as a huge spurious overflow).
|
|
276
|
+
// The deriver must refuse to guess — name the collision as a fact and
|
|
277
|
+
// withhold the pairing-derived facts for that tag.
|
|
278
|
+
test('three nodes sharing one testTag yield a duplicate_tag fact and no overflow/clip facts through that tag', () => {
|
|
279
|
+
const dump = {
|
|
280
|
+
canvas: CANVAS,
|
|
281
|
+
nodes: [
|
|
282
|
+
{ id: 'list', bounds: { left: 0, top: 0, right: 200, bottom: 400 }, parentId: null, clipsChildren: false },
|
|
283
|
+
{ id: 'copy_button', bounds: { left: 20, top: 20, right: 180, bottom: 60 }, parentId: 'list', clipsChildren: false },
|
|
284
|
+
{ id: 'copy_button', bounds: { left: 20, top: 80, right: 180, bottom: 120 }, parentId: 'list', clipsChildren: false },
|
|
285
|
+
{ id: 'copy_button', bounds: { left: 20, top: 140, right: 180, bottom: 180 }, parentId: 'list', clipsChildren: false },
|
|
286
|
+
// Sits inside the FIRST pill; paired against the last-wins instance
|
|
287
|
+
// (top 140) it would read as a spurious 110px top overflow.
|
|
288
|
+
{ id: 'copy_label', bounds: { left: 30, top: 30, right: 120, bottom: 50 }, parentId: 'copy_button', clipsChildren: false },
|
|
289
|
+
],
|
|
290
|
+
};
|
|
291
|
+
expect(deriveStructuralFacts(dump)).toEqual([
|
|
292
|
+
{ kind: 'duplicate_tag', tag: 'copy_button', instanceCount: 3 },
|
|
293
|
+
]);
|
|
294
|
+
});
|
|
295
|
+
test('a duplicated node overflowing its unique parent is withheld too — attribution is ambiguous', () => {
|
|
296
|
+
const dump = {
|
|
297
|
+
canvas: CANVAS,
|
|
298
|
+
nodes: [
|
|
299
|
+
{ id: 'row', bounds: { left: 20, top: 20, right: 180, bottom: 100 }, parentId: null, clipsChildren: false },
|
|
300
|
+
// The first instance sticks out 15px past the row; the second is contained.
|
|
301
|
+
{ id: 'chip', bounds: { left: 150, top: 30, right: 195, bottom: 80 }, parentId: 'row', clipsChildren: false },
|
|
302
|
+
{ id: 'chip', bounds: { left: 30, top: 30, right: 60, bottom: 60 }, parentId: 'row', clipsChildren: false },
|
|
303
|
+
],
|
|
304
|
+
};
|
|
305
|
+
expect(deriveStructuralFacts(dump)).toEqual([
|
|
306
|
+
{ kind: 'duplicate_tag', tag: 'chip', instanceCount: 2 },
|
|
307
|
+
]);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
259
310
|
// ---- Signal B: key-geometry diff + inline structural-block assembly ----
|
|
260
311
|
describe('diffKeyGeometry — dimensional gaps', () => {
|
|
261
312
|
test('reports a size delta on the right element beyond tolerance', () => {
|
|
@@ -743,6 +794,57 @@ describe('deriveLineFacts — declared line split vs rendered laid-out lines', (
|
|
|
743
794
|
]);
|
|
744
795
|
});
|
|
745
796
|
});
|
|
797
|
+
describe('deriveTypefaceFacts — resolved vs design typeface per element', () => {
|
|
798
|
+
test('a family mismatch yields one typeface fact carrying both fonts', () => {
|
|
799
|
+
const facts = deriveTypefaceFacts({ code: { family: 'Roboto', weight: 400 } }, { code: { family: 'Poppins', weight: 600 } });
|
|
800
|
+
expect(facts).toEqual([
|
|
801
|
+
{
|
|
802
|
+
kind: 'typeface',
|
|
803
|
+
elementId: 'code',
|
|
804
|
+
channel: 'text',
|
|
805
|
+
rendered: { family: 'Roboto', weight: 400 },
|
|
806
|
+
reference: { family: 'Poppins', weight: 600 },
|
|
807
|
+
},
|
|
808
|
+
]);
|
|
809
|
+
});
|
|
810
|
+
test('a weight mismatch on the same family yields a typeface fact', () => {
|
|
811
|
+
const facts = deriveTypefaceFacts({ code: { family: 'Poppins', weight: 400 } }, { code: { family: 'Poppins', weight: 600 } });
|
|
812
|
+
expect(facts).toHaveLength(1);
|
|
813
|
+
expect(facts[0]).toMatchObject({ kind: 'typeface', elementId: 'code', channel: 'text' });
|
|
814
|
+
});
|
|
815
|
+
test('an exact family+weight match produces no fact', () => {
|
|
816
|
+
expect(deriveTypefaceFacts({ code: { family: 'Poppins', weight: 600 } }, { code: { family: 'Poppins', weight: 600 } })).toEqual([]);
|
|
817
|
+
});
|
|
818
|
+
test('a normalization-equivalent pair produces no fact (suffix-encoded weight vs numeric, case, separators)', () => {
|
|
819
|
+
// design names "Poppins-SemiBold"; render resolved "Poppins" weight 600 — same font.
|
|
820
|
+
expect(deriveTypefaceFacts({ code: { family: 'Poppins', weight: 600 } }, { code: { family: 'Poppins-SemiBold' } })).toEqual([]);
|
|
821
|
+
// resource-name encoding "fira_sans_medium" vs design "Fira Sans" weight 500.
|
|
822
|
+
expect(deriveTypefaceFacts({ code: { family: 'fira_sans_medium' } }, { code: { family: 'Fira Sans', weight: 500 } })).toEqual([]);
|
|
823
|
+
// pure case difference on the family.
|
|
824
|
+
expect(deriveTypefaceFacts({ code: { family: 'POPPINS', weight: 600 } }, { code: { family: 'poppins', weight: 600 } })).toEqual([]);
|
|
825
|
+
});
|
|
826
|
+
test('a suffix-encoded family that disagrees on weight still flags', () => {
|
|
827
|
+
// design "Poppins-SemiBold" (→600) vs resolved "Poppins" Regular (400) — a real
|
|
828
|
+
// weight fallback the suffix normalization must NOT mask.
|
|
829
|
+
const facts = deriveTypefaceFacts({ code: { family: 'Poppins', weight: 400 } }, { code: { family: 'Poppins-SemiBold' } });
|
|
830
|
+
expect(facts).toHaveLength(1);
|
|
831
|
+
expect(facts[0]).toMatchObject({ kind: 'typeface', elementId: 'code' });
|
|
832
|
+
});
|
|
833
|
+
test('a missing rendered or reference font produces no fact (graceful, no false positive)', () => {
|
|
834
|
+
// reference declares a font the render never resolved → no signal → no fact
|
|
835
|
+
expect(deriveTypefaceFacts({ code: undefined }, { code: { family: 'Poppins', weight: 600 } })).toEqual([]);
|
|
836
|
+
expect(deriveTypefaceFacts({ code: { family: '' } }, { code: { family: 'Poppins', weight: 600 } })).toEqual([]);
|
|
837
|
+
// render resolved a font the reference never declared → no reference signal → no fact
|
|
838
|
+
expect(deriveTypefaceFacts({ code: { family: 'Roboto', weight: 400 } }, {})).toEqual([]);
|
|
839
|
+
expect(deriveTypefaceFacts({ code: { family: 'Roboto', weight: 400 } }, { code: { family: '' } })).toEqual([]);
|
|
840
|
+
});
|
|
841
|
+
test('result is order-independent and deterministic (sorted by element id)', () => {
|
|
842
|
+
const a = deriveTypefaceFacts({ b: { family: 'Roboto', weight: 400 }, a: { family: 'Arial', weight: 400 } }, { a: { family: 'Poppins', weight: 600 }, b: { family: 'Roboto', weight: 400 } });
|
|
843
|
+
const b = deriveTypefaceFacts({ a: { family: 'Arial', weight: 400 }, b: { family: 'Roboto', weight: 400 } }, { b: { family: 'Roboto', weight: 400 }, a: { family: 'Poppins', weight: 600 } });
|
|
844
|
+
expect(a).toEqual(b);
|
|
845
|
+
expect(a.map((f) => f.elementId)).toEqual(['a']);
|
|
846
|
+
});
|
|
847
|
+
});
|
|
746
848
|
describe('buildStructuralBlock — appearance container (Signal C, additive)', () => {
|
|
747
849
|
test('no appearanceReference → no appearance container; facts + geometry unchanged', () => {
|
|
748
850
|
const withC = buildStructuralBlock(HUB_PROMO_DUMP, HUB_PROMO_REFERENCE, 2);
|
|
@@ -960,6 +1062,117 @@ describe('buildStructuralBlock — appearance container (Signal C, additive)', (
|
|
|
960
1062
|
const block = buildStructuralBlock(COLOR_DUMP, null, 2);
|
|
961
1063
|
expect(block.appearance).toBeUndefined();
|
|
962
1064
|
});
|
|
1065
|
+
// ---- Signal C typeface facts (resolved vs design font, issue 03) ----
|
|
1066
|
+
// A dump whose text node carries the resolved-typeface enrichment (issue 03):
|
|
1067
|
+
// the font that ACTUALLY painted, post-fallback — here a Roboto fallback where
|
|
1068
|
+
// the design wants Poppins SemiBold. The dump also declares the render SDK the
|
|
1069
|
+
// proof ran at (TextRunShaper, the capture seam, is API 31+).
|
|
1070
|
+
const TYPEFACE_DUMP = {
|
|
1071
|
+
canvas: { left: 0, top: 0, right: 200, bottom: 400 },
|
|
1072
|
+
renderSdk: 34,
|
|
1073
|
+
nodes: [
|
|
1074
|
+
{
|
|
1075
|
+
id: 'code',
|
|
1076
|
+
bounds: { left: 0, top: 0, right: 100, bottom: 20 },
|
|
1077
|
+
parentId: null,
|
|
1078
|
+
clipsChildren: false,
|
|
1079
|
+
text: 'INV-50',
|
|
1080
|
+
resolvedTypeface: { family: 'Roboto', weight: 400 },
|
|
1081
|
+
},
|
|
1082
|
+
],
|
|
1083
|
+
};
|
|
1084
|
+
test('with a reference typeface → a typeface fact fires on divergence and the resolved font is persisted (issue 03)', () => {
|
|
1085
|
+
const block = buildStructuralBlock(TYPEFACE_DUMP, null, 2, undefined, {
|
|
1086
|
+
baseline: 'figma',
|
|
1087
|
+
elementIds: ['code'],
|
|
1088
|
+
typeface: { code: { family: 'Poppins', weight: 600 } },
|
|
1089
|
+
});
|
|
1090
|
+
expect(block.appearance?.facts).toEqual([
|
|
1091
|
+
{
|
|
1092
|
+
kind: 'typeface',
|
|
1093
|
+
elementId: 'code',
|
|
1094
|
+
channel: 'text',
|
|
1095
|
+
rendered: { family: 'Roboto', weight: 400 },
|
|
1096
|
+
reference: { family: 'Poppins', weight: 600 },
|
|
1097
|
+
},
|
|
1098
|
+
]);
|
|
1099
|
+
expect(block.appearance?.rendered?.typeface).toEqual({ code: { family: 'Roboto', weight: 400 } });
|
|
1100
|
+
});
|
|
1101
|
+
test('a matching resolved typeface → no fact; the snapshot still persists the resolved font (issue 03)', () => {
|
|
1102
|
+
const block = buildStructuralBlock(TYPEFACE_DUMP, null, 2, undefined, {
|
|
1103
|
+
baseline: 'figma',
|
|
1104
|
+
elementIds: ['code'],
|
|
1105
|
+
typeface: { code: { family: 'Roboto', weight: 400 } },
|
|
1106
|
+
});
|
|
1107
|
+
expect(block.appearance?.facts).toEqual([]);
|
|
1108
|
+
expect(block.appearance?.rendered?.typeface).toEqual({ code: { family: 'Roboto', weight: 400 } });
|
|
1109
|
+
});
|
|
1110
|
+
test('a node without the enrichment yields no typeface signal and no snapshot entry (old packages degrade gracefully)', () => {
|
|
1111
|
+
const block = buildStructuralBlock(COPY_DUMP, null, 2, undefined, {
|
|
1112
|
+
baseline: 'figma',
|
|
1113
|
+
elementIds: ['title'],
|
|
1114
|
+
typeface: { title: { family: 'Poppins', weight: 600 } },
|
|
1115
|
+
});
|
|
1116
|
+
expect(block.appearance?.facts).toEqual([]);
|
|
1117
|
+
expect(block.appearance?.rendered?.typeface).toBeUndefined();
|
|
1118
|
+
});
|
|
1119
|
+
test('a render SDK below 31 omits typeface facts AND carries the advisory — never a silent pass (issue 03)', () => {
|
|
1120
|
+
const oldSdkDump = {
|
|
1121
|
+
canvas: { left: 0, top: 0, right: 200, bottom: 400 },
|
|
1122
|
+
renderSdk: 28,
|
|
1123
|
+
nodes: [
|
|
1124
|
+
{ id: 'code', bounds: { left: 0, top: 0, right: 100, bottom: 20 }, parentId: null, clipsChildren: false, text: 'INV-50' },
|
|
1125
|
+
],
|
|
1126
|
+
};
|
|
1127
|
+
const block = buildStructuralBlock(oldSdkDump, null, 2, undefined, {
|
|
1128
|
+
baseline: 'figma',
|
|
1129
|
+
elementIds: ['code'],
|
|
1130
|
+
typeface: { code: { family: 'Poppins', weight: 600 } },
|
|
1131
|
+
});
|
|
1132
|
+
expect(block.appearance?.facts).toEqual([]);
|
|
1133
|
+
expect(block.appearance?.advisories).toEqual([
|
|
1134
|
+
'typeface check requires render SDK >= 31; this proof rendered at SDK 28 — typeface facts were not derived (not checked, not passing)',
|
|
1135
|
+
]);
|
|
1136
|
+
});
|
|
1137
|
+
test('a render SDK below 31 without a typeface reference carries no advisory (nothing was checkable)', () => {
|
|
1138
|
+
const oldSdkDump = {
|
|
1139
|
+
canvas: { left: 0, top: 0, right: 200, bottom: 400 },
|
|
1140
|
+
renderSdk: 28,
|
|
1141
|
+
nodes: [
|
|
1142
|
+
{ id: 'code', bounds: { left: 0, top: 0, right: 100, bottom: 20 }, parentId: null, clipsChildren: false },
|
|
1143
|
+
],
|
|
1144
|
+
};
|
|
1145
|
+
const block = buildStructuralBlock(oldSdkDump, null, 2, undefined, {
|
|
1146
|
+
baseline: 'figma',
|
|
1147
|
+
elementIds: ['code'],
|
|
1148
|
+
});
|
|
1149
|
+
expect(block.appearance?.advisories).toBeUndefined();
|
|
1150
|
+
});
|
|
1151
|
+
test('a render SDK >= 31 or an undeclared one carries no advisory; facts derive normally', () => {
|
|
1152
|
+
// Declared modern SDK → facts derive, no advisory.
|
|
1153
|
+
const modern = buildStructuralBlock(TYPEFACE_DUMP, null, 2, undefined, {
|
|
1154
|
+
baseline: 'figma',
|
|
1155
|
+
elementIds: ['code'],
|
|
1156
|
+
typeface: { code: { family: 'Poppins', weight: 600 } },
|
|
1157
|
+
});
|
|
1158
|
+
expect(modern.appearance?.advisories).toBeUndefined();
|
|
1159
|
+
expect(modern.appearance?.facts).toHaveLength(1);
|
|
1160
|
+
// Undeclared SDK (an old package's dump): no advisory, derivation still runs
|
|
1161
|
+
// (it just finds no rendered typefaces → no facts, the graceful path).
|
|
1162
|
+
const undeclared = {
|
|
1163
|
+
canvas: { left: 0, top: 0, right: 200, bottom: 400 },
|
|
1164
|
+
nodes: [
|
|
1165
|
+
{ id: 'code', bounds: { left: 0, top: 0, right: 100, bottom: 20 }, parentId: null, clipsChildren: false },
|
|
1166
|
+
],
|
|
1167
|
+
};
|
|
1168
|
+
const block = buildStructuralBlock(undeclared, null, 2, undefined, {
|
|
1169
|
+
baseline: 'figma',
|
|
1170
|
+
elementIds: ['code'],
|
|
1171
|
+
typeface: { code: { family: 'Poppins', weight: 600 } },
|
|
1172
|
+
});
|
|
1173
|
+
expect(block.appearance?.advisories).toBeUndefined();
|
|
1174
|
+
expect(block.appearance?.facts).toEqual([]);
|
|
1175
|
+
});
|
|
963
1176
|
});
|
|
964
1177
|
// ---- Signal C: previous-render → AppearanceReference mapper (issue 01) ----
|
|
965
1178
|
//
|
|
@@ -1008,6 +1221,47 @@ describe('appearanceReferenceFromPreviousRender — previous render snapshot →
|
|
|
1008
1221
|
text: { copy: 'Get 5 € off your first order placed by 1 lug 2026.' },
|
|
1009
1222
|
});
|
|
1010
1223
|
});
|
|
1224
|
+
test('a previous render with per-element resolved typefaces → the mapper carries typeface into the reference (typeface issue 05)', () => {
|
|
1225
|
+
// The previous render's RESOLVED font becomes the drift reference: a
|
|
1226
|
+
// previously-correct element that silently falls back on a later build then
|
|
1227
|
+
// diverges from its own prior snapshot and is flagged.
|
|
1228
|
+
const previous = {
|
|
1229
|
+
elementIds: ['banner', 'code'],
|
|
1230
|
+
typeface: { code: { family: 'poppins_semibold', weight: 600 } },
|
|
1231
|
+
};
|
|
1232
|
+
expect(appearanceReferenceFromPreviousRender(previous)).toEqual({
|
|
1233
|
+
baseline: 'previous_render',
|
|
1234
|
+
elementIds: ['banner', 'code'],
|
|
1235
|
+
typeface: { code: { family: 'poppins_semibold', weight: 600 } },
|
|
1236
|
+
});
|
|
1237
|
+
// An empty typeface map is no signal on that axis — not carried.
|
|
1238
|
+
expect(appearanceReferenceFromPreviousRender({ elementIds: ['banner'], typeface: {} })).toEqual({ baseline: 'previous_render', elementIds: ['banner'] });
|
|
1239
|
+
});
|
|
1240
|
+
test('fed into buildStructuralBlock, a typeface that drifts from the previous render yields a previous_render typeface fact (typeface issue 05)', () => {
|
|
1241
|
+
const dump = {
|
|
1242
|
+
canvas: { left: 0, top: 0, right: 343, bottom: 72 },
|
|
1243
|
+
renderSdk: 34,
|
|
1244
|
+
nodes: [
|
|
1245
|
+
{ id: 'code', bounds: { left: 0, top: 0, right: 343, bottom: 40 }, parentId: null, clipsChildren: false, text: 'INV-50', resolvedTypeface: { family: 'Roboto-Regular', weight: 400 } },
|
|
1246
|
+
],
|
|
1247
|
+
};
|
|
1248
|
+
const previous = {
|
|
1249
|
+
elementIds: ['code'],
|
|
1250
|
+
typeface: { code: { family: 'poppins_semibold' } },
|
|
1251
|
+
};
|
|
1252
|
+
const block = buildStructuralBlock(dump, null, 2, undefined, appearanceReferenceFromPreviousRender(previous));
|
|
1253
|
+
expect(block.appearance?.baseline).toBe('previous_render');
|
|
1254
|
+
const typefaceFacts = (block.appearance?.facts ?? []).filter((f) => f.kind === 'typeface');
|
|
1255
|
+
expect(typefaceFacts).toEqual([
|
|
1256
|
+
{
|
|
1257
|
+
kind: 'typeface',
|
|
1258
|
+
elementId: 'code',
|
|
1259
|
+
channel: 'text',
|
|
1260
|
+
rendered: { family: 'Roboto-Regular', weight: 400 },
|
|
1261
|
+
reference: { family: 'poppins_semibold' },
|
|
1262
|
+
},
|
|
1263
|
+
]);
|
|
1264
|
+
});
|
|
1011
1265
|
test('a previous render with both color and text → the reference carries both maps (issue 03)', () => {
|
|
1012
1266
|
const previous = {
|
|
1013
1267
|
elementIds: ['banner', 'copy'],
|
|
@@ -1256,9 +1510,54 @@ describe('buildAppearanceReferenceFromParams — union of geometry/text/color id
|
|
|
1256
1510
|
lines: { promoCopy: ['Get 5 EUR off', 'placed by tomorrow.'] },
|
|
1257
1511
|
});
|
|
1258
1512
|
});
|
|
1513
|
+
test('typeface only (no geometry/text/color) → a reference carrying typeface, ids from the typeface keys (typeface issue 04)', () => {
|
|
1514
|
+
const ref = buildAppearanceReferenceFromParams({
|
|
1515
|
+
refTypeface: { code: { family: 'Poppins', weight: 600 } },
|
|
1516
|
+
baseline: 'figma',
|
|
1517
|
+
});
|
|
1518
|
+
expect(ref).toEqual({
|
|
1519
|
+
baseline: 'figma',
|
|
1520
|
+
elementIds: ['code'],
|
|
1521
|
+
typeface: { code: { family: 'Poppins', weight: 600 } },
|
|
1522
|
+
});
|
|
1523
|
+
});
|
|
1259
1524
|
test('none present → null (no appearance container)', () => {
|
|
1260
1525
|
expect(buildAppearanceReferenceFromParams({ baseline: 'figma' })).toBeNull();
|
|
1261
|
-
expect(buildAppearanceReferenceFromParams({ baseline: 'figma', refGeom: {}, refColor: {}, refText: {}, refSpans: {}, refLines: {} })).toBeNull();
|
|
1526
|
+
expect(buildAppearanceReferenceFromParams({ baseline: 'figma', refGeom: {}, refColor: {}, refText: {}, refSpans: {}, refLines: {}, refTypeface: {} })).toBeNull();
|
|
1527
|
+
});
|
|
1528
|
+
test('fed into buildStructuralBlock, a typeface reference flags a diverging resolved font and stays silent on a matching or unresolvable one (typeface issue 04)', () => {
|
|
1529
|
+
// The design (Figma) declares Poppins 600 on `code` and Fira Sans 500 on
|
|
1530
|
+
// `label`. The render resolved a Roboto fallback on `code` (→ one fact) and
|
|
1531
|
+
// the correct font on `label` (→ none); `legacyText` has a declared font but
|
|
1532
|
+
// no resolved enrichment (old package) → no signal, no fact.
|
|
1533
|
+
const dump = {
|
|
1534
|
+
canvas: { left: 0, top: 0, right: 686, bottom: 282 },
|
|
1535
|
+
renderSdk: 34,
|
|
1536
|
+
nodes: [
|
|
1537
|
+
{ id: 'code', bounds: { left: 0, top: 0, right: 300, bottom: 40 }, parentId: null, clipsChildren: false, text: 'INV-50', resolvedTypeface: { family: 'Roboto-Regular', weight: 400 } },
|
|
1538
|
+
{ id: 'label', bounds: { left: 0, top: 50, right: 300, bottom: 90 }, parentId: null, clipsChildren: false, text: 'Your code', resolvedTypeface: { family: 'fira_sans_medium' } },
|
|
1539
|
+
{ id: 'legacyText', bounds: { left: 0, top: 100, right: 300, bottom: 140 }, parentId: null, clipsChildren: false, text: 'Old' },
|
|
1540
|
+
],
|
|
1541
|
+
};
|
|
1542
|
+
const ref = buildAppearanceReferenceFromParams({
|
|
1543
|
+
refTypeface: {
|
|
1544
|
+
code: { family: 'Poppins', weight: 600 },
|
|
1545
|
+
label: { family: 'Fira Sans', weight: 500 },
|
|
1546
|
+
legacyText: { family: 'Poppins', weight: 400 },
|
|
1547
|
+
},
|
|
1548
|
+
baseline: 'figma',
|
|
1549
|
+
});
|
|
1550
|
+
const block = buildStructuralBlock(dump, null, 2, undefined, ref);
|
|
1551
|
+
const typefaceFacts = (block.appearance?.facts ?? []).filter((f) => f.kind === 'typeface');
|
|
1552
|
+
expect(typefaceFacts).toEqual([
|
|
1553
|
+
{
|
|
1554
|
+
kind: 'typeface',
|
|
1555
|
+
elementId: 'code',
|
|
1556
|
+
channel: 'text',
|
|
1557
|
+
rendered: { family: 'Roboto-Regular', weight: 400 },
|
|
1558
|
+
reference: { family: 'Poppins', weight: 600 },
|
|
1559
|
+
},
|
|
1560
|
+
]);
|
|
1262
1561
|
});
|
|
1263
1562
|
test('fed into buildStructuralBlock, a spans-only reference emits exactly one span_mismatch for the wrong extent (issue 16)', () => {
|
|
1264
1563
|
const dump = {
|
|
@@ -1524,6 +1823,235 @@ describe('buildStructuralBlock — placement facts (composition runs)', () => {
|
|
|
1524
1823
|
}
|
|
1525
1824
|
});
|
|
1526
1825
|
});
|
|
1826
|
+
describe('buildStructuralBlock — duplicate testTag withhold (geometry / overhang / placement)', () => {
|
|
1827
|
+
// A duplicated tag's entry in the generated-geometry map is last-wins, so its
|
|
1828
|
+
// size diff, any overhang computed against it as a frame, and its placement
|
|
1829
|
+
// drift would all compare an arbitrary instance. Each is withheld; the
|
|
1830
|
+
// deriver's duplicate_tag fact carries the collision.
|
|
1831
|
+
test('a duplicated tag gets no geometry element — no size/clip facts for it', () => {
|
|
1832
|
+
const dump = {
|
|
1833
|
+
canvas: { left: 0, top: 0, right: 360, bottom: 200 },
|
|
1834
|
+
nodes: [
|
|
1835
|
+
{ id: 'card', bounds: { left: 0, top: 0, right: 360, bottom: 200 }, parentId: null, clipsChildren: false },
|
|
1836
|
+
// 64×33 rendered vs 60×36 declared — would sizeFlag if compared.
|
|
1837
|
+
{ id: 'pill', bounds: { left: 10, top: 10, right: 74, bottom: 43 }, parentId: 'card', clipsChildren: false },
|
|
1838
|
+
{ id: 'pill', bounds: { left: 10, top: 60, right: 74, bottom: 93 }, parentId: 'card', clipsChildren: false },
|
|
1839
|
+
],
|
|
1840
|
+
};
|
|
1841
|
+
const block = buildStructuralBlock(dump, { card: { w: 360, h: 200 }, pill: { w: 60, h: 36 } }, 1);
|
|
1842
|
+
expect(block.facts).toContainEqual({ kind: 'duplicate_tag', tag: 'pill', instanceCount: 2 });
|
|
1843
|
+
expect(block.geometry.elements.map((e) => e.id)).toEqual(['card']);
|
|
1844
|
+
expect(block.summary.clean).toBe(false);
|
|
1845
|
+
});
|
|
1846
|
+
test('no overhang is computed against a duplicated frame — the pairing would be arbitrary', () => {
|
|
1847
|
+
const dump = {
|
|
1848
|
+
canvas: { left: 0, top: 0, right: 360, bottom: 400 },
|
|
1849
|
+
nodes: [
|
|
1850
|
+
{ id: 'banner', bounds: { left: 0, top: 0, right: 343, bottom: 72 }, parentId: null, clipsChildren: false },
|
|
1851
|
+
{ id: 'banner', bounds: { left: 0, top: 100, right: 343, bottom: 172 }, parentId: null, clipsChildren: false },
|
|
1852
|
+
// Child of the duplicated banner: vs the last-wins instance it would
|
|
1853
|
+
// read a spurious 100dp top clip.
|
|
1854
|
+
{ id: 'coin', bounds: { left: 300, top: 40, right: 340, bottom: 90 }, parentId: 'banner', clipsChildren: false },
|
|
1855
|
+
],
|
|
1856
|
+
};
|
|
1857
|
+
const block = buildStructuralBlock(dump, { banner: { x: 0, y: 0, w: 343, h: 72 }, coin: { x: 300, y: 40, w: 40, h: 50 } }, 1);
|
|
1858
|
+
const coin = block.geometry.elements.find((e) => e.id === 'coin');
|
|
1859
|
+
expect(coin).toBeDefined();
|
|
1860
|
+
expect(coin.overhang).toBeUndefined();
|
|
1861
|
+
});
|
|
1862
|
+
test('placement facts skip a duplicated member tag (composition runs)', () => {
|
|
1863
|
+
const dump = {
|
|
1864
|
+
canvas: { left: 0, top: 0, right: 360, bottom: 640 },
|
|
1865
|
+
nodes: [
|
|
1866
|
+
{ id: 'root', bounds: { left: 0, top: 0, right: 360, bottom: 640 }, parentId: null, clipsChildren: false },
|
|
1867
|
+
{ id: 'member', bounds: { left: 0, top: 0, right: 360, bottom: 100 }, parentId: 'root', clipsChildren: false },
|
|
1868
|
+
{ id: 'member', bounds: { left: 0, top: 200, right: 360, bottom: 300 }, parentId: 'root', clipsChildren: false },
|
|
1869
|
+
// Genuinely misplaced: rendered y 500 vs design y 540.
|
|
1870
|
+
{ id: 'footer', bounds: { left: 0, top: 500, right: 360, bottom: 640 }, parentId: 'root', clipsChildren: false },
|
|
1871
|
+
],
|
|
1872
|
+
};
|
|
1873
|
+
const block = buildStructuralBlock(dump, { member: { x: 0, y: 0, w: 360, h: 100 }, footer: { x: 0, y: 540, w: 360, h: 140 } }, 1, undefined, undefined, { w: 360, h: 640 });
|
|
1874
|
+
expect(block.placement.withheld).toBe(false);
|
|
1875
|
+
expect(block.placement.facts.map((f) => f.elementId)).toEqual(['footer']);
|
|
1876
|
+
});
|
|
1877
|
+
test('the review scaffold rows the collision under the tag with the re-tag remediation', () => {
|
|
1878
|
+
const scaffold = buildReviewScaffold({
|
|
1879
|
+
facts: [{ kind: 'duplicate_tag', tag: 'copy_button', instanceCount: 3 }],
|
|
1880
|
+
});
|
|
1881
|
+
expect(scaffold.mustExplain).toEqual(['copy_button']);
|
|
1882
|
+
expect(scaffold.rows).toHaveLength(1);
|
|
1883
|
+
expect(scaffold.rows[0].element).toBe('copy_button');
|
|
1884
|
+
expect(scaffold.rows[0].fact).toContain('3 nodes');
|
|
1885
|
+
expect(scaffold.rows[0].fact).toContain('unique testTag');
|
|
1886
|
+
});
|
|
1887
|
+
});
|
|
1888
|
+
describe('buildStructuralBlock — text-aware size tolerance (line-box residue)', () => {
|
|
1889
|
+
// JVM (Robolectric) text metrics differ a few dp from Figma's nominal line
|
|
1890
|
+
// boxes, so a text-driven element legitimately renders ±~2dp per laid-out
|
|
1891
|
+
// line. The significance flag (sizeFlag) gets that band — min(8, 2 + 2·L)dp
|
|
1892
|
+
// over the element's subtree line count — while the MEASUREMENT (deltas)
|
|
1893
|
+
// stays exact and non-text elements keep the strict 1dp band (ADR-0004:
|
|
1894
|
+
// strict, not loosen). Fixtures pin the July log cases.
|
|
1895
|
+
const CANVAS_640 = { left: 0, top: 0, right: 360, bottom: 640 };
|
|
1896
|
+
test('the 1-line pill (64×33 rendered vs 60×36 design) records the deltas but does not size-flag', () => {
|
|
1897
|
+
const dump = {
|
|
1898
|
+
canvas: CANVAS_640,
|
|
1899
|
+
nodes: [
|
|
1900
|
+
{ id: 'root', bounds: { left: 0, top: 0, right: 360, bottom: 640 }, parentId: null, clipsChildren: false },
|
|
1901
|
+
{ id: 'pill', bounds: { left: 16, top: 16, right: 80, bottom: 49 }, parentId: 'root', clipsChildren: false },
|
|
1902
|
+
{
|
|
1903
|
+
id: 'pill_label',
|
|
1904
|
+
bounds: { left: 24, top: 24, right: 72, bottom: 41 },
|
|
1905
|
+
parentId: 'pill',
|
|
1906
|
+
clipsChildren: false,
|
|
1907
|
+
text: 'Copy code',
|
|
1908
|
+
textLines: [{ start: 0, end: 9, text: 'Copy code' }],
|
|
1909
|
+
},
|
|
1910
|
+
],
|
|
1911
|
+
};
|
|
1912
|
+
const block = buildStructuralBlock(dump, { pill: { w: 60, h: 36 } }, 1);
|
|
1913
|
+
const pill = block.geometry.elements.find((e) => e.id === 'pill');
|
|
1914
|
+
// Measurement integrity: the deltas are still recorded, exactly.
|
|
1915
|
+
expect(pill.deltas).toEqual([
|
|
1916
|
+
{ dimension: 'w', delta: 4 },
|
|
1917
|
+
{ dimension: 'h', delta: -3 },
|
|
1918
|
+
]);
|
|
1919
|
+
// Significance: within the 1-line text band (±4dp) — not a size divergence.
|
|
1920
|
+
expect(pill.sizeFlag).toBe(false);
|
|
1921
|
+
expect(block.summary.clean).toBe(true);
|
|
1922
|
+
});
|
|
1923
|
+
test('the 3-line card (65dp rendered vs 72 design) is within the line-scaled band', () => {
|
|
1924
|
+
const dump = {
|
|
1925
|
+
canvas: CANVAS_640,
|
|
1926
|
+
nodes: [
|
|
1927
|
+
{ id: 'root', bounds: { left: 0, top: 0, right: 360, bottom: 640 }, parentId: null, clipsChildren: false },
|
|
1928
|
+
{ id: 'card', bounds: { left: 16, top: 16, right: 359, bottom: 81 }, parentId: 'root', clipsChildren: false },
|
|
1929
|
+
{
|
|
1930
|
+
id: 'title',
|
|
1931
|
+
bounds: { left: 24, top: 24, right: 351, bottom: 45 },
|
|
1932
|
+
parentId: 'card',
|
|
1933
|
+
clipsChildren: false,
|
|
1934
|
+
text: 'Fresh picks',
|
|
1935
|
+
textLines: [{ start: 0, end: 11, text: 'Fresh picks' }],
|
|
1936
|
+
},
|
|
1937
|
+
{
|
|
1938
|
+
id: 'subtitle',
|
|
1939
|
+
bounds: { left: 24, top: 49, right: 351, bottom: 73 },
|
|
1940
|
+
parentId: 'card',
|
|
1941
|
+
clipsChildren: false,
|
|
1942
|
+
text: 'Two lines of helper copy wrap here',
|
|
1943
|
+
textLines: [
|
|
1944
|
+
{ start: 0, end: 17, text: 'Two lines of help' },
|
|
1945
|
+
{ start: 18, end: 34, text: 'er copy wrap here' },
|
|
1946
|
+
],
|
|
1947
|
+
},
|
|
1948
|
+
],
|
|
1949
|
+
};
|
|
1950
|
+
const block = buildStructuralBlock(dump, { card: { w: 343, h: 72 } }, 1);
|
|
1951
|
+
const card = block.geometry.elements.find((e) => e.id === 'card');
|
|
1952
|
+
expect(card.deltas).toEqual([{ dimension: 'h', delta: -7 }]);
|
|
1953
|
+
// 3 laid-out lines in the subtree → band min(8, 2 + 2·3) = 8dp ≥ 7.
|
|
1954
|
+
expect(card.sizeFlag).toBe(false);
|
|
1955
|
+
});
|
|
1956
|
+
test('a −2dp helper text node stays unflagged (its own line makes it text-driven)', () => {
|
|
1957
|
+
const dump = {
|
|
1958
|
+
canvas: CANVAS_640,
|
|
1959
|
+
nodes: [
|
|
1960
|
+
{ id: 'root', bounds: { left: 0, top: 0, right: 360, bottom: 640 }, parentId: null, clipsChildren: false },
|
|
1961
|
+
{
|
|
1962
|
+
id: 'helper',
|
|
1963
|
+
bounds: { left: 16, top: 100, right: 136, bottom: 118 },
|
|
1964
|
+
parentId: 'root',
|
|
1965
|
+
clipsChildren: false,
|
|
1966
|
+
text: 'Terms apply',
|
|
1967
|
+
textLines: [{ start: 0, end: 11, text: 'Terms apply' }],
|
|
1968
|
+
},
|
|
1969
|
+
],
|
|
1970
|
+
};
|
|
1971
|
+
const block = buildStructuralBlock(dump, { helper: { w: 120, h: 20 } }, 1);
|
|
1972
|
+
const helper = block.geometry.elements.find((e) => e.id === 'helper');
|
|
1973
|
+
expect(helper.deltas).toEqual([{ dimension: 'h', delta: -2 }]);
|
|
1974
|
+
expect(helper.sizeFlag).toBe(false);
|
|
1975
|
+
});
|
|
1976
|
+
test('the 328 vs 343dp button class still fails — 15dp is no line-box residue', () => {
|
|
1977
|
+
const dump = {
|
|
1978
|
+
canvas: CANVAS_640,
|
|
1979
|
+
nodes: [
|
|
1980
|
+
{ id: 'root', bounds: { left: 0, top: 0, right: 360, bottom: 640 }, parentId: null, clipsChildren: false },
|
|
1981
|
+
{ id: 'button', bounds: { left: 16, top: 500, right: 344, bottom: 548 }, parentId: 'root', clipsChildren: false },
|
|
1982
|
+
{
|
|
1983
|
+
id: 'button_label',
|
|
1984
|
+
bounds: { left: 100, top: 514, right: 260, bottom: 534 },
|
|
1985
|
+
parentId: 'button',
|
|
1986
|
+
clipsChildren: false,
|
|
1987
|
+
text: 'Continue',
|
|
1988
|
+
textLines: [{ start: 0, end: 8, text: 'Continue' }],
|
|
1989
|
+
},
|
|
1990
|
+
],
|
|
1991
|
+
};
|
|
1992
|
+
const block = buildStructuralBlock(dump, { button: { w: 343, h: 48 } }, 1);
|
|
1993
|
+
const button = block.geometry.elements.find((e) => e.id === 'button');
|
|
1994
|
+
expect(button.sizeFlag).toBe(true);
|
|
1995
|
+
expect(block.summary.clean).toBe(false);
|
|
1996
|
+
});
|
|
1997
|
+
test('the text band is capped at 8dp — a 10dp delta on a 5-line block still fails', () => {
|
|
1998
|
+
const dump = {
|
|
1999
|
+
canvas: CANVAS_640,
|
|
2000
|
+
nodes: [
|
|
2001
|
+
{ id: 'root', bounds: { left: 0, top: 0, right: 360, bottom: 640 }, parentId: null, clipsChildren: false },
|
|
2002
|
+
{
|
|
2003
|
+
id: 'paragraph',
|
|
2004
|
+
bounds: { left: 16, top: 16, right: 344, bottom: 126 },
|
|
2005
|
+
parentId: 'root',
|
|
2006
|
+
clipsChildren: false,
|
|
2007
|
+
text: 'five lines of copy wrap',
|
|
2008
|
+
textLines: [
|
|
2009
|
+
{ start: 0, end: 4, text: 'five' },
|
|
2010
|
+
{ start: 5, end: 10, text: 'lines' },
|
|
2011
|
+
{ start: 11, end: 13, text: 'of' },
|
|
2012
|
+
{ start: 14, end: 18, text: 'copy' },
|
|
2013
|
+
{ start: 19, end: 23, text: 'wrap' },
|
|
2014
|
+
],
|
|
2015
|
+
},
|
|
2016
|
+
],
|
|
2017
|
+
};
|
|
2018
|
+
const block = buildStructuralBlock(dump, { paragraph: { w: 328, h: 120 } }, 1);
|
|
2019
|
+
const paragraph = block.geometry.elements.find((e) => e.id === 'paragraph');
|
|
2020
|
+
// min(8, 2 + 2·5) = 8dp < 10dp — the cap keeps large stacks honest.
|
|
2021
|
+
expect(paragraph.sizeFlag).toBe(true);
|
|
2022
|
+
});
|
|
2023
|
+
test('a non-text element keeps the strict band — a 3dp delta still flags', () => {
|
|
2024
|
+
const dump = {
|
|
2025
|
+
canvas: CANVAS_640,
|
|
2026
|
+
nodes: [
|
|
2027
|
+
{ id: 'root', bounds: { left: 0, top: 0, right: 360, bottom: 640 }, parentId: null, clipsChildren: false },
|
|
2028
|
+
{ id: 'thumbnail', bounds: { left: 16, top: 16, right: 119, bottom: 116 }, parentId: 'root', clipsChildren: false },
|
|
2029
|
+
],
|
|
2030
|
+
};
|
|
2031
|
+
const block = buildStructuralBlock(dump, { thumbnail: { w: 100, h: 100 } }, 1);
|
|
2032
|
+
const thumbnail = block.geometry.elements.find((e) => e.id === 'thumbnail');
|
|
2033
|
+
expect(thumbnail.deltas).toEqual([{ dimension: 'w', delta: 3 }]);
|
|
2034
|
+
expect(thumbnail.sizeFlag).toBe(true);
|
|
2035
|
+
});
|
|
2036
|
+
test('an old package (text but no textLines) still counts one line per text node', () => {
|
|
2037
|
+
const dump = {
|
|
2038
|
+
canvas: CANVAS_640,
|
|
2039
|
+
nodes: [
|
|
2040
|
+
{ id: 'root', bounds: { left: 0, top: 0, right: 360, bottom: 640 }, parentId: null, clipsChildren: false },
|
|
2041
|
+
{ id: 'pill', bounds: { left: 16, top: 16, right: 80, bottom: 49 }, parentId: 'root', clipsChildren: false },
|
|
2042
|
+
{
|
|
2043
|
+
id: 'pill_label',
|
|
2044
|
+
bounds: { left: 24, top: 24, right: 72, bottom: 41 },
|
|
2045
|
+
parentId: 'pill',
|
|
2046
|
+
clipsChildren: false,
|
|
2047
|
+
text: 'Copy code',
|
|
2048
|
+
},
|
|
2049
|
+
],
|
|
2050
|
+
};
|
|
2051
|
+
const block = buildStructuralBlock(dump, { pill: { w: 60, h: 36 } }, 1);
|
|
2052
|
+
expect(block.geometry.elements.find((e) => e.id === 'pill').sizeFlag).toBe(false);
|
|
2053
|
+
});
|
|
2054
|
+
});
|
|
1527
2055
|
describe('embedded geometry stage parity', () => {
|
|
1528
2056
|
// The render ships a JS-source port of the differ + assembler. It must produce
|
|
1529
2057
|
// byte-identical blocks to the canonical typed module, or run-side structural
|
|
@@ -1592,6 +2120,20 @@ describe('embedded geometry stage parity', () => {
|
|
|
1592
2120
|
};
|
|
1593
2121
|
expect(embedded.buildAppearanceReferenceFromParams(params)).toEqual(buildAppearanceReferenceFromParams(params));
|
|
1594
2122
|
});
|
|
2123
|
+
test('embedded buildAppearanceReferenceFromParams matches the module for a typeface declaration (typeface issue 04)', () => {
|
|
2124
|
+
const cases = [
|
|
2125
|
+
{ refTypeface: { code: { family: 'Poppins', weight: 600 } }, baseline: 'figma' },
|
|
2126
|
+
{
|
|
2127
|
+
refGeom: { code: { w: 100, h: 20 } },
|
|
2128
|
+
refTypeface: { code: { family: 'Poppins', weight: 600 }, label: { family: 'Fira Sans' } },
|
|
2129
|
+
baseline: 'figma',
|
|
2130
|
+
},
|
|
2131
|
+
{ refTypeface: {}, baseline: 'figma' },
|
|
2132
|
+
];
|
|
2133
|
+
for (const params of cases) {
|
|
2134
|
+
expect(embedded.buildAppearanceReferenceFromParams(params)).toEqual(buildAppearanceReferenceFromParams(params));
|
|
2135
|
+
}
|
|
2136
|
+
});
|
|
1595
2137
|
test('embedded deriveLineFacts matches the module (Signal C, issue 18)', () => {
|
|
1596
2138
|
const cases = [
|
|
1597
2139
|
[
|
|
@@ -1634,6 +2176,56 @@ describe('embedded geometry stage parity', () => {
|
|
|
1634
2176
|
};
|
|
1635
2177
|
expect(embedded.buildStructuralBlock(dumpWithLines, null, 2, undefined, appearanceReference)).toEqual(buildStructuralBlock(dumpWithLines, null, 2, undefined, appearanceReference));
|
|
1636
2178
|
});
|
|
2179
|
+
test('embedded deriveTypefaceFacts matches the module (Signal C, issue 03)', () => {
|
|
2180
|
+
const cases = [
|
|
2181
|
+
[{ code: { family: 'Roboto', weight: 400 } }, { code: { family: 'Poppins', weight: 600 } }],
|
|
2182
|
+
[{ code: { family: 'Poppins', weight: 600 } }, { code: { family: 'Poppins', weight: 600 } }],
|
|
2183
|
+
[{ code: { family: 'Poppins', weight: 600 } }, { code: { family: 'Poppins-SemiBold' } }],
|
|
2184
|
+
[{ code: { family: 'fira_sans_medium' } }, { code: { family: 'Fira Sans', weight: 500 } }],
|
|
2185
|
+
[{ code: { family: 'Poppins', weight: 400 } }, { code: { family: 'Poppins-SemiBold' } }],
|
|
2186
|
+
[{}, { code: { family: 'Poppins', weight: 600 } }],
|
|
2187
|
+
[{ code: { family: 'Roboto', weight: 400 } }, {}],
|
|
2188
|
+
[
|
|
2189
|
+
{ b: { family: 'Roboto', weight: 400 }, a: { family: 'Arial', weight: 400 } },
|
|
2190
|
+
{ a: { family: 'Poppins', weight: 600 }, b: { family: 'Roboto', weight: 400 } },
|
|
2191
|
+
],
|
|
2192
|
+
];
|
|
2193
|
+
for (const [rendered, reference] of cases) {
|
|
2194
|
+
expect(embedded.deriveTypefaceFacts(rendered, reference)).toEqual(deriveTypefaceFacts(rendered, reference));
|
|
2195
|
+
}
|
|
2196
|
+
});
|
|
2197
|
+
test('embedded buildStructuralBlock matches the module with a typeface reference + the render-SDK gate (issue 03)', () => {
|
|
2198
|
+
const dumps = [
|
|
2199
|
+
{
|
|
2200
|
+
canvas: { left: 0, top: 0, right: 200, bottom: 400 },
|
|
2201
|
+
renderSdk: 34,
|
|
2202
|
+
nodes: [
|
|
2203
|
+
{ id: 'code', bounds: { left: 0, top: 0, right: 100, bottom: 20 }, parentId: null, clipsChildren: false, text: 'INV-50', resolvedTypeface: { family: 'Roboto', weight: 400 } },
|
|
2204
|
+
],
|
|
2205
|
+
},
|
|
2206
|
+
{
|
|
2207
|
+
canvas: { left: 0, top: 0, right: 200, bottom: 400 },
|
|
2208
|
+
renderSdk: 28,
|
|
2209
|
+
nodes: [
|
|
2210
|
+
{ id: 'code', bounds: { left: 0, top: 0, right: 100, bottom: 20 }, parentId: null, clipsChildren: false, text: 'INV-50' },
|
|
2211
|
+
],
|
|
2212
|
+
},
|
|
2213
|
+
{
|
|
2214
|
+
canvas: { left: 0, top: 0, right: 200, bottom: 400 },
|
|
2215
|
+
nodes: [
|
|
2216
|
+
{ id: 'code', bounds: { left: 0, top: 0, right: 100, bottom: 20 }, parentId: null, clipsChildren: false },
|
|
2217
|
+
],
|
|
2218
|
+
},
|
|
2219
|
+
];
|
|
2220
|
+
const appearanceReference = {
|
|
2221
|
+
baseline: 'figma',
|
|
2222
|
+
elementIds: ['code'],
|
|
2223
|
+
typeface: { code: { family: 'Poppins', weight: 600 } },
|
|
2224
|
+
};
|
|
2225
|
+
for (const dump of dumps) {
|
|
2226
|
+
expect(embedded.buildStructuralBlock(dump, null, 2, undefined, appearanceReference)).toEqual(buildStructuralBlock(dump, null, 2, undefined, appearanceReference));
|
|
2227
|
+
}
|
|
2228
|
+
});
|
|
1637
2229
|
test('embedded deriveCopyFacts matches the module (Signal C)', () => {
|
|
1638
2230
|
const cases = [
|
|
1639
2231
|
[{ title: 'a' }, { title: 'a' }],
|
|
@@ -1732,6 +2324,40 @@ describe('embedded geometry stage parity', () => {
|
|
|
1732
2324
|
};
|
|
1733
2325
|
expect(embedded.buildStructuralBlock(dumpWithText, null, 2, undefined, appearanceReference)).toEqual(buildStructuralBlock(dumpWithText, null, 2, undefined, appearanceReference));
|
|
1734
2326
|
});
|
|
2327
|
+
// Duplicated banner tag + a child declared in the reference: exercises the
|
|
2328
|
+
// collision guard end-to-end (duplicate_tag fact, geometry skip, overhang
|
|
2329
|
+
// withheld against the duplicated frame, scaffold row).
|
|
2330
|
+
const DUPLICATE_TAG_DUMP = {
|
|
2331
|
+
canvas: { left: 0, top: 0, right: 720, bottom: 800 },
|
|
2332
|
+
nodes: [
|
|
2333
|
+
{ id: 'banner', bounds: { left: 0, top: 0, right: 686, bottom: 144 }, parentId: null, clipsChildren: false },
|
|
2334
|
+
{ id: 'banner', bounds: { left: 0, top: 200, right: 686, bottom: 344 }, parentId: null, clipsChildren: false },
|
|
2335
|
+
{ id: 'coin', bounds: { left: 600, top: 80, right: 680, bottom: 180 }, parentId: 'banner', clipsChildren: false },
|
|
2336
|
+
],
|
|
2337
|
+
};
|
|
2338
|
+
const DUPLICATE_TAG_REF = {
|
|
2339
|
+
banner: { x: 0, y: 0, w: 343, h: 72 },
|
|
2340
|
+
coin: { x: 300, y: 40, w: 40, h: 50 },
|
|
2341
|
+
};
|
|
2342
|
+
// A text-driven pill inside the band (density 2: px are 2× the dp numbers)
|
|
2343
|
+
// plus a non-text sibling outside it — exercises the text-aware size band.
|
|
2344
|
+
const TEXT_BAND_DUMP = {
|
|
2345
|
+
canvas: { left: 0, top: 0, right: 720, bottom: 400 },
|
|
2346
|
+
nodes: [
|
|
2347
|
+
{ id: 'root', bounds: { left: 0, top: 0, right: 720, bottom: 400 }, parentId: null, clipsChildren: false },
|
|
2348
|
+
{ id: 'pill', bounds: { left: 32, top: 32, right: 160, bottom: 98 }, parentId: 'root', clipsChildren: false },
|
|
2349
|
+
{
|
|
2350
|
+
id: 'pill_label',
|
|
2351
|
+
bounds: { left: 48, top: 48, right: 144, bottom: 82 },
|
|
2352
|
+
parentId: 'pill',
|
|
2353
|
+
clipsChildren: false,
|
|
2354
|
+
text: 'Copy code',
|
|
2355
|
+
textLines: [{ start: 0, end: 9, text: 'Copy code' }],
|
|
2356
|
+
},
|
|
2357
|
+
{ id: 'thumbnail', bounds: { left: 200, top: 32, right: 406, bottom: 232 }, parentId: 'root', clipsChildren: false },
|
|
2358
|
+
],
|
|
2359
|
+
};
|
|
2360
|
+
const TEXT_BAND_REF = { pill: { w: 60, h: 36 }, thumbnail: { w: 100, h: 100 } };
|
|
1735
2361
|
const parityCases = [
|
|
1736
2362
|
{ name: 'factsOnly', dump: HUB_PROMO_DUMP, ref: null },
|
|
1737
2363
|
{ name: 'withGeometry', dump: HUB_PROMO_DUMP, ref: HUB_PROMO_REFERENCE },
|
|
@@ -1739,6 +2365,8 @@ describe('embedded geometry stage parity', () => {
|
|
|
1739
2365
|
{ name: 'overhangGuest', dump: OVERHANG_GUEST_DUMP, ref: OVERHANG_GUEST_REF },
|
|
1740
2366
|
{ name: 'overhangPosition', dump: OVERHANG_POSITION_DUMP, ref: OVERHANG_POSITION_REF },
|
|
1741
2367
|
{ name: 'overhangMissing', dump: OVERHANG_MISSING_DUMP, ref: OVERHANG_MISSING_REF },
|
|
2368
|
+
{ name: 'duplicateTag', dump: DUPLICATE_TAG_DUMP, ref: DUPLICATE_TAG_REF },
|
|
2369
|
+
{ name: 'textSizeBand', dump: TEXT_BAND_DUMP, ref: TEXT_BAND_REF },
|
|
1742
2370
|
];
|
|
1743
2371
|
for (const { name, dump, ref } of parityCases) {
|
|
1744
2372
|
test(`embedded buildStructuralBlock matches the module (${name})`, () => {
|
|
@@ -1749,5 +2377,19 @@ describe('embedded geometry stage parity', () => {
|
|
|
1749
2377
|
expect(embedded.buildReviewScaffold(block)).toEqual(buildReviewScaffold(block));
|
|
1750
2378
|
});
|
|
1751
2379
|
}
|
|
2380
|
+
test('embedded buildStructuralBlock matches the module for a duplicated member tag under a declared frame', () => {
|
|
2381
|
+
const dump = {
|
|
2382
|
+
canvas: { left: 0, top: 0, right: 360, bottom: 640 },
|
|
2383
|
+
nodes: [
|
|
2384
|
+
{ id: 'root', bounds: { left: 0, top: 0, right: 360, bottom: 640 }, parentId: null, clipsChildren: false },
|
|
2385
|
+
{ id: 'member', bounds: { left: 0, top: 0, right: 360, bottom: 100 }, parentId: 'root', clipsChildren: false },
|
|
2386
|
+
{ id: 'member', bounds: { left: 0, top: 200, right: 360, bottom: 300 }, parentId: 'root', clipsChildren: false },
|
|
2387
|
+
{ id: 'footer', bounds: { left: 0, top: 500, right: 360, bottom: 640 }, parentId: 'root', clipsChildren: false },
|
|
2388
|
+
],
|
|
2389
|
+
};
|
|
2390
|
+
const ref = { member: { x: 0, y: 0, w: 360, h: 100 }, footer: { x: 0, y: 540, w: 360, h: 140 } };
|
|
2391
|
+
const frame = { w: 360, h: 640 };
|
|
2392
|
+
expect(embedded.buildStructuralBlock(dump, ref, 1, undefined, undefined, frame)).toEqual(buildStructuralBlock(dump, ref, 1, undefined, undefined, frame));
|
|
2393
|
+
});
|
|
1752
2394
|
});
|
|
1753
2395
|
//# sourceMappingURL=structural-facts.test.js.map
|