@@ -19,6 +19,12 @@ internal static int Run(string[] arguments)
1919 var width = ReadDimension ( arguments , "--width" , 1440 ) ;
2020 var height = ReadDimension ( arguments , "--height" , 900 ) ;
2121 var overlay = ReadArgument ( arguments , "--open-overlay" ) ;
22+ var drawTrendline = arguments . Contains (
23+ "--draw-trendline" ,
24+ StringComparer . Ordinal ) ;
25+ var certifyPopovers = arguments . Contains (
26+ "--popover-proof" ,
27+ StringComparer . Ordinal ) ;
2228 Directory . CreateDirectory ( output ) ;
2329 var view = new NativeWebSceneView ( useCompositionVisual : false ) ;
2430 var window = new Window
@@ -69,7 +75,24 @@ internal static int Run(string[] arguments)
6975 button : 0 ,
7076 pressed : false ) ;
7177 PumpFrames ( view , window , TimeSpan . FromMilliseconds ( 650 ) ) ;
78+ if ( certifyPopovers )
79+ {
80+ surface . SubmitWheel ( 700 , 350 , - 100 ) ;
81+ PumpFrames ( view , window , TimeSpan . FromMilliseconds ( 500 ) ) ;
82+ }
7283 var evidence = WaitForWebSocketEvidence ( view , window ) ;
84+ TrendlineGeometry ? trendlineGeometry = null ;
85+ if ( drawTrendline )
86+ {
87+ trendlineGeometry = DrawTrendline ( view , window , surface ) ;
88+ evidence = WaitForWebSocketEvidence ( view , window ) ;
89+ File . WriteAllText (
90+ Path . Combine ( output , "trendline-scene-diagnostics.json" ) ,
91+ view . SceneDiagnostics ) ;
92+ File . WriteAllText (
93+ Path . Combine ( output , "trendline-feature-use.json" ) ,
94+ view . FeatureUseReport ) ;
95+ }
7396 PumpFrames ( view , window , TimeSpan . FromSeconds ( 3 ) ) ;
7497 var rendererMetrics = surface . GetRendererMemoryMetrics ( ) ;
7598 if ( rendererMetrics . RetainedCommandCount < 100
@@ -88,6 +111,10 @@ internal static int Run(string[] arguments)
88111 output ,
89112 "native-tradingview-terminal-evidence.json" ) ;
90113 File . WriteAllText ( evidencePath , evidence ) ;
114+ if ( trendlineGeometry is not null )
115+ {
116+ ValidateTrendlineHandles ( screenshotPath , trendlineGeometry . Value ) ;
117+ }
91118 if ( overlay is not null )
92119 {
93120 CaptureOverlay (
@@ -146,6 +173,7 @@ internal static int Run(string[] arguments)
146173 activeRail . GetProperty ( "className" ) . GetString ( ) ?? "" ;
147174 var chartValuesCoach = visual . GetProperty ( "chartValuesCoach" ) ;
148175 var chartValuesCoachRect = chartValuesCoach . GetProperty ( "rect" ) ;
176+ var zoomCoach = visual . GetProperty ( "zoomCoach" ) ;
149177 var chartCanvasRect = pointerTarget . GetProperty ( "rect" ) ;
150178 var rightEdge = rightRect . GetProperty ( "x" ) . GetDouble ( )
151179 + rightRect . GetProperty ( "width" ) . GetDouble ( ) ;
@@ -157,6 +185,25 @@ static double Bottom(JsonElement rect) =>
157185 static double Right ( JsonElement rect ) =>
158186 rect . GetProperty ( "x" ) . GetDouble ( )
159187 + rect . GetProperty ( "width" ) . GetDouble ( ) ;
188+ var popoversMatch = ! certifyPopovers ;
189+ if ( certifyPopovers && zoomCoach . ValueKind == JsonValueKind . Object )
190+ {
191+ var zoomChain = zoomCoach . GetProperty ( "chain" ) ;
192+ if ( zoomChain . GetArrayLength ( ) >= 3 )
193+ {
194+ var textRect = zoomChain [ 0 ] . GetProperty ( "rect" ) ;
195+ var toastRect = zoomChain [ 2 ] . GetProperty ( "rect" ) ;
196+ popoversMatch =
197+ zoomCoach . GetProperty ( "message" ) . GetString ( ) ? . Contains (
198+ "while zooming to maintain the chart position" ,
199+ StringComparison . Ordinal ) == true
200+ && textRect . GetProperty ( "height" ) . GetDouble ( ) <= 21.5
201+ && textRect . GetProperty ( "width" ) . GetDouble ( ) > 380
202+ && toastRect . GetProperty ( "height" ) . GetDouble ( ) <= 45.5
203+ && toastRect . GetProperty ( "width" ) . GetDouble ( )
204+ >= textRect . GetProperty ( "width" ) . GetDouble ( ) + 49 ;
205+ }
206+ }
160207 if ( ! layout . GetProperty ( "ready" ) . GetBoolean ( )
161208 || loadingIndicator . GetProperty ( "count" ) . GetInt32 ( ) != 1
162209 || loadingIndicator . GetProperty ( "display" ) . GetString ( ) != "none"
@@ -209,8 +256,10 @@ static double Right(JsonElement rect) =>
209256 < chartCanvasRect . GetProperty ( "y" ) . GetDouble ( )
210257 || Right ( chartValuesCoachRect ) > Right ( chartCanvasRect )
211258 || Bottom ( chartValuesCoachRect ) > Bottom ( chartCanvasRect )
259+ || ! popoversMatch
212260 || pointerTarget . GetProperty ( "tag" ) . GetString ( ) != "CANVAS"
213- || pointerTarget . GetProperty ( "cursor" ) . GetString ( ) != "crosshair"
261+ || ( ! drawTrendline
262+ && pointerTarget . GetProperty ( "cursor" ) . GetString ( ) != "crosshair" )
214263 || pointerInput . GetProperty ( "pointermove" ) . GetInt32 ( ) < 1
215264 || pointerInput . GetProperty ( "mousemove" ) . GetInt32 ( ) < 1
216265 || pointerInput . GetProperty ( "pointerdown" ) . GetInt32 ( ) < 1
@@ -479,6 +528,57 @@ private static string WaitForWebSocketEvidence(
479528 }
480529 return null;
481530 })();
531+ const zoomMessage = Array.from(
532+ chartDocument.querySelectorAll('*'))
533+ .filter(node => {
534+ const rect = node.getBoundingClientRect();
535+ return rect.width > 0 && rect.height > 0
536+ && node.children.length === 0
537+ && node.textContent?.includes(
538+ 'while zooming to maintain the chart position');
539+ })
540+ .sort((left, right) =>
541+ left.getBoundingClientRect().width
542+ - right.getBoundingClientRect().width)[0];
543+ const describePopoverChain = node => {
544+ const result = [];
545+ for (let current = node;
546+ current && result.length < 8;
547+ current = current.parentElement) {
548+ const rect = current.getBoundingClientRect();
549+ const style = getComputedStyle(current);
550+ result.push({
551+ tag: current.tagName,
552+ className: current.className,
553+ styleAttribute: current.getAttribute('style'),
554+ text: current.children.length === 0
555+ ? current.textContent?.trim() : null,
556+ rect: {
557+ x: rect.x, y: rect.y,
558+ width: rect.width, height: rect.height
559+ },
560+ position: style.position,
561+ display: style.display,
562+ transform: style.transform,
563+ width: style.width,
564+ minWidth: style.minWidth,
565+ maxWidth: style.maxWidth,
566+ whiteSpace: style.whiteSpace,
567+ overflowWrap: style.overflowWrap,
568+ wordBreak: style.wordBreak,
569+ flex: style.flex,
570+ flexGrow: style.flexGrow,
571+ flexShrink: style.flexShrink,
572+ flexBasis: style.flexBasis,
573+ padding: style.padding,
574+ margin: style.margin,
575+ lineHeight: style.lineHeight,
576+ fontSize: style.fontSize,
577+ backgroundColor: style.backgroundColor
578+ });
579+ }
580+ return result;
581+ };
482582 return {
483583 ready: Boolean(
484584 right && widgetbarWrap && widgetbarTabs
@@ -559,7 +659,12 @@ private static string WaitForWebSocketEvidence(
559659 position:
560660 getComputedStyle(chartValuesCoach).position,
561661 transform:
562- getComputedStyle(chartValuesCoach).transform
662+ getComputedStyle(chartValuesCoach).transform,
663+ chain: describePopoverChain(chartValuesMessage)
664+ } : null,
665+ zoomCoach: zoomMessage ? {
666+ message: zoomMessage.textContent?.trim(),
667+ chain: describePopoverChain(zoomMessage)
563668 } : null
564669 },
565670 loadingIndicator: loadingIndicator ? {
@@ -807,6 +912,114 @@ private static void CaptureOverlay(
807912 height ) ;
808913 }
809914
915+ private static TrendlineGeometry DrawTrendline (
916+ NativeWebSceneView view ,
917+ Window window ,
918+ NativeSceneSurface surface )
919+ {
920+ var evaluation = view . EvaluateTextAsync ( """
921+ (() => {
922+ const chartDocument = Array.from(
923+ document.querySelectorAll('iframe'))
924+ .map(frame => frame.contentDocument)
925+ .find(candidate =>
926+ candidate?.querySelectorAll('canvas').length >= 8);
927+ const tool = Array.from(
928+ chartDocument?.querySelectorAll('button') ?? [])
929+ .find(node => node.getAttribute('aria-label') === 'Trendline');
930+ const canvas = chartDocument?.elementFromPoint(700, 350);
931+ if (!tool || canvas?.tagName !== 'CANVAS') return null;
932+ const toolRect = tool.getBoundingClientRect();
933+ const canvasRect = canvas.getBoundingClientRect();
934+ return {
935+ tool: {
936+ x: toolRect.x + toolRect.width / 2,
937+ y: toolRect.y + toolRect.height / 2
938+ },
939+ start: {
940+ x: canvasRect.x + canvasRect.width * 0.25,
941+ y: canvasRect.y + canvasRect.height * 0.72
942+ },
943+ end: {
944+ x: canvasRect.x + canvasRect.width * 0.68,
945+ y: canvasRect.y + canvasRect.height * 0.28
946+ }
947+ };
948+ })()
949+ """ ) ;
950+ PumpUntil ( evaluation , TimeSpan . FromSeconds ( 10 ) ) ;
951+ using var geometry = JsonDocument . Parse ( evaluation . Result ) ;
952+ if ( geometry . RootElement . ValueKind != JsonValueKind . Object )
953+ {
954+ throw new InvalidOperationException (
955+ "TradingView trendline tool or chart canvas was unavailable." ) ;
956+ }
957+
958+ static ( double X , double Y ) Point ( JsonElement root , string name )
959+ {
960+ var point = root . GetProperty ( name ) ;
961+ return (
962+ point . GetProperty ( "x" ) . GetDouble ( ) ,
963+ point . GetProperty ( "y" ) . GetDouble ( ) ) ;
964+ }
965+
966+ void Click ( ( double X , double Y ) point )
967+ {
968+ surface . SubmitAvaloniaPointerMove ( point . X , point . Y ) ;
969+ PumpFrames ( view , window , TimeSpan . FromMilliseconds ( 100 ) ) ;
970+ surface . SubmitPointerButton ( 2 , point . X , point . Y , 0 , pressed : true ) ;
971+ PumpFrames ( view , window , TimeSpan . FromMilliseconds ( 50 ) ) ;
972+ surface . SubmitPointerButton ( 3 , point . X , point . Y , 0 , pressed : false ) ;
973+ PumpFrames ( view , window , TimeSpan . FromMilliseconds ( 250 ) ) ;
974+ }
975+
976+ Click ( Point ( geometry . RootElement , "tool" ) ) ;
977+ Click ( Point ( geometry . RootElement , "start" ) ) ;
978+ Click ( Point ( geometry . RootElement , "end" ) ) ;
979+ PumpFrames ( view , window , TimeSpan . FromSeconds ( 1 ) ) ;
980+ return new TrendlineGeometry (
981+ Point ( geometry . RootElement , "start" ) ,
982+ Point ( geometry . RootElement , "end" ) ) ;
983+ }
984+
985+ private static void ValidateTrendlineHandles (
986+ string screenshotPath ,
987+ TrendlineGeometry geometry )
988+ {
989+ using var bitmap = SKBitmap . Decode ( File . ReadAllBytes ( screenshotPath ) )
990+ ?? throw new InvalidOperationException (
991+ "TradingView trendline capture could not be decoded." ) ;
992+ static int HandleColorDistance ( SKColor color ) =>
993+ Math . Abs ( color . Red - 0x1e )
994+ + Math . Abs ( color . Green - 0x53 )
995+ + Math . Abs ( color . Blue - 0xe5 ) ;
996+ int CountHandlePixels ( ( double X , double Y ) point )
997+ {
998+ var centerX = ( int ) Math . Round ( point . X ) ;
999+ var centerY = ( int ) Math . Round ( point . Y ) ;
1000+ var count = 0 ;
1001+ for ( var y = centerY - 8 ; y <= centerY + 8 ; ++ y )
1002+ {
1003+ for ( var x = centerX - 8 ; x <= centerX + 8 ; ++ x )
1004+ {
1005+ var radiusSquared = ( x - point . X ) * ( x - point . X )
1006+ + ( y - point . Y ) * ( y - point . Y ) ;
1007+ if ( radiusSquared is < 9 or > 64 ) continue ;
1008+ if ( HandleColorDistance ( bitmap . GetPixel ( x , y ) ) <= 30 ) ++ count ;
1009+ }
1010+ }
1011+ return count ;
1012+ }
1013+ var startPixels = CountHandlePixels ( geometry . Start ) ;
1014+ var endPixels = CountHandlePixels ( geometry . End ) ;
1015+ if ( startPixels < 12 || endPixels < 12 )
1016+ {
1017+ throw new InvalidOperationException (
1018+ "TradingView selected trendline endpoint handles were not painted "
1019+ + $ "(start={ startPixels } , end={ endPixels } ).") ;
1020+ }
1021+ }
1022+
8101023 private static void ValidateSelectedToolbarBackground (
8111024 string screenshotPath ,
8121025 JsonElement activeRect ,
@@ -872,4 +1085,8 @@ private static int ReadDimension(
8721085 }
8731086 return null ;
8741087 }
1088+
1089+ private readonly record struct TrendlineGeometry (
1090+ ( double X , double Y ) Start ,
1091+ ( double X , double Y ) End ) ;
8751092}
0 commit comments