Skip to content

Commit 45e101d

Browse files
committed
test(tradingview): certify canvas and popover rendering
1 parent b325f59 commit 45e101d

3 files changed

Lines changed: 271 additions & 9 deletions

File tree

‎samples/NativeTradingViewTerminal/HeadlessProof.cs‎

Lines changed: 219 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)