|
| 1 | +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; |
| 2 | +import { computeAndroidPanelLayout, ensureHmrDevOverlayRuntimeInstalled, hexRgbaToAndroidArgb } from './dev-overlay.js'; |
| 3 | +import { getGlobalScope } from './global-scope.js'; |
| 4 | + |
| 5 | +// Minimal stand-ins for the Android widget surface the host touches. Method |
| 6 | +// bodies record just enough to assert attachment and visibility. |
| 7 | +class FakeView { |
| 8 | + parent: FakeView | null = null; |
| 9 | + children: FakeView[] = []; |
| 10 | + visibility = 8; |
| 11 | + clickable = false; |
| 12 | + layoutParams: any = null; |
| 13 | + text = ''; |
| 14 | + translationY = 0; |
| 15 | + alpha = 1; |
| 16 | + endAction: any = null; |
| 17 | + addView(child: FakeView, params?: any) { |
| 18 | + child.parent = this; |
| 19 | + child.layoutParams = params ?? null; |
| 20 | + this.children.push(child); |
| 21 | + } |
| 22 | + removeView(child: FakeView) { |
| 23 | + this.children = this.children.filter((c) => c !== child); |
| 24 | + child.parent = null; |
| 25 | + } |
| 26 | + getParent() { |
| 27 | + return this.parent; |
| 28 | + } |
| 29 | + getWidth() { |
| 30 | + return 1080; |
| 31 | + } |
| 32 | + setClickable(value: boolean) { |
| 33 | + this.clickable = value; |
| 34 | + } |
| 35 | + setFocusable() {} |
| 36 | + setVisibility(value: number) { |
| 37 | + this.visibility = value; |
| 38 | + } |
| 39 | + setBackgroundColor() {} |
| 40 | + setBackground() {} |
| 41 | + setLayoutParams(params: any) { |
| 42 | + this.layoutParams = params; |
| 43 | + } |
| 44 | + setPadding() {} |
| 45 | + setElevation() {} |
| 46 | + setOrientation() {} |
| 47 | + setGravity() {} |
| 48 | + setTextSize() {} |
| 49 | + setTypeface() {} |
| 50 | + setText(value: string) { |
| 51 | + this.text = value; |
| 52 | + } |
| 53 | + setTextColor() {} |
| 54 | + setTranslationY(value: number) { |
| 55 | + this.translationY = value; |
| 56 | + } |
| 57 | + setAlpha(value: number) { |
| 58 | + this.alpha = value; |
| 59 | + } |
| 60 | + bringToFront() {} |
| 61 | + equals(other: unknown) { |
| 62 | + return other === this; |
| 63 | + } |
| 64 | + animate() { |
| 65 | + const chain: any = { |
| 66 | + translationY: () => chain, |
| 67 | + alpha: () => chain, |
| 68 | + setDuration: () => chain, |
| 69 | + setInterpolator: () => chain, |
| 70 | + withEndAction: (runnable: any) => { |
| 71 | + this.endAction = runnable; |
| 72 | + return chain; |
| 73 | + }, |
| 74 | + start: () => { |
| 75 | + this.endAction?.run?.(); |
| 76 | + this.endAction = null; |
| 77 | + }, |
| 78 | + }; |
| 79 | + return chain; |
| 80 | + } |
| 81 | +} |
| 82 | + |
| 83 | +class FakeLayoutParams { |
| 84 | + gravity = 0; |
| 85 | + topMargin = 0; |
| 86 | + margins: number[] = []; |
| 87 | + constructor( |
| 88 | + public width: number, |
| 89 | + public height: number, |
| 90 | + ) {} |
| 91 | + setMargins(left: number, top: number, right: number, bottom: number) { |
| 92 | + this.margins = [left, top, right, bottom]; |
| 93 | + } |
| 94 | +} |
| 95 | + |
| 96 | +class FakeDrawable { |
| 97 | + setCornerRadius() {} |
| 98 | + setColor() {} |
| 99 | +} |
| 100 | + |
| 101 | +class FakeFrameLayout extends FakeView { |
| 102 | + static LayoutParams = FakeLayoutParams; |
| 103 | +} |
| 104 | +class FakeLinearLayout extends FakeView { |
| 105 | + static LayoutParams = FakeLayoutParams; |
| 106 | + static VERTICAL = 1; |
| 107 | +} |
| 108 | +class FakeTextView extends FakeView {} |
| 109 | + |
| 110 | +class FakeRunnable { |
| 111 | + constructor(private readonly impl: { run: () => void }) {} |
| 112 | + run() { |
| 113 | + this.impl.run(); |
| 114 | + } |
| 115 | +} |
| 116 | + |
| 117 | +// NativeScript layouts the in-tree host would build if it were reached. |
| 118 | +class FakeNsLayout { |
| 119 | + children: any[] = []; |
| 120 | + addChild(child: any) { |
| 121 | + this.children.push(child); |
| 122 | + } |
| 123 | +} |
| 124 | + |
| 125 | +function installFakeAndroid(decor: FakeView, page: { content: any }) { |
| 126 | + const g = getGlobalScope(); |
| 127 | + g.android = { |
| 128 | + widget: { FrameLayout: FakeFrameLayout, LinearLayout: FakeLinearLayout, TextView: FakeTextView }, |
| 129 | + view: { |
| 130 | + View: { VISIBLE: 0, GONE: 8 }, |
| 131 | + Gravity: { TOP: 48, BOTTOM: 80, CENTER: 17, CENTER_HORIZONTAL: 1 }, |
| 132 | + animation: { DecelerateInterpolator: class {}, AccelerateInterpolator: class {} }, |
| 133 | + }, |
| 134 | + util: { TypedValue: { COMPLEX_UNIT_SP: 2 } }, |
| 135 | + graphics: { Typeface: { DEFAULT_BOLD: {} }, drawable: { GradientDrawable: FakeDrawable } }, |
| 136 | + }; |
| 137 | + g.java = { lang: { Runnable: FakeRunnable } }; |
| 138 | + const activity = { |
| 139 | + getWindow: () => ({ getDecorView: () => decor }), |
| 140 | + getResources: () => ({ getDisplayMetrics: () => ({ density: 2 }) }), |
| 141 | + }; |
| 142 | + g.Application = { android: { foregroundActivity: activity }, getRootView: () => page }; |
| 143 | + g.GridLayout = FakeNsLayout; |
| 144 | + g.StackLayout = FakeNsLayout; |
| 145 | + g.Label = FakeNsLayout; |
| 146 | +} |
| 147 | + |
| 148 | +const GLOBALS = ['__NS_HMR_DEV_OVERLAY__', '__NS_HMR_DEV_OVERLAY_STATE__', '__NS_HMR_ANDROID_SAFE_INSETS__', 'android', 'java', 'Application', 'GridLayout', 'StackLayout', 'Label']; |
| 149 | + |
| 150 | +function clearGlobals() { |
| 151 | + const g = getGlobalScope(); |
| 152 | + for (const key of GLOBALS) { |
| 153 | + delete g[key]; |
| 154 | + } |
| 155 | +} |
| 156 | + |
| 157 | +describe('Android overlay colour packing', () => { |
| 158 | + it('packs #RRGGBBAA into a signed ARGB int', () => { |
| 159 | + expect(hexRgbaToAndroidArgb('#FFFFFFFF')).toBe(-1); |
| 160 | + expect(hexRgbaToAndroidArgb('#00000000')).toBe(0); |
| 161 | + expect(hexRgbaToAndroidArgb('#FF000080')).toBe((0x80ff0000 | 0) >> 0); |
| 162 | + expect(hexRgbaToAndroidArgb('#0e6e2fff')).toBe((0xff0e6e2f | 0) >> 0); |
| 163 | + }); |
| 164 | + |
| 165 | + it('treats six-digit values as opaque and rejects garbage', () => { |
| 166 | + expect(hexRgbaToAndroidArgb('#112233')).toBe(hexRgbaToAndroidArgb('#112233FF')); |
| 167 | + expect(hexRgbaToAndroidArgb('nope')).toBe(0); |
| 168 | + }); |
| 169 | +}); |
| 170 | + |
| 171 | +describe('Android overlay panel layout', () => { |
| 172 | + it('scales the safe-area margins by density and caps the width by the host', () => { |
| 173 | + const layout = computeAndroidPanelLayout({ position: 'top', density: 2, hostWidthPx: 500, safeInsets: { top: 24, bottom: 0, left: 0, right: 0 } }); |
| 174 | + expect(layout.marginPx).toEqual({ top: 64, right: 32, bottom: 0, left: 32 }); |
| 175 | + expect(layout.widthPx).toBe(500 - 64); |
| 176 | + }); |
| 177 | + |
| 178 | + it('uses the full panel width when the host has not been measured yet', () => { |
| 179 | + const layout = computeAndroidPanelLayout({ position: 'bottom', density: 3, hostWidthPx: 0 }); |
| 180 | + expect(layout.widthPx).toBe(960); |
| 181 | + expect(layout.marginPx.bottom).toBe(24); |
| 182 | + }); |
| 183 | +}); |
| 184 | + |
| 185 | +describe('Android overlay host', () => { |
| 186 | + beforeEach(clearGlobals); |
| 187 | + afterEach(clearGlobals); |
| 188 | + |
| 189 | + it('mounts the chip on the DecorView and leaves the page content untouched', () => { |
| 190 | + const decor = new FakeView(); |
| 191 | + const content = { id: 'app-root' }; |
| 192 | + const page = { content }; |
| 193 | + installFakeAndroid(decor, page); |
| 194 | + |
| 195 | + const api = ensureHmrDevOverlayRuntimeInstalled(true); |
| 196 | + api.setUpdateStage('received', { detail: 'Updating /src/app/guitar/guitar.component.html' }); |
| 197 | + |
| 198 | + expect(page.content).toBe(content); |
| 199 | + expect(decor.children).toHaveLength(1); |
| 200 | + const root = decor.children[0]; |
| 201 | + expect(root.visibility).toBe(0); |
| 202 | + expect(root.clickable).toBe(false); |
| 203 | + const [panel] = root.children; |
| 204 | + const [title, status] = panel.children; |
| 205 | + expect(title.text).toBe(api.getSnapshot().title); |
| 206 | + expect(status.text).toContain('Updating /src/app/guitar/guitar.component.html'); |
| 207 | + expect(panel.layoutParams.gravity).toBe(48 | 1); |
| 208 | + }); |
| 209 | + |
| 210 | + it('reuses the mounted chip across stages and hides it on demand', () => { |
| 211 | + const decor = new FakeView(); |
| 212 | + installFakeAndroid(decor, { content: {} }); |
| 213 | + |
| 214 | + const api = ensureHmrDevOverlayRuntimeInstalled(true); |
| 215 | + api.setUpdateStage('received'); |
| 216 | + api.setUpdateStage('rebooting'); |
| 217 | + expect(decor.children).toHaveLength(1); |
| 218 | + |
| 219 | + api.hide('test'); |
| 220 | + expect(decor.children[0].visibility).toBe(8); |
| 221 | + }); |
| 222 | + |
| 223 | + it('rebuilds the chip when the Activity hands out a new DecorView', () => { |
| 224 | + const firstDecor = new FakeView(); |
| 225 | + installFakeAndroid(firstDecor, { content: {} }); |
| 226 | + const api = ensureHmrDevOverlayRuntimeInstalled(true); |
| 227 | + api.setUpdateStage('received'); |
| 228 | + expect(firstDecor.children).toHaveLength(1); |
| 229 | + |
| 230 | + const secondDecor = new FakeView(); |
| 231 | + getGlobalScope().Application.android.foregroundActivity.getWindow = () => ({ getDecorView: () => secondDecor }); |
| 232 | + api.setUpdateStage('reimporting'); |
| 233 | + |
| 234 | + expect(firstDecor.children).toHaveLength(0); |
| 235 | + expect(secondDecor.children).toHaveLength(1); |
| 236 | + }); |
| 237 | +}); |
0 commit comments