11using OpenClawTray . Helpers ;
22using OpenClawTray . Services ;
3+ using System ;
34using System . Drawing ;
45using System . Drawing . Imaging ;
56using System . IO ;
910namespace OpenClaw . Tray . Tests ;
1011
1112/// <summary>
12- /// Verifies the lobster tray/desktop icon is composed with a status dot in the
13- /// bottom-right corner, mirroring the companion-app connection status.
13+ /// Verifies the lobster tray/desktop icon badge policy: only attention states are
14+ /// badged (grey dot for disconnected, red dot with a "-" for error); healthy
15+ /// states (connected/connecting) render the plain lobster with no badge.
1416/// </summary>
1517[ SupportedOSPlatform ( "windows" ) ]
1618public sealed class StatusBadgeIconFactoryTests
@@ -26,6 +28,26 @@ public void DotColor_MapsAccentToStatusColor(int accent, int r, int g, int b)
2628 Assert . Equal ( Color . FromArgb ( r , g , b ) , color ) ;
2729 }
2830
31+ [ Theory ]
32+ [ InlineData ( ( int ) ConnectionStatusAccent . Neutral , true ) ]
33+ [ InlineData ( ( int ) ConnectionStatusAccent . Critical , true ) ]
34+ [ InlineData ( ( int ) ConnectionStatusAccent . Success , false ) ]
35+ [ InlineData ( ( int ) ConnectionStatusAccent . Caution , false ) ]
36+ public void ShouldBadge_OnlyNeutralAndCritical ( int accent , bool expected )
37+ {
38+ Assert . Equal ( expected , StatusBadgeIconFactory . ShouldBadge ( ( ConnectionStatusAccent ) accent ) ) ;
39+ }
40+
41+ [ Theory ]
42+ [ InlineData ( ( int ) ConnectionStatusAccent . Critical , true ) ]
43+ [ InlineData ( ( int ) ConnectionStatusAccent . Neutral , false ) ]
44+ [ InlineData ( ( int ) ConnectionStatusAccent . Success , false ) ]
45+ [ InlineData ( ( int ) ConnectionStatusAccent . Caution , false ) ]
46+ public void HasDash_OnlyCritical ( int accent , bool expected )
47+ {
48+ Assert . Equal ( expected , StatusBadgeIconFactory . HasDash ( ( ConnectionStatusAccent ) accent ) ) ;
49+ }
50+
2951 [ Fact ]
3052 public void Compose_DrawsDotInBottomRightCorner ( )
3153 {
@@ -34,33 +56,49 @@ public void Compose_DrawsDotInBottomRightCorner()
3456 // Fully transparent base so the only opaque pixels come from the dot.
3557
3658 using var composed = StatusBadgeIconFactory . Compose (
37- baseImage , size , StatusBadgeIconFactory . DotColor ( ConnectionStatusAccent . Success ) ) ;
59+ baseImage , size , StatusBadgeIconFactory . DotColor ( ConnectionStatusAccent . Neutral ) ) ;
3860
39- // Bottom-right region carries the coloured dot.
61+ // Bottom-right region carries the dot.
4062 var dotPixel = composed . GetPixel ( ( int ) ( size * 0.80 ) , ( int ) ( size * 0.80 ) ) ;
4163 Assert . True ( dotPixel . A > 200 , "Dot should be opaque in the bottom-right corner" ) ;
42- Assert . True ( dotPixel . G > dotPixel . R && dotPixel . G > dotPixel . B , "Success dot should read green" ) ;
4364
4465 // Top-left stays transparent (no badge, base was empty).
4566 var cornerPixel = composed . GetPixel ( 2 , 2 ) ;
4667 Assert . True ( cornerPixel . A < 40 , "Top-left corner should remain transparent" ) ;
4768 }
4869
4970 [ Fact ]
50- public void DotFraction_ScalesLargerOnTinyIconsAndSubtlerOnLargeIcons ( )
71+ public void Compose_NullColor_RendersPlainLobsterWithNoBadge ( )
5172 {
52- // Tiny tray icons get the largest dot for legibility.
53- Assert . Equal ( 0.44 , StatusBadgeIconFactory . DotFraction ( 16 ) , 3 ) ;
54- Assert . Equal ( 0.44 , StatusBadgeIconFactory . DotFraction ( 32 ) , 3 ) ;
73+ const int size = 64 ;
74+ using var baseImage = new Bitmap ( size , size , PixelFormat . Format32bppArgb ) ;
75+ // Transparent base + null dot colour => the whole icon stays transparent.
5576
56- // Large taskbar / alt-tab icons get the subtlest dot.
57- Assert . Equal ( 0.26 , StatusBadgeIconFactory . DotFraction ( 256 ) , 3 ) ;
77+ using var composed = StatusBadgeIconFactory . Compose ( baseImage , size , dotColor : null ) ;
5878
59- // Monotonically shrinks as the icon grows between the two extremes.
60- var f48 = StatusBadgeIconFactory . DotFraction ( 48 ) ;
61- var f128 = StatusBadgeIconFactory . DotFraction ( 128 ) ;
62- Assert . True ( f48 < 0.44 && f48 > f128 , "48px dot fraction sits between the extremes" ) ;
63- Assert . True ( f128 > 0.26 && f128 < f48 , "128px dot fraction is subtler than 48px but above the floor" ) ;
79+ var dotPixel = composed . GetPixel ( ( int ) ( size * 0.80 ) , ( int ) ( size * 0.80 ) ) ;
80+ Assert . True ( dotPixel . A < 40 , "Healthy state should render no dot in the bottom-right corner" ) ;
81+ }
82+
83+ [ Fact ]
84+ public void Compose_ErrorState_DrawsRedDotWithWhiteDash ( )
85+ {
86+ const int size = 64 ;
87+ using var baseImage = new Bitmap ( size , size , PixelFormat . Format32bppArgb ) ;
88+
89+ using var composed = StatusBadgeIconFactory . Compose (
90+ baseImage , size , StatusBadgeIconFactory . DotColor ( ConnectionStatusAccent . Critical ) , withDash : true ) ;
91+
92+ var ( cx , cy , dot ) = DotCenter ( size ) ;
93+
94+ // Centre of the dot is covered by the white minus glyph.
95+ var centre = composed . GetPixel ( ( int ) cx , ( int ) cy ) ;
96+ Assert . True ( centre . R > 230 && centre . G > 230 && centre . B > 230 , "Dash centre should be white" ) ;
97+
98+ // Below the dash (still inside the dot) reads red.
99+ var belowDash = composed . GetPixel ( ( int ) cx , ( int ) ( cy + dot * 0.30f ) ) ;
100+ Assert . True ( belowDash . A > 200 && belowDash . R > belowDash . G && belowDash . R > belowDash . B ,
101+ "Dot around the dash should read red" ) ;
64102 }
65103
66104 [ Fact ]
@@ -70,13 +108,30 @@ public void Compose_UsesDistinctColorPerAccent()
70108 using var baseImage = new Bitmap ( size , size , PixelFormat . Format32bppArgb ) ;
71109 var px = ( int ) ( size * 0.80 ) ;
72110
73- using var success = StatusBadgeIconFactory . Compose ( baseImage , size , StatusBadgeIconFactory . DotColor ( ConnectionStatusAccent . Success ) ) ;
111+ using var neutral = StatusBadgeIconFactory . Compose ( baseImage , size , StatusBadgeIconFactory . DotColor ( ConnectionStatusAccent . Neutral ) ) ;
74112 using var critical = StatusBadgeIconFactory . Compose ( baseImage , size , StatusBadgeIconFactory . DotColor ( ConnectionStatusAccent . Critical ) ) ;
75113
76- var green = success . GetPixel ( px , px ) ;
114+ var gray = neutral . GetPixel ( px , px ) ;
77115 var red = critical . GetPixel ( px , px ) ;
78- Assert . True ( green . G > green . R , "Success dot is green-dominant" ) ;
79- Assert . True ( red . R > red . G , "Critical dot is red-dominant" ) ;
116+ Assert . True ( Math . Abs ( gray . R - gray . G ) < 20 && Math . Abs ( gray . G - gray . B ) < 20 , "Neutral dot is grey" ) ;
117+ Assert . True ( red . R > red . G && red . R > red . B , "Critical dot is red-dominant" ) ;
118+ }
119+
120+ [ Fact ]
121+ public void DotFraction_ScalesLargerOnTinyIconsAndSubtlerOnLargeIcons ( )
122+ {
123+ // Tiny tray icons get the largest dot for legibility.
124+ Assert . Equal ( 0.44 , StatusBadgeIconFactory . DotFraction ( 16 ) , 3 ) ;
125+ Assert . Equal ( 0.44 , StatusBadgeIconFactory . DotFraction ( 32 ) , 3 ) ;
126+
127+ // Large taskbar / alt-tab icons get the subtlest dot.
128+ Assert . Equal ( 0.26 , StatusBadgeIconFactory . DotFraction ( 256 ) , 3 ) ;
129+
130+ // Monotonically shrinks as the icon grows between the two extremes.
131+ var f48 = StatusBadgeIconFactory . DotFraction ( 48 ) ;
132+ var f128 = StatusBadgeIconFactory . DotFraction ( 128 ) ;
133+ Assert . True ( f48 < 0.44 && f48 > f128 , "48px dot fraction sits between the extremes" ) ;
134+ Assert . True ( f128 > 0.26 && f128 < f48 , "128px dot fraction is subtler than 48px but above the floor" ) ;
80135 }
81136
82137 [ Fact ]
@@ -86,7 +141,7 @@ public void CreateIcoBytes_ProducesValidMultiSizeIcon()
86141 using var baseImage = new Bitmap ( 256 , 256 , PixelFormat . Format32bppArgb ) ;
87142
88143 var bytes = StatusBadgeIconFactory . CreateIcoBytes (
89- baseImage , StatusBadgeIconFactory . DotColor ( ConnectionStatusAccent . Caution ) , sizes ) ;
144+ baseImage , StatusBadgeIconFactory . DotColor ( ConnectionStatusAccent . Critical ) , withDash : true , sizes ) ;
90145
91146 // ICONDIR header: reserved=0, type=1 (icon), count=frames.
92147 Assert . Equal ( 0 , bytes [ 0 ] | bytes [ 1 ] ) ;
@@ -100,11 +155,37 @@ public void CreateIcoBytes_ProducesValidMultiSizeIcon()
100155 Assert . NotNull ( icon ) ;
101156 }
102157
158+ [ Fact ]
159+ public void CreateIcoBytes_NullColor_ProducesValidPlainIcon ( )
160+ {
161+ var sizes = new [ ] { 16 , 32 } ;
162+ using var baseImage = new Bitmap ( 256 , 256 , PixelFormat . Format32bppArgb ) ;
163+
164+ var bytes = StatusBadgeIconFactory . CreateIcoBytes ( baseImage , dotColor : null , withDash : false , sizes ) ;
165+
166+ using var stream = new MemoryStream ( bytes ) ;
167+ using var icon = new Icon ( stream ) ;
168+ Assert . NotNull ( icon ) ;
169+ }
170+
103171 [ Fact ]
104172 public void IconSizes_CoverTrayAndTaskbarResolutions ( )
105173 {
106174 Assert . Contains ( 16 , StatusBadgeIconFactory . IconSizes ) ; // tray at 100% DPI
107175 Assert . Contains ( 32 , StatusBadgeIconFactory . IconSizes ) ; // tray at 200% DPI / taskbar
108176 Assert . Contains ( 256 , StatusBadgeIconFactory . IconSizes ) ; // high-DPI taskbar
109177 }
178+
179+ // Mirrors the dot geometry in StatusBadgeIconFactory.Compose so tests can sample
180+ // the dot centre precisely.
181+ private static ( float Cx , float Cy , float Dot ) DotCenter ( int size )
182+ {
183+ float dot = size * ( float ) StatusBadgeIconFactory . DotFraction ( size ) ;
184+ float ring = Math . Max ( 1f , dot * 0.14f ) ;
185+ float pad = size * 0.02f ;
186+ float outer = dot + ( ring * 2f ) ;
187+ float outerX = size - outer - pad ;
188+ float outerY = size - outer - pad ;
189+ return ( outerX + ring + ( dot / 2f ) , outerY + ring + ( dot / 2f ) , dot ) ;
190+ }
110191}
0 commit comments