From 1eec617cb97128f4b180424a019a5d7f9b32fddb Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Sun, 29 Mar 2026 23:16:19 -0700 Subject: [PATCH] feat: add settings dialog open/close, tab switching, localStorage management --- muxplex/frontend/app.js | 129 ++++- ...t_frontend_js.cpython-312-pytest-9.0.2.pyc | Bin 33121 -> 94759 bytes muxplex/tests/test_frontend_js.py | 517 ++++++++++++++++++ 3 files changed, 642 insertions(+), 4 deletions(-) diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 4e65092..8bad217 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -130,6 +130,11 @@ let _previewTimer = null; var _previewSessionName = null; // track by NAME, not DOM element +// ─── Settings state ─────────────────────────────────────────────────────────── +let _settingsOpen = false; +const DISPLAY_SETTINGS_KEY = 'muxplex.display'; +const DISPLAY_DEFAULTS = {fontSize: 14, hoverPreviewDelay: 1500, gridColumns: 'auto', bellSound: false, notificationPermission: 'default'}; + // ─── DOM helpers ────────────────────────────────────────────────────────────── function $(id) { return document.getElementById(id); @@ -898,17 +903,117 @@ function _setViewingSession(name) { _viewingSession = name; } +// ─── Settings dialog ────────────────────────────────────────────────────────── + +/** + * Load display settings from localStorage, merging with DISPLAY_DEFAULTS. + * Returns defaults on any error. + * @returns {object} + */ +function loadDisplaySettings() { + try { + const raw = localStorage.getItem(DISPLAY_SETTINGS_KEY); + if (raw === null) return Object.assign({}, DISPLAY_DEFAULTS); + const saved = JSON.parse(raw); + return Object.assign({}, DISPLAY_DEFAULTS, saved); + } catch (_) { + return Object.assign({}, DISPLAY_DEFAULTS); + } +} + +/** + * Save display settings to localStorage. + * @param {object} settings + */ +function saveDisplaySettings(settings) { + try { + localStorage.setItem(DISPLAY_SETTINGS_KEY, JSON.stringify(settings)); + } catch (_) { /* blocked — ok */ } +} + +/** + * Open the settings dialog. + * Sets _settingsOpen, calls dialog.showModal(), removes hidden from backdrop, + * and loads current display settings into form controls. + */ +function openSettings() { + _settingsOpen = true; + const dialog = $('settings-dialog'); + if (dialog) dialog.showModal(); + const backdrop = $('settings-backdrop'); + if (backdrop) backdrop.classList.remove('hidden'); + const settings = loadDisplaySettings(); + const fontSizeEl = $('setting-font-size'); + if (fontSizeEl) fontSizeEl.value = String(settings.fontSize); + const hoverDelayEl = $('setting-hover-delay'); + if (hoverDelayEl) hoverDelayEl.value = String(settings.hoverPreviewDelay); + const gridColumnsEl = $('setting-grid-columns'); + if (gridColumnsEl) gridColumnsEl.value = String(settings.gridColumns); +} + +/** + * Close the settings dialog. + * Sets _settingsOpen to false, calls dialog.close(), adds hidden to backdrop. + */ +function closeSettings() { + _settingsOpen = false; + const dialog = $('settings-dialog'); + if (dialog) dialog.close(); + const backdrop = $('settings-backdrop'); + if (backdrop) backdrop.classList.add('hidden'); +} + +/** + * Switch the active settings tab. + * Toggles settings-tab--active class and aria-selected on tab buttons, + * toggles hidden class on settings-panel elements by matching data-tab. + * @param {string} tabName + */ +function switchSettingsTab(tabName) { + document.querySelectorAll('.settings-tab').forEach(function(tab) { + const isActive = tab.dataset.tab === tabName; + if (isActive) { + tab.classList.add('settings-tab--active'); + tab.setAttribute('aria-selected', 'true'); + } else { + tab.classList.remove('settings-tab--active'); + tab.setAttribute('aria-selected', 'false'); + } + }); + document.querySelectorAll('.settings-panel').forEach(function(panel) { + const panelTab = panel.dataset.tab || panel.dataset.panel; + if (panelTab === tabName) { + panel.classList.remove('hidden'); + } else { + panel.classList.add('hidden'); + } + }); +} + /** * Global keydown handler. - * When in fullscreen: Escape returns to grid. + * If settings are open: Escape closes settings. + * Comma key (not in inputs) opens settings. + * In fullscreen: Escape returns to grid. * @param {KeyboardEvent} e */ function handleGlobalKeydown(e) { - if (_viewMode === 'fullscreen') { + if (_settingsOpen) { if (e.key === 'Escape') { - e.preventDefault(); - closeSession(); + closeSettings(); } + return; + } + if (e.key === ',' && !e.ctrlKey && !e.metaKey) { + const tag = document.activeElement && document.activeElement.tagName; + if (tag !== 'INPUT' && tag !== 'TEXTAREA' && tag !== 'SELECT') { + openSettings(); + return; + } + } + if (_viewMode === 'fullscreen' && e.key === 'Escape') { + e.preventDefault(); + closeSession(); } } @@ -997,6 +1102,16 @@ function bindStaticEventListeners() { on($('session-pill'), 'click', openBottomSheet); on($('sheet-backdrop'), 'click', closeBottomSheet); + // Settings dialog bindings + on($('settings-btn'), 'click', openSettings); + on($('settings-btn-expanded'), 'click', openSettings); + on($('settings-backdrop'), 'click', closeSettings); + const settingsDialog = $('settings-dialog'); + if (settingsDialog) settingsDialog.addEventListener('cancel', closeSettings); + document.querySelectorAll('.settings-tab').forEach(function(tab) { + on(tab, 'click', function() { switchSettingsTab(tab.dataset.tab); }); + }); + // Hover preview — delegated on grid container (tiles are re-rendered each poll) var gridEl = $('session-grid'); if (gridEl && !('ontouchstart' in window)) { // desktop only @@ -1109,6 +1224,12 @@ if (typeof module !== 'undefined' && module.exports) { // Hover preview popover showPreview, hidePreview, + // Settings + loadDisplaySettings, + saveDisplaySettings, + openSettings, + closeSettings, + switchSettingsTab, // Test-only helpers _setCurrentSessions, _setViewMode, diff --git a/muxplex/tests/__pycache__/test_frontend_js.cpython-312-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_frontend_js.cpython-312-pytest-9.0.2.pyc index 946886647489b43627ee125cdabb07692c4d61ef..e323508c6e8528806556a4e4a7509c65eb7702fb 100644 GIT binary patch literal 94759 zcmeHw33MFEdEN}rI0pm?9!oCI;Ry^$42gsHfu)wma(V6ot3AZ0(E!~92OJ#f9&nc< zKugPdept(XOGmOr%WDU+7~9M$k?iO>_KWkJ_$hH5KRbhfB$%Rs=xmKufP8P_vg*458;|v{O?lV&xAsMMiFsUnt{8G z2z@2=N=Oa85mLiyMLPUO1@kKhmA;B_Xt3&y>M(y&^F|GOQu{`2MM$d|SSTv9e69Zd zFpmv9W2K6aUz%E(jf6s~Lhc(4TEoB!k=yd$H-m39YV)-PYSn(k)tA)jFDP#`!LNb8 z5PmKEMeyt3FNR+ae+m2u{Ab`dz+Vb~9{gqS8{t0-e?I)>@E5>e0lx|UO85)muY$h_ z{%ZJ(;je+e1b!6$Gw|2KUkZO6{AKXh!+#e32KdY2Z-l=B{wDY<;Wxuy1-}LUYWOku zYv6B&ABDdK{#y83;je?=3V%KPIQ$Lpx53{Ce>?n5@Y~=w!*7S*0>1-(41Op4&G2`? z-vWOp{H^eJ!Ec4X8-5)A9{Ahf?}fh|{yz9^@b|-ShkpQm2mFKZJK=Z1-vR$Q_&ebr zg1-y?C*bdf|2+IX@DIb^3;ziGeejRM-w*#7`~&b`fPWDFarj;EUxfc0{1fmG!9NNA z6Yx*Le;)p6_=n-2fqw-4S@=iczXbmn{FmXs0KXglariy(Uxfb({1fm$3I8PgSK*(6 z{~G+$@Lz|22L2oH&%*!I<%-zLqkR})J(`g*qUVS8=y`p3D5DLj+ma(A@c|?HU#@&P zdP&n$=Tn*y9Z9COOh$|L4XavI*9M0#CDYO5kQ%)JC#@Yz5BDb1C$!7z@S8(XBQ-dZ zM!CLZCN(@1{|g$|r}MSq1=(u;hJ0n>#cpbYylN1lDSP*;m|kDObQ}+jg)pbh>r!|; zJQgXy=zX7BE#9NnWbs(79ji!% zS;@*wlgMwY!w0~PRf;-<|M{?5_ZI$-@$lukSbcWO*+VCf^zKh zqat64hval*TRru--$jFXFv^G5y#@6LlZK(`8CK(tm_fDdIDMm@)ts+?Zsc+Xqd$?< z^G%7qWICPDwGq8zGdTLzHvC4?n&s1% z8{63Ti+PzCFybSZ^XpjR&~U<>^NGRaTZvRg8#EH)FV@%Koiz0Bz6a+@=uaZ6KL}M+ zRp%nBulMC5vA?FsUnzePx?3euS<;6h{e!Ad!y0GOUsKlaiwr-g3N>$bX0VcE^&eD* z>URHiB?_{nABxhfE*86s_Jsl%L%ra9Ev@xsH1+t9s=XDxl+;tn-n8bP z`|w=8r4MGk>B{40rRI_jPavbxv-L{-I`%w1u}|Ep=E{2_|BrvLb)GwB5ih0r1Rz{ zBnlP>g67gDXOd?w#oL@2Fqh0ESv}3A zu3#=brTFe9G1AYBXhTKjPlG+5@0@9|=XlV0vdTSAhS5B0YHY={SWi>qt8fTSdzMX& ziG$Y{o4N zqrsp=58!%22W9+1s{cY7zl=F9kAuZhWLz%x%kMihxBKOM(9tQOrmS2JP~r_)19T_; zlD+szbbPIej<5X&kdCbqEiln>q&hJO!aCMDlRQ|*W=v&4B zj*W!pn)9Z}oQc@7`A(H)TZRXnArcp7IJjV*O2AEX;t+m@z{QtwT>QZG%3Or=awNxe z0*THkK(pdmXOd@DtmPQJeO8!Bj#<$j%!;RUR&44^4;xzPY0+TM=Q}T2>^UBEVyqU3 z4Xacx8**kGz^r%yKfRTl0MCA6QJR)v0h1ZQRs=9NI&=Dt!OhM*4>}6hicvT`B!?iU z{NzA<4&(0(eiC(AVWKW8u6N`joJS~ObS{EaCqyP7z0#TF0n!_qof&|-m`M&Gy(0k9 zPbrX&bBPHPN(+$*F$UeEJf2_dm*027Z1>CQu5h+gsfG75K@T}~E^=893-3RSiStSP z^j+l48j3@(AQ0_%EJQn6{6u=3<5PPJJd}8LrA+lHawD9tVB#GMg?LA5$RXOnH)74{ z;1nQY5+p_@l}<<5s~{(03U!pT3(FxrZ<3J95W|&KXXwWC4Pa>!L)+u*8t{=xvPnxVvDLjml(BqE0!z2&^pjDNvRaUisxl>}ZH*N1&?ka{TB(D@m zqSa9en*^FASLXLsQu0cz|H%4O{$C~gROUpV)~gXy;;hQ{oj$T~YJ=!QbsifJjbq~f zs4n~ekWy=+E=Zishs0UsxN>=ZY(dss-FyHAiH-GL(E2V$#p-kh+=2ML_F~}aoU!>_ zi*_zX{%2d_o4aD?hR%)O+vEeuEIgLpbbferNOj4UZRfIO+w)bB`s%yz7c?jJE#yeq zlf2S9tX}3Yufy*p4Cc`bbdAK$hWa$e=hy(>jmCTh1k|bxEvffi;KFRxhtKpJI(bs3 z8Ku*3*B6nqm>ilBIt?{_DLKpFb=qIo zSHUspgGky87ngb2&hTn~XloN9V@whDFX^wNit|)g2>o;=^n>cqy!r1Qf9Lp}+5_LM zjeNJZ{@uEF>h9Jk^Qx~L`LI6J&@`j$y{+ucE!>NM{l2N}o!G$>rmdjNaVN6i-F@%u zyZZOOc<{>MJ96)Ha&a!gcP%9@KXGt!u@}G>-TTA}u+AklU?n+F`GWy~$Db{)0Kf|X z7XWw%fG>;*G5`ydot%T~Q1N*;RxP<4rIU8tX)yMgZSdbfW$lbjpw7XZ$py$L0nXS(;N` znb?1=GZ$Giqr8Iq7LW7DG(~t&x+rk1Q$7|!Bupu<a7&>Q zKI}n1iZnal6bTY-@}*Aq7krE44;KDD+p<(_q3B=>1yz&4fnD@uLZhyf63|A%#|7)c7h>6U-B!AC3%h0%$9+@jB9lJXpwm9b*YKNDu(`?v zo2w>)lzI;1Bh?8b0RkIVJCi(MbCYYg8)eKS2iSb!zQJao@dKj-mjE-sW*;w&AG>k% zRvcCE94Mx_5xt){>|UOPahy>EgZrEyxR z#!rut6O7m=_ME~F8@zQ4y=aLMYr#%Ou>vEuK<~)nSH0pW@nxU zoh9-tWXg~zvVD?VM(5tdPd`LXVH_ja_OlH^A#5H9|N9-NkRtt0u<(By1U*L6S{&L^ zhjr*HmYZQb0W$M01uyY?BpZSv=R_Io=^d~M`;YItx7t-{FBH38WJ+k6jK3d4T5qRm zDaA_jHD-f2-)g(7!ji3Yvz|>+eEl_g%Q!h@iQuniORw1!+Pw48AfvyH>i!r%Q|ewy zd2A`Pf!BUpY0oWepHUq5O{JYS@F;EC3d&4(8W+8L@tup;)_!s5%CS3^TG+{cAGMaN zC;Poid{MQqzyX?FYAvlK#}a?vx<3NQ8bDS|lMobp+Yg{Ut&p{|1k94^eoU;2v=qhl z&!U6$F>?H5?|0Ax2_!inW1@B)_wh0}jMP7e+PxC^o7^x^HeBLaq^CtRNOes}rd zqqzZtQU%hlEcMYyy7pI=dcj~(6-a=2n_U`7tt1B+3=SXRzqYPGj6neRK#aj5#;~OX zWFi{Pgn*@>pX=liL*p3q+g$!T$^?2V-9V3#lBUdr{soNtzs1je#^|A9eDaJ@jfbZ* zabU8>3+PIoF>+YL<9l*K()?uQ2Txi>P30UV;FjorNbrphkt+RSfnedM7@Fi^U&aJjipFn$-5XhTL zN>*ybo7|O)aG6lsp52PcU0wvQs75?MxD8u8Wy~Z8(7W$LFZY==^@{4@VxZBRuTdXc$AXMt@Wda5+!7nf)R%zlw(!h@_ z(Yh2q>OYBIZ<}i@DQcb)h)y2MML2q6!;+Bd!FU?hI+L8I1b`)N@CU z0$Bha1Mo-y>SMC)xwj;EEOf516pUO}sGu*P+;iXUC=*(?JGKZcXj$a=w|*7lpo?Ip z>#X-f-8hg|8-4dkH~Q_7y7Vu4U}l+nq=!H1lU0E(>*~kEOnIDyoME-Q-(b?9r0ziT z+NM*rzBEjJ!&3P?M}FIkwGJn?II>82P*KP1R8_aI*(utXVB2@BkE823Fi0qDFSO@OsI1>oD@$GNZhV`<9u& zW790g!_q~W>)rCX2qIw$xi?cKuDi$_IFIv*UkUYx#zShDtQS~kQ!j2Q#&xNrI2;GT?RDD}iCbkc>fD#5;9M=CzT2jp z-F{N0!Cm7=cKWtTtu_^lSF1Hpl3r~pZ?Ay@b~3H*y2fe*Y90=uN3WIYv$v~A>H6EY zJ}A^2*5Nw(ZPqzgNrhbfOTYb~4?>*?3U^ z>S_1Aruu_P!_dI9VYN{csJ30FV-2j8T&_xCVIsrZ%Sc-Ja6PWYO&@d-4s>ja*Kc1=^T?c)J$)-TXO!X4@z%sL!EbUS3?&JU?OPyI5RGrbjjXn<(}=EfJrBb0zelQc+c%vt-b(uZQCI zAA~3|wx4roV*9^SnTzp*X5XhLHa5-b>+7978L(U$ib|*BwAp$WO=2any5u`PsKU$s zQu!fy);p;=o_B6O;7oFoIe5XLFen&mykrj96Mf(N1Myx~*$>5+ljf^}_DEPoENhW9 zF~AEk25||f_Sl`)-y&eo;Uh;6y>haryBJQmz@6C_A6)0ob$__vMn(&X>8)z#lcVX3 z;lTrc4ZZsrJ07?yr@WYptYRkZXOtIl-!c<;Y?{S*Sh^^4eUW@Ff=HM`?oCvQ>n<|p zw=+BtI3Tp7Iq-w4$-gR)-HhGA2#th*<|* zqK@#0aTu`){uSPbvw}ntJ9{|)%H_IPL)Ht47&ZqIHIpPQ&+xlbS#58$tuvml6;WRZ zBq>q})L2!ip}zE}s>!NsEYo~NTW5BI2XKkENC*a$jVKcRSvUq!XoQn+bcCO}^S~?G zilS-~1{$ybI*Mont$rT?qFR^al%o@S--9Xal{3mw+_!j~N2V#lgVIHT_W-IWFCL2^ zVpGb|+-gd;-?x>ccOvt1%E5`wFLzArn^B;A_~0!b<&kNM@St>&|H~cnu?QkzN;#NY zMpd}(BH%DU0q|Gj62NFy6Hi*fdT0}`KD*O1>>U&D!tf5iNvZiyMsFqa&{>SaI$dzCwff@*Y}=Qbn;7BkO{mmh{WnqLMLUkLI;Xq_ zN`O%SGs~hx1Mfl0%90&Lh(l;X&!5z=Q?toEiy&fC$_u$QlVs;0oDk6T!IWVj1h{W9B>?)L@z3KsA3FWtCRTw0HI*?r+6qq&d!-3zuQDskPxWO;HY zOG|nAHig}`yK*BoIg4%;P-vHC7`zP-{E>}35ymZc}bh)^zkX;KYe^MeiYM( zsx3Wzeiz+7f$5|FYXkxak4|_(Y2URZcUByxTLWF%v-i_t5X}+A*4SIRzp?>#jpGE;gfd~wv9a$a>0P|f1uH2v? z;zFAVU$&2VVDYb5SUed*ggHf)z-=PJo+4s)`-ljm4599n8_z`$F*2yQm6Ba|Fy^1! zF~7-pGnMJPAjWY|ve$Rq-&-!ny{CXLwBO>%_F&`Rl!#`HwFE*l$zBiO|3my4KTDH9 zVDHW5sjKF1bmSsjOQa=Q6G5s|NQT6DT)KrkqBUE$Im#!O=AR6WVe+_yrGdWhS7 zC6vKe@N2*n#1nDy)o_M39@%9v(*014_f3&I+VVC&Yg1-J>Ze?nD%HZOm2Bf3)`l{~ zb70qi&sM!nOth3@M2U@;R5>eiJ_6-K!J$;~cf)_!hf*OXT>~TM5QQGAfOvH6SS>`L zYmHj9X}oT%j-}O))th3`L$y44j7 zLarQZG+Q&@CgyCt)lbZMzNka^5KWp7(IgUct|9Se{A%&58?T3b+f~^U1sP(xmQ=a1 zGpMOfrn@u4da_@Ojv6@K6crq1`(cmxy(4lh_o>y-&}~cCfpbH$(Ac@5ZQG);dz*Yp zTgU@A%@DAmU6@noYyC7iXUI7VC*NRitPO#9iE>b({96pC#w3FV*=aPDfAEF#1`;wW4gat_YmY`V%Yjh3K3|~e~Fx5Cg)ek zxk1i9B8OII{Tt-`V{-lp9D`az@=ErL%P#R#q4fP;HV<_>M40s9!36vB3_jBIo2cy< z3E$d)X>=tdHRql!q(FS{ZDnt6A;g&+_f2IlDG;Z$X)7o*xhw#~xO$H?t`3{&J-2uq zA>7jx;X&!509jNwAB!Lorj(xCdh?0QeOu}IumYmfI7nn-k87FHOfDwgdXOi!9{iUZ za;--h&L>5I8toxQYsle(>tK`W#Twvljp6n$Q{?z8w%K`(+`g;rp@J=;HP2B# zni-DTnkNME?d$K$)RI@>c=fS!i9P=n2Cp&_!|coG^bo#Gzl}V!7ZLq`f=alaUPkb2oFjZ1+J;`u?QkzO6kvSpekH<5tiAPfi?uRKmPa7+`&Ll zp_OWlq(bQ+ZuM9dsL%Dzt8l4w}~AClwlb#8fB_k(v;)PMxPV zj@6m8P5oGz`=+5l z-!xc#V_VJqsL)Ce6*|vW!jB4FB6I+_yTX2+?7 zWsz8n_9{qc(FqYdjfF5LvpO@diw-KvpUk3;Ga#{U9(1EEP3l+uk5Jvez*>0s+i>QF z&|);u#4bkdOzgU=$gh-u4$8*YlBV-fl)t{)k9J=EOI7!2$7{LoEbodm`To8 z_!2+V7EkdtE^^58$`-HoG&9Nfl||^D=L;$eVl|Z_d_Em4`WV!7&E2Ap1s~cr*y0ZM zv++$Ez@=&Ky1-mkuyoZ?iL#e2c>?h)G3r=6W)meGsQwp{L->hy9?S)+_Z zX4uI*{ZoA29{}Q;IpdSj-Z238SW-`G}aKOTi5F zV~#G3Ez554o^Jrn5S7BJXKF@g<=kus~*_2H2c3&_1DpxewON~PQm*4eQXpaJd#^}J>WRs+LsLK*`4ZeycX%g$wrWxf`+_%gG9-C${9+oc3 zOk!oC+;}d6h)p4P3nkm{+sdnVB8zBuXZO`iF0z8};?V96;ygmTJG*&Mx+q|KEP{AU zyF07gyE{lPvi#kw3Zxe?x^M;fyG@|tDU{$2mcK`_{HY5A@evMR?5=7_gFYExGtZF&89B$h-&H5Xk7mX`=-0krlW{D{07XWg4>Cw$=;yNS#T65`pfA zIS~8N#5%0Pj+fi$+3X^bm1!GpWmPFrlaOlxb8;~r&Kr+`ZSy zKj4QL5o>8pcWrjQSZK4e^S08NTi7|HIPRNDCvA38+O!pvnabPje3Nf>zWJ>UlW#k> z1s~aFC)(oKxNLE0XYy=zcI@($F_WBMW$v#Q+3egE9Cv3h%kGRx+0&9ovt`bJS@Lu< zOW1LDKYfVyO-Ke3X{YlphLipSa(s3=v$b(Eqc4AYlMm$fd5MiGEKPa2U^U<77u~3#qzNTB4J87 zmRm!mTz3&NCD`bkdom@E)bQmwN;Lc7NNNO}Uf*7yi;d3hKF~a=s?Kdra2v(eUWv6s z|6jDI_^}rGn$j)o7?_I2%G%egM1!&&J`H8d4z<>~rHPajX{s)|r@19Vdz!9=2PNf> zCQg!xkJkSWzG@$>KJ8Av-$Pw}OxtPZex_wRjhJ~9D`Y#(_FM#!n{21GxDBV>R$c>P zl2gu1?6`WEj8UDzeTzqVWSSy8C|%^gdRRUdK_pBmXL8G_3fEm^k^8sk)vrL+YG5rC zLkt)2bSQ*1m0&z#8?M&5*a06Ei#T!3n+#WQR!G4M6yVZH5_b8ivsI;9O|^l^jzbBG z4LA1@6nx*T?NZ2CVyl~smAkGnscaOKk=2T7S2jegoak|(Ub3oe%ePemH4*;j!?3M7 zKfAqvR_0`1pEi<-LY?yIQqpRei!dn5;JwSRy``uzR?TeKyLBouZI!R#gAMwF zxFQd(zk>s3-c-r@N{}*YvOG!t5c)5{x>5hXDEuM;=ifqrtjRP(-XceOr=g9S%V01= z1W4d9W{9Afhoy@$w&x;<$0Vb%-7TYm6S&`PXngnhJIC+Twt-P}x2kep^_3$Z)`l9I zW|V!mm3_H|5Q22vH?r`HGQ<7- zX`2)rAVF(u5@tytn8zgY;`g2}0SEyHODj~^;C;WWwy54+xo2&(+S+D=@77-XZtpu7 zQe5&IZD79`XN4;Q^n;=N4Mu`@9B^QWGU{Pqwmk$zDPR-XJ6(tkzWcVaJGT&`ZjSq= zvYUV$rA=EwnaRNh$5t@ocZ*A12i@Ovk*(F>$14d0VuZh z)YA%z_Z0_INj;TpHMBI8rNUz1@DQQ6(caNaW_SofN&gRHM-Ks@xX?+aqdYs$QP>wq z7P4|;0!e2DmMC;FxERk6;a?Ok;+ueV@^8{B(fnQlj4saeLj{b`kouC;kowY$^3p9H zM+o;cMR-uUC_rja&Br2$gem2v+y+W^-9@m?KtN{hu}yygnLtBopdmF-?qbuBdbId* zzFrO&U;tSB?YNutK zEsT;9a{1O@q;>S`aJUxL5=Z?@WB%-2=FuI!3Jcs2EpWd%XPCcf`eB8M{;#-(!!+yp zdH*aIfysWWC)d2kDJ^0een4)IbVeQBYXOicY zJNG&>pp2R1`^q9hFsB|&_i4a9n)~HE*hu!>(tOAp$&7i_${{OkKlCQkC$!7z@S8)N z$=!EB>$~WbrHtz*2)3Ri$43@MCzUR~l*!iFyroQ*4L@heD^B#Y_9rg#4@j`KyCfL( z8bIvN5$OIqG}FekIZ%jc^HG=vTFxM}Nu4lICV4iL=mB~Us*OdkYzm431{@n3A0pt` zU@AB-$Nr&ZHtuQV*c>kmnMmobQ;q^S@)4siIRaZa7jUdy;JwV+cnR2;pXaE`7fNoK zMO(@Ql6x5crT~y+`cYg$9i-o%eWG3uaQPD-7jSvsws9x08ynciArKNUxNQr|QwT1> zbMv!}yMo!qtr#HdkFIOHz9KYMFBX*X2rTGEU{$watiiOP`!lv4Tka~1=u>Jzm#oCD z@qX=?%}OjR=zhCY-@M%b0}!yF`*x&k0}zjq1zp?M`5<9$OFK@*^I2Ah)urz#S-|4) zMrM&$2r11k(AU!9t>Y+M)aPIETI5||Ym<)vmg=?0yTG)C)MN>X+3N5S7aNn(tb!O9 z#uJFUho7{_i*N&e_2UtcbE~p@3!)HoUfaT082-{XJU9p`CS5ZOO)Z7LG*AUXBGngt z*r3>&&p>W0oPBX)%gE)n?J)!A`lBqoGiKC( zR)nC^lm$CwB0?guJ!Zty!*6PO{dh~Pemth=zkokn{|DrJ4NhJ`)jPAbtcKk&gT;4e zOA)V*Z^<^*AB2Q}re|0~+S_hg zN#1V}DZ+z3`PX9f5C@*7`!g9X3##@ElWz_sSdbGhYh z>?P86EkNVc3b=tKSaUe?^ZI|ZbhB< zk(t|+w!TW9dX+grzzL&`;j$5xus~3R583WU$acTzhYau)9~xQpOy6jRvzeKMU`p3$ zJx>jdjAp>seJhiMefMo#s-zTRQg!_F*;jhK0t>oR|-QS-1S!u1WQm2fjA&^>l7UJ4?A! zyYQ;|m4Tlc_)<2ZWs>L7H zhZpf?{{4TAmvLMq-KGEd`?$Yo_ut_MUd%*Rv z0r{tp>%%q)kM^d9)b30&lj=Ki2_hjUQ$|J`(qLSd31QPyNm{n{W=P>{3VVk*327(# z;(9NDnBGT@&+cKiLGaV53SSCy$9=WKMqU;Zb?jg;mEMpr*rz5m`qo5Rd~AZA*~k-W z<$A!;e?Cp*#je{*S8gHX5gqqUrHcf?DQ(&c%1kan@JB_NJXcV%BG^>4 zK>;6rRv-qB>4A$KO0?`%nfS2E!YvO$ogO{dB6baAxb{y=YcpzWHVrP;E zS}t>)<^Z%blVt4#Ef3u{vV@~C*$g_k|HS%}@qhBc3jg6S%xuRGwU+tBiWpUx>6oBJ^%V79@2 z?`T?!gGCI{5p&m(N1|kT=y1vyNhdFNo9EZ#`e)Hs`WQJr8;$uzK4ok~%Q%%Id7W&0 zcJH-;e-5=jhe`|*8i3#iU0ir$9V1rEZ%I&F5~k5Asl89%4$#~Hos4%wP8hwV(7s~( zZKXZ8uzf~x+&7hW+E=8sX)7o*-DzC(?!|X5UR(Rcp)1E=)5rvb(aB@E2xnf~c;HBN zQX<3y=Yo-vE$$&sHg5HlF_Rp`$({fZVwA`$V1WyYE8yg)^C8|}Ij%54jADS$NM6!r z3kZG6%o-5-)j`}hZfCcAKuGm?0bwWL-*Ny#y$yjtB8!2DfdC_tpi%@rW*AXR5zNFX zb5#@-(i>=29i`dIC}0cqX)f~xW#T-`9XJoQ(86_gq75yW;%>bR4;9yu^eC)g^DQcg z>P#HG_NoVKX()NRW22|cYM$i4blL+2OLdICsqXhxHhGVL%6y^ z5Uv;qb-bZW5Vyhs;#dt9jN6S??3`;DCw0&C=OP>wSun2aTE7QgxrO9C)ZCg)o-$^V z1IBd(FfM>`Ru2UG@&d-YU|eev80XmsD8bayag@y;liNC*x0DI%q7GQc+bV$_?K0?l z(VW8s@aB?%WyvBn=OQr)lcX$?SFWhEEE1Oml^iaTMdC@adfHttcwp4x)M-oDx>A=WKzDy zOw&n%I$txA%v?yPdf5TAymA(?d~G6eK9$z+f@&E4fHX(G=Ed&B*+V@q`Uuk6Y{QN{BIfS!#IO73j)QN;n8Y=V+|e21LhCFl3Z`F(Q!ker+3+$QIb$oW%p{*0VIC+BFmS+`eO4ME&T59%>M!Cs?vQaC?gy3Oa5x`I{f|)S3!xuv{G(9a zpVU7y(fsb#ceY-AYq~x;D7Tjs8*q delta 356 zcmWO2y-vbl6vpwsMJiNU`KCZyXraK1pBI2$fP)v{N*PQP$*Ub442f}dU}AMME-nt- zgWiFgi7|2uPX15woZoZS^ZcFuy4URw%Q7|QG5dc0J6za~{wfxO)jW!ghmEsB9L1Yh z#0-`&i)A#if?HU{9M;f62W_n5Ha0MiO)Q{`Mf9+QK9;eC6>QHbRZ4(04AH?3*0GBX zjIfD4bg_>f4$w!5EgWJScQC+R3~>)TxQ|^txYcDZ8EbDxDS2l}{nJpBn?y}d<>4wn m6(bS8Xe!`e%4hv$MweG{axpnpPn?2WueIgLO#Cc8CCq None: assert "bindStaticEventListeners" in exports, ( "module.exports must still export bindStaticEventListeners" ) + + +# ── Settings state variables ───────────────────────────────────────────────── + +def test_settings_open_state_variable_exists() -> None: + """_settingsOpen state variable must be declared.""" + assert "_settingsOpen" in _JS, "_settingsOpen must be declared in app.js" + + +def test_display_settings_key_constant_exists() -> None: + """DISPLAY_SETTINGS_KEY constant must be declared.""" + assert "DISPLAY_SETTINGS_KEY" in _JS, "DISPLAY_SETTINGS_KEY must be in app.js" + + +def test_display_settings_key_value() -> None: + """DISPLAY_SETTINGS_KEY must be 'muxplex.display'.""" + assert "'muxplex.display'" in _JS or '"muxplex.display"' in _JS, ( + "DISPLAY_SETTINGS_KEY must be 'muxplex.display'" + ) + + +def test_display_defaults_constant_exists() -> None: + """DISPLAY_DEFAULTS constant must be declared.""" + assert "DISPLAY_DEFAULTS" in _JS, "DISPLAY_DEFAULTS must be in app.js" + + +def test_display_defaults_has_font_size() -> None: + """DISPLAY_DEFAULTS must contain fontSize: 14.""" + assert "fontSize" in _JS, "DISPLAY_DEFAULTS must include fontSize" + assert "14" in _JS, "DISPLAY_DEFAULTS fontSize must be 14" + + +def test_display_defaults_has_hover_preview_delay() -> None: + """DISPLAY_DEFAULTS must contain hoverPreviewDelay: 1500.""" + assert "hoverPreviewDelay" in _JS, "DISPLAY_DEFAULTS must include hoverPreviewDelay" + + +def test_display_defaults_has_grid_columns() -> None: + """DISPLAY_DEFAULTS must contain gridColumns: 'auto'.""" + assert "gridColumns" in _JS, "DISPLAY_DEFAULTS must include gridColumns" + + +def test_display_defaults_has_bell_sound() -> None: + """DISPLAY_DEFAULTS must contain bellSound: false.""" + assert "bellSound" in _JS, "DISPLAY_DEFAULTS must include bellSound" + + +def test_display_defaults_has_notification_permission() -> None: + """DISPLAY_DEFAULTS must contain notificationPermission: 'default'.""" + assert "notificationPermission" in _JS, "DISPLAY_DEFAULTS must include notificationPermission" + + +# ── Settings functions must exist ───────────────────────────────────────────── + +def test_load_display_settings_function_exists() -> None: + """loadDisplaySettings function must exist.""" + assert "function loadDisplaySettings" in _JS, ( + "loadDisplaySettings must be defined in app.js" + ) + + +def test_save_display_settings_function_exists() -> None: + """saveDisplaySettings function must exist.""" + assert "function saveDisplaySettings" in _JS, ( + "saveDisplaySettings must be defined in app.js" + ) + + +def test_open_settings_function_exists() -> None: + """openSettings function must exist.""" + assert "function openSettings" in _JS, "openSettings must be defined in app.js" + + +def test_close_settings_function_exists() -> None: + """closeSettings function must exist.""" + assert "function closeSettings" in _JS, "closeSettings must be defined in app.js" + + +def test_switch_settings_tab_function_exists() -> None: + """switchSettingsTab function must exist.""" + assert "function switchSettingsTab" in _JS, ( + "switchSettingsTab must be defined in app.js" + ) + + +# ── loadDisplaySettings implementation ─────────────────────────────────────── + +def test_load_display_settings_reads_from_localstorage() -> None: + """loadDisplaySettings must read from localStorage using DISPLAY_SETTINGS_KEY.""" + match = re.search( + r"function loadDisplaySettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "loadDisplaySettings function not found" + body = match.group(1) + assert "localStorage" in body, "loadDisplaySettings must read from localStorage" + assert "DISPLAY_SETTINGS_KEY" in body or "muxplex.display" in body, ( + "loadDisplaySettings must use DISPLAY_SETTINGS_KEY" + ) + + +def test_load_display_settings_uses_object_assign() -> None: + """loadDisplaySettings must merge with DISPLAY_DEFAULTS via Object.assign.""" + match = re.search( + r"function loadDisplaySettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "loadDisplaySettings function not found" + body = match.group(1) + assert "Object.assign" in body, "loadDisplaySettings must use Object.assign to merge with defaults" + + +def test_load_display_settings_returns_defaults_on_error() -> None: + """loadDisplaySettings must catch errors and return defaults.""" + match = re.search( + r"function loadDisplaySettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "loadDisplaySettings function not found" + body = match.group(1) + assert "catch" in body or "try" in body, ( + "loadDisplaySettings must have error handling (try/catch)" + ) + + +# ── saveDisplaySettings implementation ─────────────────────────────────────── + +def test_save_display_settings_writes_to_localstorage() -> None: + """saveDisplaySettings must write to localStorage.""" + match = re.search( + r"function saveDisplaySettings\s*\(.*?\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "saveDisplaySettings function not found" + body = match.group(1) + assert "localStorage" in body, "saveDisplaySettings must write to localStorage" + assert "setItem" in body, "saveDisplaySettings must call localStorage.setItem" + + +def test_save_display_settings_catches_errors() -> None: + """saveDisplaySettings must catch errors.""" + match = re.search( + r"function saveDisplaySettings\s*\(.*?\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "saveDisplaySettings function not found" + body = match.group(1) + assert "catch" in body or "try" in body, ( + "saveDisplaySettings must have error handling (try/catch)" + ) + + +# ── openSettings implementation ─────────────────────────────────────────────── + +def test_open_settings_sets_settings_open_true() -> None: + """openSettings must set _settingsOpen = true.""" + match = re.search( + r"function openSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "openSettings function not found" + body = match.group(1) + assert "_settingsOpen" in body and "true" in body, ( + "openSettings must set _settingsOpen = true" + ) + + +def test_open_settings_calls_show_modal() -> None: + """openSettings must call dialog.showModal().""" + match = re.search( + r"function openSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "openSettings function not found" + body = match.group(1) + assert "showModal" in body, "openSettings must call dialog.showModal()" + + +def test_open_settings_removes_hidden_from_backdrop() -> None: + """openSettings must remove hidden class from backdrop.""" + match = re.search( + r"function openSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "openSettings function not found" + body = match.group(1) + assert "settings-backdrop" in body, "openSettings must reference settings-backdrop" + assert "remove" in body, "openSettings must call remove (hidden class removal)" + + +def test_open_settings_loads_form_controls() -> None: + """openSettings must load display settings into form controls.""" + match = re.search( + r"function openSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "openSettings function not found" + body = match.group(1) + assert "setting-font-size" in body, "openSettings must set setting-font-size form control" + assert "setting-hover-delay" in body, "openSettings must set setting-hover-delay form control" + assert "setting-grid-columns" in body, "openSettings must set setting-grid-columns form control" + + +# ── closeSettings implementation ────────────────────────────────────────────── + +def test_close_settings_sets_settings_open_false() -> None: + """closeSettings must set _settingsOpen = false.""" + match = re.search( + r"function closeSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "closeSettings function not found" + body = match.group(1) + assert "_settingsOpen" in body and "false" in body, ( + "closeSettings must set _settingsOpen = false" + ) + + +def test_close_settings_calls_dialog_close() -> None: + """closeSettings must call dialog.close().""" + match = re.search( + r"function closeSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "closeSettings function not found" + body = match.group(1) + assert ".close()" in body, "closeSettings must call dialog.close()" + + +def test_close_settings_adds_hidden_to_backdrop() -> None: + """closeSettings must add hidden class to backdrop.""" + match = re.search( + r"function closeSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "closeSettings function not found" + body = match.group(1) + assert "settings-backdrop" in body, "closeSettings must reference settings-backdrop" + assert "add" in body, "closeSettings must call add (hidden class addition)" + + +# ── switchSettingsTab implementation ────────────────────────────────────────── + +def test_switch_settings_tab_has_tab_name_param() -> None: + """switchSettingsTab must accept tabName parameter.""" + assert "function switchSettingsTab" in _JS, "switchSettingsTab must be defined" + match = re.search( + r"function switchSettingsTab\s*\((\w+)\)", + _JS, + ) + assert match, "switchSettingsTab must have a parameter" + + +def test_switch_settings_tab_toggles_active_class() -> None: + """switchSettingsTab must toggle settings-tab--active class.""" + match = re.search( + r"function switchSettingsTab\s*\(\w+\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "switchSettingsTab function not found" + body = match.group(1) + assert "settings-tab--active" in body, ( + "switchSettingsTab must toggle settings-tab--active class" + ) + + +def test_switch_settings_tab_toggles_aria_selected() -> None: + """switchSettingsTab must toggle aria-selected on tab buttons.""" + match = re.search( + r"function switchSettingsTab\s*\(\w+\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "switchSettingsTab function not found" + body = match.group(1) + assert "aria-selected" in body, "switchSettingsTab must toggle aria-selected" + + +def test_switch_settings_tab_toggles_panel_hidden() -> None: + """switchSettingsTab must toggle hidden class on settings-panel elements.""" + match = re.search( + r"function switchSettingsTab\s*\(\w+\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "switchSettingsTab function not found" + body = match.group(1) + assert "settings-panel" in body or "data-tab" in body, ( + "switchSettingsTab must handle settings-panel elements via data-tab" + ) + + +# ── handleGlobalKeydown settings integration ───────────────────────────────── + +def test_handle_global_keydown_checks_settings_open() -> None: + """handleGlobalKeydown must check _settingsOpen.""" + match = re.search( + r"function handleGlobalKeydown\s*\(e\)\s*\{(.*?)\n\}", + _JS, + re.DOTALL, + ) + assert match, "handleGlobalKeydown function not found" + body = match.group(1) + assert "_settingsOpen" in body, ( + "handleGlobalKeydown must check _settingsOpen" + ) + + +def test_handle_global_keydown_calls_close_settings_on_escape() -> None: + """handleGlobalKeydown must call closeSettings on Escape when settings open.""" + match = re.search( + r"function handleGlobalKeydown\s*\(e\)\s*\{(.*?)\n\}", + _JS, + re.DOTALL, + ) + assert match, "handleGlobalKeydown function not found" + body = match.group(1) + assert "closeSettings" in body, ( + "handleGlobalKeydown must call closeSettings" + ) + + +def test_handle_global_keydown_opens_settings_on_comma() -> None: + """handleGlobalKeydown must open settings when comma pressed.""" + match = re.search( + r"function handleGlobalKeydown\s*\(e\)\s*\{(.*?)\n\}", + _JS, + re.DOTALL, + ) + assert match, "handleGlobalKeydown function not found" + body = match.group(1) + assert "openSettings" in body, ( + "handleGlobalKeydown must call openSettings for comma key" + ) + assert "," in body or "comma" in body.lower(), ( + "handleGlobalKeydown must check for comma key" + ) + + +def test_handle_global_keydown_comma_guards_input_elements() -> None: + """handleGlobalKeydown comma shortcut must not fire in input/textarea/select.""" + match = re.search( + r"function handleGlobalKeydown\s*\(e\)\s*\{(.*?)\n\}", + _JS, + re.DOTALL, + ) + assert match, "handleGlobalKeydown function not found" + body = match.group(1) + # Must guard against inputs + has_input_guard = ( + "INPUT" in body or "input" in body.lower() or + "textarea" in body.lower() or "select" in body.lower() or + "tagName" in body + ) + assert has_input_guard, ( + "handleGlobalKeydown comma shortcut must guard against input/textarea/select" + ) + + +# ── bindStaticEventListeners settings wiring ───────────────────────────────── + +def test_bind_static_event_listeners_binds_settings_btn() -> None: + """bindStaticEventListeners must bind settings-btn click to openSettings.""" + match = re.search( + r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}", + _JS, + re.DOTALL, + ) + assert match, "bindStaticEventListeners function not found" + body = match.group(1) + assert "settings-btn" in body, ( + "bindStaticEventListeners must bind settings-btn click" + ) + + +def test_bind_static_event_listeners_binds_settings_btn_expanded() -> None: + """bindStaticEventListeners must bind settings-btn-expanded click to openSettings.""" + match = re.search( + r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}", + _JS, + re.DOTALL, + ) + assert match, "bindStaticEventListeners function not found" + body = match.group(1) + assert "settings-btn-expanded" in body, ( + "bindStaticEventListeners must bind settings-btn-expanded click" + ) + + +def test_bind_static_event_listeners_binds_settings_backdrop() -> None: + """bindStaticEventListeners must bind settings-backdrop click to closeSettings.""" + match = re.search( + r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}", + _JS, + re.DOTALL, + ) + assert match, "bindStaticEventListeners function not found" + body = match.group(1) + assert "settings-backdrop" in body, ( + "bindStaticEventListeners must bind settings-backdrop click" + ) + + +def test_bind_static_event_listeners_binds_dialog_cancel() -> None: + """bindStaticEventListeners must bind dialog cancel event to closeSettings.""" + match = re.search( + r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}", + _JS, + re.DOTALL, + ) + assert match, "bindStaticEventListeners function not found" + body = match.group(1) + assert "cancel" in body, ( + "bindStaticEventListeners must bind dialog cancel event" + ) + + +def test_bind_static_event_listeners_binds_settings_tabs() -> None: + """bindStaticEventListeners must bind .settings-tab click to switchSettingsTab.""" + match = re.search( + r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}", + _JS, + re.DOTALL, + ) + assert match, "bindStaticEventListeners function not found" + body = match.group(1) + assert "settings-tab" in body, ( + "bindStaticEventListeners must bind .settings-tab click" + ) + assert "switchSettingsTab" in body, ( + "bindStaticEventListeners must call switchSettingsTab" + ) + + +# ── module.exports for new settings functions ──────────────────────────────── + +def test_exports_load_display_settings() -> None: + """module.exports must export loadDisplaySettings.""" + match = re.search( + r"module\.exports\s*=\s*\{(.*?)\};", + _JS, + re.DOTALL, + ) + assert match, "module.exports block not found" + exports = match.group(1) + assert "loadDisplaySettings" in exports, ( + "module.exports must export loadDisplaySettings" + ) + + +def test_exports_save_display_settings() -> None: + """module.exports must export saveDisplaySettings.""" + match = re.search( + r"module\.exports\s*=\s*\{(.*?)\};", + _JS, + re.DOTALL, + ) + assert match, "module.exports block not found" + exports = match.group(1) + assert "saveDisplaySettings" in exports, ( + "module.exports must export saveDisplaySettings" + ) + + +def test_exports_open_settings() -> None: + """module.exports must export openSettings.""" + match = re.search( + r"module\.exports\s*=\s*\{(.*?)\};", + _JS, + re.DOTALL, + ) + assert match, "module.exports block not found" + exports = match.group(1) + assert "openSettings" in exports, ( + "module.exports must export openSettings" + ) + + +def test_exports_close_settings() -> None: + """module.exports must export closeSettings.""" + match = re.search( + r"module\.exports\s*=\s*\{(.*?)\};", + _JS, + re.DOTALL, + ) + assert match, "module.exports block not found" + exports = match.group(1) + assert "closeSettings" in exports, ( + "module.exports must export closeSettings" + ) + + +def test_exports_switch_settings_tab() -> None: + """module.exports must export switchSettingsTab.""" + match = re.search( + r"module\.exports\s*=\s*\{(.*?)\};", + _JS, + re.DOTALL, + ) + assert match, "module.exports block not found" + exports = match.group(1) + assert "switchSettingsTab" in exports, ( + "module.exports must export switchSettingsTab" + )