@@ -4,7 +4,7 @@ const scenegraph = require("../../../packages/scenegraph/lib/brs-sg.node.js");
44const core = require ( "../../../packages/node/bin/brs.node.js" ) ;
55
66const { SGNodeFactory, sgRoot } = scenegraph ;
7- const { BrsDevice, BrsString, Float, Int32, RoArray } = core ;
7+ const { BrsBoolean , BrsDevice, BrsString, Float, Int32, RoArray } = core ;
88
99/** Minimal interpreter accepted by renderNode → renderChildren when draw2D is absent. */
1010const fakeInterpreter = { } ;
@@ -129,3 +129,72 @@ describe("LayoutGroup positions siblings after a non-scrolling ScrollingLabel's
129129 expect ( length . getValueJS ( "translation" ) [ 0 ] ) . toBeCloseTo ( expectedX ) ;
130130 } ) ;
131131} ) ;
132+
133+ /**
134+ * Regression: a HIDDEN ScrollingLabel must not contribute to a vertical LayoutGroup's stack height.
135+ * SGDEX's StandardGridItemComponent (playusb's main-menu grid items) uses exactly this shape — a
136+ * "labelsLayout" LayoutGroup(vert, vertAlignment="bottom") with two ScrollingLabel lines, where an
137+ * unused second line is hidden via setLabelDataOrHide: `label.text = ""` (always run, even redundantly,
138+ * which Label.setValue's "force re-measure" path turns into an immediate getMeasured() call while the
139+ * label is still momentarily visible), THEN `label.visible = false` and `label.scale = [0,0]`.
140+ *
141+ * Before #1154, ScrollingLabel's reported width for that forced empty-text measurement was 0
142+ * (fullTextWidth for empty text), which failed LayoutGroup.chooseActiveRect's width>0-AND-height>0
143+ * gate, so the stale rect fell back to zero size. #1154 made ScrollingLabel always report
144+ * width = maxWidth (non-zero) regardless of text, so that same stale-but-nonzero-height rect started
145+ * passing the gate — counting a full line-height + itemSpacing for a label that never draws anything,
146+ * and shifting the bottom-aligned visible line upward (overlapping content above it, e.g. an icon).
147+ */
148+ describe ( "LayoutGroup ignores a hidden ScrollingLabel's height in a vertical stack" , ( ) => {
149+ beforeAll ( ( ) => {
150+ const commonZip = fs . readFileSync ( path . join ( __dirname , "../../../packages/scenegraph/assets/common.zip" ) ) ;
151+ BrsDevice . fileSystem . setup ( commonZip . buffer , new ArrayBuffer ( 1024 * 1024 ) , new ArrayBuffer ( 1024 * 1024 ) ) ;
152+ } ) ;
153+
154+ afterEach ( ( ) => {
155+ sgRoot . setFocused ( ) ;
156+ } ) ;
157+
158+ function buildLabelsLayout ( includeHiddenLine2 ) {
159+ const layout = SGNodeFactory . createNode ( "LayoutGroup" ) ;
160+ layout . setValue ( "layoutDirection" , new BrsString ( "vert" ) ) ;
161+ layout . setValue ( "itemSpacings" , vector ( [ 5 ] ) ) ;
162+ layout . setValue ( "vertAlignment" , new BrsString ( "bottom" ) ) ;
163+
164+ const line1 = SGNodeFactory . createNode ( "ScrollingLabel" ) ;
165+ line1 . setValue ( "font" , new BrsString ( "font:SmallSystemFont" ) ) ;
166+ line1 . setValue ( "maxWidth" , new Int32 ( 200 ) ) ;
167+ line1 . setValue ( "text" , new BrsString ( "Recent Files" ) ) ;
168+ layout . appendChildToParent ( line1 ) ;
169+
170+ if ( includeHiddenLine2 ) {
171+ const line2 = SGNodeFactory . createNode ( "ScrollingLabel" ) ;
172+ line2 . setValue ( "font" , new BrsString ( "font:SmallSystemFont" ) ) ;
173+ line2 . setValue ( "maxWidth" , new Int32 ( 200 ) ) ;
174+ // Matches setLabelDataOrHide's exact field-write order: text cleared first (redundant —
175+ // the field already defaults to "" — but still forces a re-measure), THEN hidden.
176+ line2 . setValue ( "text" , new BrsString ( "" ) ) ;
177+ line2 . setValue ( "visible" , BrsBoolean . False ) ;
178+ line2 . setValue ( "scale" , vector ( [ 0 , 0 ] ) ) ;
179+ layout . appendChildToParent ( line2 ) ;
180+ }
181+
182+ return { layout, line1 } ;
183+ }
184+
185+ test ( "a hidden second line does not push the visible first line up" , ( ) => {
186+ const { layout : layoutAlone , line1 : line1Alone } = buildLabelsLayout ( false ) ;
187+ layoutAlone . renderNode ( fakeInterpreter , [ 0 , 0 ] , 0 , 1 ) ;
188+ // A hidden-but-still-present child contributes zero HEIGHT (this fix), but — pre-dating
189+ // #1154 too, and out of scope here — calculateTotalPrimary still adds its one itemSpacings
190+ // entry regardless of visibility. So the correct baseline is "line1 alone" minus that single
191+ // spacing, not an exact match: this still pins the actual regression (a full extra line
192+ // height, ~4x the spacing here) while not asserting an unrelated, pre-existing quirk away.
193+ const expectedY = line1Alone . getValueJS ( "translation" ) [ 1 ] - 5 ;
194+
195+ const { layout : layoutWithHidden , line1 : line1WithHidden } = buildLabelsLayout ( true ) ;
196+ layoutWithHidden . renderNode ( fakeInterpreter , [ 0 , 0 ] , 0 , 1 ) ;
197+
198+ expect ( line1WithHidden . getValueJS ( "translation" ) [ 1 ] ) . toBeCloseTo ( expectedY ) ;
199+ } ) ;
200+ } ) ;
0 commit comments