Files
astra-mobile/src/components/player/nowPlayingLayout.test.mts
T
2026-08-01 01:52:29 -04:00

669 lines
26 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import {
getNowPlayingLayout,
getScopeHeight,
getTabletCompanionLayout,
NOW_PLAYING_ART_COMFORT_MIN,
NOW_PLAYING_CONTENT_BOTTOM_PADDING,
NOW_PLAYING_CONTENT_TOP_PADDING,
NOW_PLAYING_HEADER_HEIGHT,
type NowPlayingLayout,
} from './nowPlayingLayout.ts';
/**
* Windows are inset-adjusted, exactly as `NowPlayingOverlay` computes them:
* window size minus safe areas, in dp.
*/
const DEVICES = [
// The device this rebuild exists for: tall and narrow, and the one where the
// old estimate error pooled into a dead gap above the badge row.
{ name: 'Poco M5', width: 393, height: 825 },
{ name: 'Pixel 7 Pro', width: 411, height: 819 },
{ name: 'Galaxy S22', width: 360, height: 732 },
{ name: 'Galaxy S22 Ultra', width: 411, height: 818 },
{ name: 'Galaxy S25 Ultra', width: 411, height: 852 },
{ name: 'OnePlus 7 Pro', width: 412, height: 871 },
{ name: 'Galaxy A15', width: 393, height: 825 },
// Floors: small, very small, and a short split-screen sliver.
{ name: 'small 320x568', width: 320, height: 568 },
{ name: 'tiny 320x480', width: 320, height: 480 },
{ name: 'split-screen 393x420', width: 393, height: 420 },
] as const;
const FONT_SCALES = [1, 1.15, 1.3] as const;
const WIDE_WINDOWS = [
[600, 840],
[800, 600],
[768, 1024],
[1024, 600],
[1024, 768],
[1366, 1024],
] as const;
function columnHeight(availableHeight: number): number {
return (
availableHeight -
NOW_PLAYING_CONTENT_TOP_PADDING -
NOW_PLAYING_CONTENT_BOTTOM_PADDING -
NOW_PLAYING_HEADER_HEIGHT
);
}
function layoutFor(
device: (typeof DEVICES)[number],
fontScale: number,
showVisualizer: boolean
): NowPlayingLayout {
return getNowPlayingLayout(
device.width,
device.height,
showVisualizer,
false,
fontScale
);
}
test('renders an identical deck for every device in the same tier', () => {
for (const fontScale of FONT_SCALES) {
const byDensity = new Map<string, { device: string; height: number }>();
for (const device of DEVICES) {
const { density, deck } = layoutFor(device, fontScale, true);
const seen = byDensity.get(density);
if (seen) {
assert.equal(
deck.height,
seen.height,
`${device.name} and ${seen.device} are both '${density}' @${fontScale} but their decks differ (${deck.height} vs ${seen.height})`
);
} else {
byDensity.set(density, { device: device.name, height: deck.height });
}
}
assert.ok(byDensity.size > 0);
}
});
test('reserves a deck exactly as tall as the rows it contains', () => {
for (const fontScale of FONT_SCALES) {
for (const device of DEVICES) {
const { deck } = layoutFor(device, fontScale, true);
// [lyric ─lyricGap─ identity] ─rowGap─
// [progress ─controlGap─ transport] ─rowGap─ utility
const identityGroup =
deck.lyricRowHeight > 0
? deck.lyricRowHeight + deck.lyricGap + deck.identityRowHeight
: deck.identityRowHeight;
const controlGroup =
deck.progressRowHeight + deck.controlGap + deck.transportRowHeight;
const sum =
identityGroup + controlGroup + deck.utilityRowHeight + deck.rowGap * 2;
assert.equal(
deck.height,
sum,
`${device.name} @${fontScale}: deck.height ${deck.height} != sum of rows ${sum}`
);
assert.equal(
deck.identityRowHeight,
deck.titleLineHeight + deck.identityGap + deck.artistLineHeight,
`${device.name} @${fontScale}: identity row must be two declared line boxes`
);
assert.equal(
deck.progressRowHeight,
deck.waveformHeight +
deck.waveformTouchPadding * 2 +
deck.timesGap +
deck.timesRowHeight,
`${device.name} @${fontScale}: progress row must cover the seek bar's real footprint`
);
}
}
});
test('fills the column with deck + stage and keeps the artwork inside it', () => {
for (const fontScale of FONT_SCALES) {
for (const device of DEVICES) {
for (const visualizer of [false, true]) {
const layout = layoutFor(device, fontScale, visualizer);
assert.equal(
layout.deck.height + layout.stageHeight,
columnHeight(device.height),
`${device.name} @${fontScale}: deck + stage must fill the column exactly`
);
const railSpace = visualizer && layout.scopeRailFits ? layout.scopeBlockHeight : 0;
assert.ok(
layout.artSize + railSpace + layout.stageInset * 2 <= layout.stageHeight,
`${device.name} @${fontScale} scope=${visualizer}: art ${layout.artSize} + rail ${railSpace} + insets ${layout.stageInset * 2} overflows stage ${layout.stageHeight}`
);
assert.ok(
layout.artSize <= layout.contentWidth,
`${device.name} @${fontScale}: art ${layout.artSize} exceeds content width ${layout.contentWidth}`
);
}
}
}
});
test('toggling the scope moves nothing outside the stage', () => {
for (const fontScale of FONT_SCALES) {
for (const device of DEVICES) {
const hidden = layoutFor(device, fontScale, false);
const visible = layoutFor(device, fontScale, true);
assert.equal(
hidden.deck.height,
visible.deck.height,
`${device.name} @${fontScale}: the scope toggle changed the deck height`
);
assert.equal(
hidden.stageHeight,
visible.stageHeight,
`${device.name} @${fontScale}: the scope toggle changed the stage height`
);
assert.equal(
hidden.density,
visible.density,
`${device.name} @${fontScale}: the scope toggle changed the density tier`
);
// The art pair is the same regardless of the current toggle state...
assert.equal(hidden.artSizeScopeOn, visible.artSizeScopeOn);
assert.equal(hidden.artSizeScopeOff, visible.artSizeScopeOff);
// ...and the resolved artSize picks the matching member.
assert.equal(hidden.artSize, hidden.artSizeScopeOff);
assert.equal(visible.artSize, visible.artSizeScopeOn);
assert.equal(
hidden.scopeRailFits,
visible.scopeRailFits,
`${device.name} @${fontScale}: the scope toggle changed whether the rail fits`
);
assert.ok(
visible.artSizeScopeOn + visible.scopeBlockHeight + visible.stageInset * 2 <=
visible.stageHeight,
`${device.name} @${fontScale}: art + rail + insets must fit the stage in every tier`
);
assert.ok(
visible.artSizeScopeOn <= visible.artSizeScopeOff,
`${device.name} @${fontScale}: turning the scope on must not grow the artwork`
);
}
}
});
test('picks the richest tier that still leaves comfortable artwork', () => {
for (const fontScale of FONT_SCALES) {
for (const device of DEVICES) {
const layout = layoutFor(device, fontScale, true);
if (layout.density === 'compact') continue;
assert.ok(
layout.artSizeScopeOn >= NOW_PLAYING_ART_COMFORT_MIN,
`${device.name} @${fontScale}: tier '${layout.density}' left only ${layout.artSizeScopeOn}dp of artwork`
);
}
}
});
test('never gives a taller window a leaner tier', () => {
const rank = { compact: 0, regular: 1, spacious: 2 } as const;
for (const fontScale of FONT_SCALES) {
for (const width of [320, 360, 393, 411]) {
let previous = -1;
for (let height = 380; height <= 1000; height += 1) {
const { density } = getNowPlayingLayout(width, height, true, false, fontScale);
const current = rank[density];
assert.ok(
current >= previous,
`width ${width} @${fontScale}: height ${height} dropped to '${density}' after a shorter window had more`
);
previous = current;
}
}
}
});
test('keeps the Poco M5 stage full instead of banking slack below the controls', () => {
const poco = DEVICES[0];
const layout = layoutFor(poco, 1, true);
// The old engine capped the artwork at 336dp on width and left the surplus
// height as a gap above the badge row. The stage absorbs it now: the artwork
// is either height-bound (meets the rail) or width-bound (centred in slack).
assert.equal(layout.density, 'spacious');
assert.equal(
layout.artSizeScopeOn + layout.scopeBlockHeight + layout.stageInset * 2,
layout.stageHeight,
'the artwork should meet the rail and fill the inset stage, leaving no dead band'
);
});
test('keeps the artwork clear of the header and the deck in both scope states', () => {
for (const fontScale of FONT_SCALES) {
for (const device of DEVICES) {
for (const visualizer of [false, true]) {
const layout = layoutFor(device, fontScale, visualizer);
if (layout.density === 'compact' && layout.artSize < NOW_PLAYING_ART_COMFORT_MIN) {
continue; // degenerate window: the artwork is already scraping the floor
}
// The artwork is centred, so its clearance is half of whatever the stage
// has left over — and the rail, when shown, eats from the bottom half.
const railSpace = visualizer && layout.scopeRailFits ? layout.scopeBlockHeight : 0;
const clearance = (layout.stageHeight - layout.artSize - railSpace) / 2;
assert.ok(
clearance >= layout.stageInset,
`${device.name} @${fontScale} scope=${visualizer}: only ${clearance}dp above the artwork, wanted ${layout.stageInset}`
);
}
}
}
});
test('holds the scope-off artwork to a shared cap across the phone lineup', () => {
// An uncapped stage let tall phones blow the artwork up to near full width
// (367dp on a Pixel 7 Pro). Every phone with the room now lands on the same
// number; the smaller ones are honestly limited by their own stage.
const capped: number[] = [];
for (const device of DEVICES) {
const layout = layoutFor(device, 1, false);
if (layout.density !== 'spacious') continue;
assert.ok(
layout.artSize <= 320,
`${device.name}: scope-off artwork ${layout.artSize} exceeded the tier cap`
);
// Devices whose stage could go bigger must be the ones sitting on the cap.
if (layout.stageHeight - layout.stageInset * 2 > 320) capped.push(layout.artSize);
}
assert.ok(capped.length >= 4, 'expected several roomy phones in the lineup');
assert.equal(
new Set(capped).size,
1,
`roomy phones disagree on scope-off artwork: ${[...new Set(capped)].join(', ')}`
);
});
test('keeps the band between the scope and the title tight', () => {
// The reserved-but-usually-empty lyric row, the rail's bottom offset and the
// gap to the title used to stack into ~96dp of void mid-screen.
for (const device of DEVICES) {
const layout = layoutFor(device, 1, true);
if (!layout.scopeRailFits) continue;
const band =
layout.railBottomOffset + layout.deck.lyricRowHeight + layout.deck.lyricGap;
assert.ok(
band <= 60,
`${device.name}: ${band}dp of dead space between the scope and the title`
);
}
});
test('binds the lyric row to the title rather than floating it', () => {
for (const device of DEVICES) {
const { deck } = layoutFor(device, 1, true);
if (deck.lyricRowHeight === 0) {
assert.equal(deck.lyricGap, 0, 'no lyric row means no gap for one');
continue;
}
assert.ok(
deck.lyricGap < deck.rowGap,
`lyric gap ${deck.lyricGap} should be tighter than the deck rowGap ${deck.rowGap}`
);
}
});
test('caps reserved line boxes so a huge font setting cannot run away', () => {
const device = DEVICES[0];
const capped = layoutFor(device, 1.2, true);
const beyond = layoutFor(device, 3, true);
assert.equal(beyond.deck.height, capped.deck.height);
assert.equal(beyond.deck.titleLineHeight, capped.deck.titleLineHeight);
// ...but it does grow up to the cap.
assert.ok(capped.deck.height > layoutFor(device, 1, true).deck.height);
});
/**
* Windows that must use the side-by-side row: too short to stack, whatever
* their width. Tablets used to be in here because the branch was picked by
* `isWideWindow` — see `STACKED_TABLETS`.
*/
const LANDSCAPE = [
{ name: 'Pixel 7 Pro landscape', width: 891, height: 339 },
{ name: 'S22 landscape', width: 780, height: 312 },
{ name: 'Poco M5 landscape', width: 873, height: 345 },
{ name: 'S25 Ultra landscape', width: 918, height: 363 },
{ name: 'very short landscape', width: 800, height: 300 },
] as const;
/**
* Windows with the height to stack artwork over a deck. Side-by-side is the
* phone-in-landscape compromise, not the big-screen layout, so none of these
* may take it — including a tablet in landscape.
*/
const STACKED_TABLETS = [
{ name: 'Tablet 10" landscape', width: 1248, height: 752 },
{ name: 'Tablet 12" landscape', width: 1366, height: 1000 },
{ name: 'Tablet portrait', width: 768, height: 1150 },
{ name: 'Foldable open landscape', width: 800, height: 650 },
{ name: 'Foldable open portrait', width: 808, height: 868 },
{ name: 'Tablet 10" landscape, companion out', width: 856, height: 752 },
] as const;
test('a window with the height to stack never uses the side-by-side row', () => {
for (const fontScale of FONT_SCALES) {
for (const window of STACKED_TABLETS) {
for (const scope of [false, true]) {
// `forceWide` is the companion tier asking for the landscape row. Even
// that must lose: the player cannot change shape because a pane slid in
// beside it.
for (const forceWide of [false, true]) {
const layout = getNowPlayingLayout(
window.width,
window.height,
scope,
forceWide,
fontScale
);
assert.equal(
layout.presentation,
'standard',
`${window.name} (scope ${scope}, forceWide ${forceWide}) should stack`
);
}
}
}
}
});
test('a stacked tablet gives the artwork the height a phone cannot', () => {
for (const window of STACKED_TABLETS) {
const layout = getNowPlayingLayout(window.width, window.height, false, false, 1);
assert.ok(
layout.artSizeScopeOff >= 320,
`${window.name}: art ${layout.artSizeScopeOff} is a thumbnail on this screen`
);
// Height-bound, not ceiling-bound: the artwork is square, so it can only
// ever spend height, and it must still clear the deck.
assert.ok(
layout.artSizeScopeOff <= layout.stageHeight,
`${window.name}: art ${layout.artSizeScopeOff} overflows stage ${layout.stageHeight}`
);
}
});
test('a stacked tablet spends spare width on the deck, not on the artwork', () => {
// The waveform is the only control that turns width into resolution. The
// artwork is square and gains nothing, so a wider column must not inflate it.
// Both windows are below the deck's ceiling so the deck is still growing.
const wider = getNowPlayingLayout(700, 1000, false, false, 1);
const narrower = getNowPlayingLayout(620, 1000, false, false, 1);
assert.ok(wider.contentWidth > narrower.contentWidth);
assert.equal(wider.artSizeScopeOff, narrower.artSizeScopeOff);
});
test('the deck stops widening well before it fills a tablet', () => {
// Past about half a 10" tablet the extra width stops buying a better scrub
// and starts stretching the rows around it — title hard left, favourite hard
// right, void between. Same failure as a full-width `TrackRow`.
for (const window of STACKED_TABLETS) {
const layout = getNowPlayingLayout(window.width, window.height, false, false, 1);
assert.ok(
layout.contentWidth <= 640,
`${window.name}: deck ${layout.contentWidth} exceeds the ceiling`
);
// A portrait tablet *should* fill its column — there is no surplus to
// leave. The margin only has to appear where the window is genuinely wide.
if (window.width < 1000) continue;
assert.ok(
layout.contentWidth <= window.width * 0.6,
`${window.name}: deck ${layout.contentWidth} of ${window.width} is a stretched row`
);
}
// And the ceiling actually binds on a tablet, rather than the window doing it.
assert.equal(getNowPlayingLayout(1248, 752, false, false, 1).contentWidth, 640);
assert.equal(getNowPlayingLayout(1366, 1000, false, false, 1).contentWidth, 640);
});
test('a phone is untouched by every tablet ceiling and floor', () => {
const phones = [...LANDSCAPE, { name: 'Pixel 7 Pro portrait', width: 380, height: 850 }];
for (const window of phones) {
for (const scope of [false, true]) {
const layout = getNowPlayingLayout(window.width, window.height, scope, false, 1);
assert.ok(
layout.artSize <= 400,
`${window.name}: art ${layout.artSize} exceeds the phone ceiling`
);
assert.ok(
layout.contentWidth <= 960,
`${window.name}: row ${layout.contentWidth} exceeds the phone ceiling`
);
}
}
});
test('landscape sizes its panes from their contents, not a fixed split', () => {
for (const fontScale of FONT_SCALES) {
for (const window of LANDSCAPE) {
const layout = getNowPlayingLayout(
window.width,
window.height,
true,
false,
fontScale
);
assert.equal(layout.presentation, 'wide', `${window.name} should be wide`);
assert.equal(
layout.leftPaneWidth + 32 + layout.rightPaneWidth,
layout.contentWidth,
`${window.name}: panes + gap must equal the row width`
);
assert.ok(
layout.contentWidth <= window.width - layout.contentPadding * 2,
`${window.name}: row ${layout.contentWidth} overflows the window`
);
// The stage pane exists to hold the artwork and its strip; a proportional
// split used to hand a 160dp artwork a 432dp pane.
assert.equal(
layout.leftPaneWidth,
Math.max(layout.artSizeScopeOff, layout.scopeWidth),
`${window.name}: stage pane should hug its widest content`
);
}
}
});
test('landscape actually uses the width it is given', () => {
// The deck cap was aliased to the portrait column width, leaving 100-150dp of
// a phone's landscape row unused. Height is scarce in landscape and the
// artwork is square, so the deck is the only pane that can spend the surplus.
for (const window of LANDSCAPE) {
if (window.width > 1000) continue; // tablets are capped by design, for now
const layout = getNowPlayingLayout(window.width, window.height, true, false, 1);
const rowSpace = Math.min(window.width - layout.contentPadding * 2, 960);
const unused = rowSpace - layout.contentWidth;
assert.ok(
unused <= 40,
`${window.name}: ${unused}dp of the row goes unused (row ${layout.contentWidth} of ${rowSpace})`
);
}
});
test('landscape keeps the scope strip in proportion to the artwork', () => {
for (const window of LANDSCAPE) {
const layout = getNowPlayingLayout(window.width, window.height, true, false, 1);
if (!layout.scopeRailFits) continue;
assert.ok(
layout.scopeWidth >= layout.artSizeScopeOn,
`${window.name}: strip ${layout.scopeWidth} narrower than artwork ${layout.artSizeScopeOn}`
);
// Was 2.7x on a Pixel in landscape, which read as a box beside the art.
assert.ok(
layout.scopeWidth <= layout.artSizeScopeOn * 1.6 + 1,
`${window.name}: strip ${layout.scopeWidth} is out of proportion to artwork ${layout.artSizeScopeOn}`
);
assert.equal(layout.scopeHeight, getScopeHeight(layout.scopeWidth));
}
});
test('landscape picks the richest deck the column can hold', () => {
const rank = { compact: 0, regular: 1, spacious: 2 } as const;
for (const fontScale of FONT_SCALES) {
for (const window of LANDSCAPE) {
const layout = getNowPlayingLayout(
window.width,
window.height,
true,
false,
fontScale
);
const column =
window.height -
NOW_PLAYING_CONTENT_TOP_PADDING -
NOW_PLAYING_CONTENT_BOTTOM_PADDING -
NOW_PLAYING_HEADER_HEIGHT;
// Either the deck fits, or it is the leanest tier and the window is the
// one at fault.
assert.ok(
layout.deck.height <= column || layout.density === 'compact',
`${window.name} @${fontScale}: '${layout.density}' deck ${layout.deck.height} exceeds column ${column}`
);
assert.equal(layout.stageHeight, column);
assert.ok(rank[layout.density] >= 0);
}
}
});
test('landscape never grows the artwork when the scope comes on', () => {
for (const fontScale of FONT_SCALES) {
for (const window of LANDSCAPE) {
const hidden = getNowPlayingLayout(window.width, window.height, false, false, fontScale);
const visible = getNowPlayingLayout(window.width, window.height, true, false, fontScale);
assert.equal(hidden.deck.height, visible.deck.height);
assert.equal(hidden.leftPaneWidth, visible.leftPaneWidth);
assert.equal(hidden.rightPaneWidth, visible.rightPaneWidth);
assert.equal(hidden.contentWidth, visible.contentWidth);
assert.ok(visible.artSizeScopeOn <= visible.artSizeScopeOff);
}
}
});
test('gives lyrics the majority of the shell and the queue a sidecar share', () => {
for (const [width, height] of [
[1248, 752],
[1366, 1000],
]) {
const queue = getTabletCompanionLayout(width, height, false, 1, 'queue');
const lyrics = getTabletCompanionLayout(width, height, false, 1, 'lyrics');
assert.ok(queue && lyrics, `${width}x${height} should qualify`);
// A queue row is a thumbnail and two short lines; a lyric line is a
// sentence. Sizing both the same is what left lyrics wrapping mid-phrase.
assert.ok(
queue.companionWidth / queue.shellWidth <= 0.4,
`queue took ${queue.companionWidth} of ${queue.shellWidth}`
);
assert.ok(
lyrics.companionWidth / lyrics.shellWidth >= 0.55,
`lyrics took only ${lyrics.companionWidth} of ${lyrics.shellWidth}`
);
}
});
test('never lets a companion starve the player, however wide it wants to be', () => {
for (let width = 720; width <= 2000; width += 1) {
for (const companion of ['queue', 'lyrics'] as const) {
const layout = getTabletCompanionLayout(width, 900, false, 1, companion);
if (!layout) continue;
assert.ok(
layout.playerRegionWidth >= 320,
`${companion} at ${width} left the player ${layout.playerRegionWidth}`
);
assert.equal(
layout.playerRegionWidth + layout.gap + layout.companionWidth,
layout.shellWidth,
`${companion} at ${width} does not account for the shell`
);
}
}
});
test('a companion never changes the artwork it sits beside', () => {
// The pane takes width from the deck, not from the cover: the artwork is
// height-bound, so opening or widening a companion must not shrink it. This
// is also what lets the pane animate in as a translate rather than a resize.
const closed = getNowPlayingLayout(1248, 752, false, false, 1, true);
for (const companion of ['queue', 'lyrics'] as const) {
const open = getTabletCompanionLayout(1248, 752, false, 1, companion);
assert.ok(open, `${companion} should qualify`);
assert.equal(
open.playerLayout.artSizeScopeOff,
closed.artSizeScopeOff,
`${companion} resized the artwork`
);
}
});
test('adds the companion only to roomy tablet canvases', () => {
for (const device of DEVICES) {
assert.equal(getTabletCompanionLayout(device.width, device.height, true), null);
}
for (const [width, height] of [
[600, 840],
[800, 600],
// A 600dp-tall tablet cannot stack artwork over a deck, and the
// side-by-side player it used to fall back to is the phone-in-landscape
// compromise rather than a tablet layout. With nothing good to show beside
// the player, it gets the full window instead of a companion.
[1024, 600],
]) {
assert.equal(getTabletCompanionLayout(width, height, true), null);
}
for (const [width, height] of [
[768, 1024],
[1024, 768],
[1366, 1024],
]) {
const layout = getTabletCompanionLayout(width, height, true);
assert.ok(layout, `${width}x${height} should qualify`);
assert.ok(layout.companionWidth >= 320 && layout.companionWidth <= 400);
assert.ok(layout.playerRegionWidth > 0);
assert.ok(layout.shellWidth <= 1200);
assert.equal(
layout.playerRegionWidth + layout.gap + layout.companionWidth,
layout.shellWidth
);
}
});
test('clamps scope strip height to its band across widths', () => {
assert.equal(getScopeHeight(0), 84);
assert.equal(getScopeHeight(300), 84);
assert.equal(getScopeHeight(448), 96);
assert.equal(getScopeHeight(10000), 96);
for (const device of DEVICES) {
const { scopeWidth, scopeHeight } = layoutFor(device, 1, true);
assert.equal(scopeHeight, getScopeHeight(scopeWidth));
}
});
test('keeps calculated dimensions finite and non-negative', () => {
const windows = [
...DEVICES.map((device) => [device.width, device.height] as const),
...WIDE_WINDOWS,
];
for (const [width, height] of windows) {
for (const visualizer of [false, true]) {
for (const fontScale of FONT_SCALES) {
const layout = getNowPlayingLayout(width, height, visualizer, false, fontScale);
for (const [key, value] of Object.entries(layout)) {
if (typeof value !== 'number') continue;
assert.ok(Number.isFinite(value), `${key} is not finite at ${width}x${height}`);
assert.ok(value >= 0, `${key} is negative at ${width}x${height}`);
}
for (const [key, value] of Object.entries(layout.deck)) {
if (typeof value !== 'number') continue;
assert.ok(Number.isFinite(value), `deck.${key} is not finite at ${width}x${height}`);
assert.ok(value >= 0, `deck.${key} is negative at ${width}x${height}`);
}
}
}
}
});