diff --git a/.github/workflows/mono-desktop.yml b/.github/workflows/mono-desktop.yml index 4e92b47b..0e5215ae 100644 --- a/.github/workflows/mono-desktop.yml +++ b/.github/workflows/mono-desktop.yml @@ -23,7 +23,7 @@ jobs: - name: Install the actual Mono and X11 runtime run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends mono-devel xvfb xauth libgdiplus libgsl-dev fonts-dejavu-core + sudo apt-get install -y --no-install-recommends mono-devel xvfb xauth libxtst6 libgdiplus libgsl-dev fonts-dejavu-core - name: Build the opt-in Framework graph and run desktop acceptance run: ./build.sh --target=Mono-Validate - name: Retain runtime evidence even on failure diff --git a/Computator.NET.Charting/ComplexCharting/ComplexChart.cs b/Computator.NET.Charting/ComplexCharting/ComplexChart.cs index 534e4a07..88c42463 100644 --- a/Computator.NET.Charting/ComplexCharting/ComplexChart.cs +++ b/Computator.NET.Charting/ComplexCharting/ComplexChart.cs @@ -36,6 +36,15 @@ private void OnPropertyChanged(string propertyName) // private Task RedrawTask; private ComplexPoint[,] _pointsValues; private bool _redrawing = false; +#if __MonoCS__ + // The real Mono Application.Run pipeline may never dispatch the awaited + // WinForms synchronization-context continuation. Poll completion from + // the UI message loop instead; never access WinForms from the worker. + private System.Windows.Forms.Timer _monoRenderTimer; + private Task _monoRenderTask; + private long _monoRenderTaskVersion; +#endif + public string LastRenderError { get; private set; } private long _redrawVersion; private Function function; private Bitmap image = new Bitmap(Screen.PrimaryScreen.Bounds.Width, Screen.PrimaryScreen.Bounds.Height); @@ -290,6 +299,22 @@ protected override void Dispose(bool disposing) { if (disposing) { +#if __MonoCS__ + // A task may finish after the control has closed; release its + // independent sample bitmap without posting into a dead form. + _monoRenderTimer?.Stop(); + _monoRenderTimer?.Dispose(); + _monoRenderTimer = null; + var pending = _monoRenderTask; + _monoRenderTask = null; + if (pending != null) + pending.ContinueWith(t => + { + if (t.Status == TaskStatus.RanToCompletion) + t.Result.Bitmap.Dispose(); + }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); +#endif ++_redrawVersion; function = null; _pointsValues = null; @@ -352,6 +377,7 @@ private void _MouseClick(object s, MouseEventArgs e) public void AddFunction(Function Fz) { + LastRenderError = null; function = Fz; Title = Fz.Name; Redraw(); @@ -451,6 +477,7 @@ private Bitmap ComposeImage(Image source, int width, int height) public void ClearAll() { + LastRenderError = null; ++_redrawVersion; function = null; _pointsValues = null; @@ -475,6 +502,98 @@ public void ShowPlotDialog() plotForm.Show(); } +#if __MonoCS__ + public void Redraw() + { + if (IsDisposed || Disposing) + return; + ++_redrawVersion; + if (function == null || DrawWidth <= 0 || DrawHeight <= 0 || _redrawing) + return; + _redrawing = true; + Cursor = Cursors.WaitCursor; + if (_monoRenderTimer == null) + { + _monoRenderTimer = new System.Windows.Forms.Timer { Interval = 35 }; + _monoRenderTimer.Tick += MonoRenderTimerTick; + } + StartMonoRenderTask(); + } + + private void StartMonoRenderTask() + { + _monoRenderTaskVersion = _redrawVersion; + var width = DrawWidth; + var height = DrawHeight; + // Use the default scheduler and a dedicated worker on Mono. This + // avoids starvation by UI-associated custom schedulers and never + // executes the expensive sampling on the WinForms thread. + _monoRenderTask = Task.Factory.StartNew( + () => CalculateValuesAndColors(width, height), CancellationToken.None, + TaskCreationOptions.LongRunning, TaskScheduler.Default); + _monoRenderTimer.Start(); + } + + private void MonoRenderTimerTick(object sender, EventArgs e) + { + var task = _monoRenderTask; + if (task == null || !task.IsCompleted) + return; + _monoRenderTask = null; + var version = _monoRenderTaskVersion; + try + { + if (task.Status == TaskStatus.RanToCompletion) + { + var samples = task.Result; + if (!IsDisposed && !Disposing && function != null && + version == _redrawVersion) + { + var oldImage = image; + image = samples.Bitmap; + _pointsValues = samples.PointsValues; + oldImage?.Dispose(); + Invalidate(); + } + else + { + samples.Bitmap.Dispose(); + } + } + else if (task.IsFaulted && !IsDisposed && !Disposing && + function != null && version == _redrawVersion) + { + var error = task.Exception.GetBaseException(); + Logger.Error(error, "Complex chart asynchronous redraw failed."); + LastRenderError = error.Message; + Invalidate(); + } + } + catch (Exception error) + { + // Keep the actual production message loop alive on Mono. + Logger.Error(error, "Complex chart result publication failed."); + LastRenderError = error.Message; + if (!IsDisposed && !Disposing) + Invalidate(); + } + + if (!IsDisposed && !Disposing && function != null && + DrawWidth > 0 && DrawHeight > 0 && version != _redrawVersion) + { + // Coalesce edits, Clear, mode changes and resizing while a + // previous evaluation is still running. Never publish stale + // samples or stale errors from superseded work. + StartMonoRenderTask(); + return; + } + + _monoRenderTimer.Stop(); + _redrawing = false; + if (!IsDisposed && !Disposing) + Cursor = Cursors.Default; + } +#else public async void Redraw() { if (IsDisposed || Disposing) @@ -498,10 +617,10 @@ public async void Redraw() } catch (Exception) when (function == null || IsDisposed || version != _redrawVersion) { - // A discarded evaluation must not show an error after a new - // plot, clear or close. Current evaluation errors still propagate. + // Superseded evaluations should never report an error. continue; } + if (function == null || IsDisposed || version != _redrawVersion) { samples.Bitmap.Dispose(); @@ -523,9 +642,20 @@ public async void Redraw() } } +#endif + protected override void OnPaint(PaintEventArgs pe) { PaintOn(pe.Graphics, ClientRectangle, image); + if (!string.IsNullOrWhiteSpace(LastRenderError)) + { + var bounds = Rectangle.Inflate(ClientRectangle, -12, -12); + if (bounds.Width > 0 && bounds.Height > 0) + TextRenderer.DrawText(pe.Graphics, "Complex chart rendering failed: " + LastRenderError, + Font, new Rectangle(bounds.Left, bounds.Top, bounds.Width, Math.Min(90, bounds.Height)), + Color.DarkRed, BackColor, + TextFormatFlags.WordBreak | TextFormatFlags.Left | TextFormatFlags.Top); + } } diff --git a/Computator.NET.Desktop/Views/ChartingView.cs b/Computator.NET.Desktop/Views/ChartingView.cs index 814b3a52..174ebfdd 100644 --- a/Computator.NET.Desktop/Views/ChartingView.cs +++ b/Computator.NET.Desktop/Views/ChartingView.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Windows.Forms; using Computator.NET.Charting.Chart3D.UI; using Computator.NET.Charting.ComplexCharting; @@ -6,11 +7,17 @@ using Computator.NET.Core.Abstract.Views; using Computator.NET.DataTypes; using Computator.NET.DataTypes.Charts; +#if __MonoCS__ +using Computator.NET.DataTypes.Events; +#endif namespace Computator.NET.Desktop.Views { public partial class ChartingView : UserControl, IChartingView { +#if __MonoCS__ + private readonly Action _showMonoChart; +#endif public ChartingView(ChartAreaValuesView chartAreaValuesView, Chart2D chart2D, ComplexChart complexChart, Chart3DControl chart3D) : this() { chartAreaValuesView.Dock = DockStyle.Right; @@ -40,6 +47,26 @@ public ChartingView(ChartAreaValuesView chartAreaValuesView, Chart2D chart2D, Co #endif (Control) chartAreaValuesView, }); +#if __MonoCS__ + // All three chart controls are docked Fill in the same panel. + // Hiding a sibling does not reliably lower its native X11 window + // on every Mono/WinForms backend. Select the active chart's actual + // z-order as well as its Visible flag (set by the presenter). + _showMonoChart = mode => + { + IChart active; + if (!Charts.TryGetValue(mode.CalculationsMode, out active)) + return; + var control = active as Control; + if (control == null || control.IsDisposed) + return; + control.BringToFront(); + control.Invalidate(); + }; + EventAggregator.Instance.Subscribe(_showMonoChart); + Disposed += (sender, args) => + EventAggregator.Instance.Unsubscribe(_showMonoChart); +#endif } private ChartingView() diff --git a/Computator.NET.slnx b/Computator.NET.slnx index 77c6a057..23bb9e28 100644 --- a/Computator.NET.slnx +++ b/Computator.NET.slnx @@ -127,6 +127,7 @@ + diff --git a/build/mono/SmokeTests/ComplexChartUiAcceptance.cs b/build/mono/SmokeTests/ComplexChartUiAcceptance.cs new file mode 100644 index 00000000..1cb658fa --- /dev/null +++ b/build/mono/SmokeTests/ComplexChartUiAcceptance.cs @@ -0,0 +1,304 @@ +using System; +using System.Diagnostics; +using System.Drawing; +using System.Drawing.Imaging; +using System.Linq; +using System.Numerics; +using System.Runtime.InteropServices; +using System.Threading; +using System.Windows.Forms; +using Computator.NET.Charting.ComplexCharting; +using Computator.NET.Core.Abstract.Controls; +using Computator.NET.Core.Model; +using Computator.NET.DataTypes; +using Computator.NET.DataTypes.Events; +using Computator.NET.DataTypes.Functions; +using Computator.NET.Desktop.Bootstrapping; +using Computator.NET.Desktop.Views; + +internal static partial class Program +{ + // Run inside the *actual* bootstrapped desktop window, not a detached chart + // created for GetImage(). Capture the X11 screen itself, not DrawToBitmap. + private static void VerifyMonoComplexChartVisible(MainView window, + WinFormsBootstrapper bootstrapper, ISharedViewState state, RecordingErrors errors) + { + state.CurrentView = ViewName.Charting; + EventAggregator.Instance.Publish(new CalculationsModeChangedEvent(CalculationsMode.Complex)); + var view = bootstrapper.Create(); + var chart = (ComplexChart)view.ComplexChart; + chart.Quality = 50; // Reproduce the production default sampling quality. + Application.DoEvents(); + Console.WriteLine("Complex UI initial: " + DescribeComplexLayout(chart)); + Check(chart.Parent.Controls.GetChildIndex(chart) == 0, + "Active complex chart is underneath another chart window in Mono/X11."); + Check(window.Visible && chart.Visible && chart.IsHandleCreated && chart.Width > 200 && chart.Height > 150, + "Complex plot does not have a visible, sized, native X11 control: " + DescribeComplexLayout(chart)); + + using (var blank = ScreenshotComplexControl(chart, "complex-visible-before.png")) + Console.WriteLine("Complex UI before draw colored pixels: " + CountDomainColorPixels(blank)); + + bootstrapper.Create().Text = "z²"; + var draw = Find(window, "addToChartButton") as Button; + Check(draw != null && draw.Visible && draw.Enabled, "Real desktop Draw button is not available."); + var paintCalls = 0; + chart.Paint += (sender, args) => paintCalls++; + for (var attempt = 0; attempt < 5; attempt++) + ClickActualX11Button(draw); // Real pointer/button events, as in production. + Console.WriteLine("X11 Draw clicks delivered (five attempted), initial chart paints: " + paintCalls); + + // The production chart uses async sampling. Pump the real message loop + // until its background calculation and native invalidation have completed. + var timer = Stopwatch.StartNew(); + Bitmap last = null; + int colored = 0; + while (timer.Elapsed < TimeSpan.FromSeconds(25)) + { + Application.DoEvents(); + if (last != null) last.Dispose(); + last = ScreenshotComplexControl(chart, null); + colored = CountDomainColorPixels(last); + if (colored > 200) break; + Thread.Sleep(60); + } + try + { + last.Save(System.IO.Path.Combine(outputDirectory, "complex-visible-after.png"), ImageFormat.Png); + Console.WriteLine("Complex UI after draw: " + DescribeComplexLayout(chart) + + "; colored pixels: " + colored + "; elapsed: " + timer.Elapsed + "; chart paint calls: " + paintCalls); + Check(colored > 200, + "z² was not painted in the actual Linux/X11 window despite clicking Draw; " + + "on-screen domain color pixels: " + colored); + } + finally { if (last != null) last.Dispose(); } + + using (var export = chart.GetImage(320, 220)) + AssertImageHasVariation(export, 40); + + // Switch expression and require the live native screen to change, + // rather than merely inspecting GetImage() again. + using (var zSquared = ScreenshotComplexControl(chart, null)) + { + bootstrapper.Create().Text = "z³"; + ClickActualX11Button(draw); + using (var zCubed = WaitForComplexPixels(chart, "complex-visible-z-cubed.png", + image => DifferentComplexPixels(zSquared, image) > 600, 25)) + Check(CountDomainColorPixels(zCubed) > 200, "Changing expression hid the complex domain colors."); + } + + // Real -> 3D -> Complex must make the selected native child frontmost, + // including the separate Mono 3D implementation introduced by #296. + foreach (var mode in new[] { CalculationsMode.Real, CalculationsMode.Fxy, CalculationsMode.Complex }) + { + EventAggregator.Instance.Publish(new CalculationsModeChangedEvent(mode)); + Application.DoEvents(); + Check(chart.Visible == (mode == CalculationsMode.Complex), + "Complex chart visibility did not follow calculation mode " + mode); + if (mode == CalculationsMode.Complex) + Check(chart.Parent.Controls.GetChildIndex(chart) == 0, + "Switching back to Complex left it behind an X11 sibling."); + } + chart.Refresh(); + Application.DoEvents(); + using (var returning = ScreenshotComplexControl(chart, null)) + Check(CountDomainColorPixels(returning) > 200, "Complex chart disappeared after mode switching."); + + var normalSize = window.ClientSize; + try + { + window.ClientSize = new Size(normalSize.Width - 100, normalSize.Height - 75); + Application.DoEvents(); + Check(chart.Width > 200 && chart.Height > 150 && draw.Visible, + "Resizing clipped the active complex plot or its Draw button."); + using (var resized = WaitForComplexPixels(chart, "complex-visible-resized.png", + image => CountDomainColorPixels(image) > 200, 15)) + AssertImageHasVariation(resized, 50); + } + finally { window.ClientSize = normalSize; Application.DoEvents(); } + + var clear = Find(window, "clearChartButton") as Button; + Check(clear != null && clear.Visible, "Desktop Clear button is not available."); + clear.PerformClick(); + Application.DoEvents(); + using (var cleared = ScreenshotComplexControl(chart, "complex-visible-cleared.png")) + Check(CountDomainColorPixels(cleared) == 0, "Clear left stale colors on the visible chart."); + + // A compilation error must be reported through the normal error handler, + // must not repaint old colors, and must not prevent a later valid Draw. + var errorsBefore = errors.Messages.Count; + bootstrapper.Create().Text = "MissingMonoFunction(z)"; + ClickActualX11Button(draw); + Check(errors.Messages.Count > errorsBefore, "Invalid complex expression was not reported."); + using (var invalid = ScreenshotComplexControl(chart, null)) + Check(CountDomainColorPixels(invalid) == 0, "Invalid Draw repainted stale domain colors."); + errors.Messages.Clear(); + + // Clear while an asynchronous drawing is being scheduled: obsolete + // samples must never appear later, including during rapid user input. + bootstrapper.Create().Text = "z⁴"; + ClickActualX11Button(draw); + clear.PerformClick(); + PumpComplexUiFor(TimeSpan.FromMilliseconds(600)); + using (var stale = ScreenshotComplexControl(chart, null)) + Check(CountDomainColorPixels(stale) == 0, "An obsolete async render repainted after Clear."); + + bootstrapper.Create().Text = "z²"; + ClickActualX11Button(draw); + using (var recovered = WaitForComplexPixels(chart, "complex-visible-recovered.png", + image => CountDomainColorPixels(image) > 200, 25)) + AssertImageHasVariation(recovered, 50); + + // The plotting delegate itself may throw only on the worker task, + // after the Draw handler returned. Confirm the async-void continuation + // cannot terminate the running desktop or permanently poison redraws. + var failingFunction = new Function( + new Func(z => + { + throw new InvalidOperationException("Intentional complex rendering failure"); + }), + FunctionType.Complex) { Name = "Intentional complex rendering failure" }; + chart.AddFunction(failingFunction); + var failureWatch = Stopwatch.StartNew(); + while (failureWatch.Elapsed < TimeSpan.FromSeconds(10) && + string.IsNullOrWhiteSpace(chart.LastRenderError)) + { + Application.DoEvents(); + Thread.Sleep(40); + } + Check(chart.LastRenderError != null && + chart.LastRenderError.Contains("Intentional complex rendering failure"), + "Asynchronous evaluation did not deliver an observable UI error."); + Application.DoEvents(); + using (var failure = ScreenshotComplexControl(chart, "complex-visible-render-failure.png")) + Check(failure.Width > 200 && failure.Height > 150, + "Failed complex render destroyed the native window."); + + chart.ClearAll(); + bootstrapper.Create().Text = "z²"; + ClickActualX11Button(draw); + using (var repaired = WaitForComplexPixels(chart, "complex-visible-after-render-failure.png", + image => CountDomainColorPixels(image) > 200, 25)) + AssertImageHasVariation(repaired, 50); + Check(string.IsNullOrEmpty(chart.LastRenderError), + "A successful redraw did not clear the earlier asynchronous error."); + + // Restore the original application mode for existing desktop workflow checks. + EventAggregator.Instance.Publish(new CalculationsModeChangedEvent(CalculationsMode.Real)); + } + + + private static Bitmap WaitForComplexPixels(Control control, string filename, + Func accepted, int timeoutSeconds) + { + var watch = Stopwatch.StartNew(); + Bitmap latest = null; + while (watch.Elapsed < TimeSpan.FromSeconds(timeoutSeconds)) + { + Application.DoEvents(); + if (latest != null) latest.Dispose(); + latest = ScreenshotComplexControl(control, null); + if (accepted(latest)) break; + Thread.Sleep(75); + } + try + { + latest.Save(System.IO.Path.Combine(outputDirectory, filename), ImageFormat.Png); + Check(accepted(latest), + "Visible X11 complex chart did not reach " + filename + " after " + watch.Elapsed); + var result = latest; + latest = null; + return result; + } + finally { if (latest != null) latest.Dispose(); } + } + + private static int DifferentComplexPixels(Bitmap first, Bitmap second) + { + Check(first.Size == second.Size, "Expression switch unexpectedly resized the chart."); + var changed = 0; + for (var x = 10; x < first.Width - 10; x += 5) + for (var y = 10; y < first.Height - 10; y += 5) + if (first.GetPixel(x, y).ToArgb() != second.GetPixel(x, y).ToArgb()) + changed++; + return changed; + } + + private static void PumpComplexUiFor(TimeSpan duration) + { + var watch = Stopwatch.StartNew(); + while (watch.Elapsed < duration) + { + Application.DoEvents(); + Thread.Sleep(35); + } + } + + private static string DescribeComplexLayout(ComplexChart chart) + { + if (chart.Parent == null) return "unparented"; + return string.Format("bounds={0} visible={1} handle={2} childIndex={3} siblings=[{4}]", + chart.Bounds, chart.Visible, chart.IsHandleCreated, + chart.Parent.Controls.GetChildIndex(chart), + string.Join("; ", chart.Parent.Controls.Cast().Select(c => + c.GetType().Name + ":" + c.Visible + ":" + c.Bounds).ToArray())); + } + + private static Bitmap ScreenshotComplexControl(Control chart, string name) + { + var rect = chart.RectangleToScreen(chart.ClientRectangle); + Check(rect.Width > 0 && rect.Height > 0, + "Complex chart has no X11 rectangle."); + var bitmap = new Bitmap(rect.Width, rect.Height); + using (var graphics = Graphics.FromImage(bitmap)) + graphics.CopyFromScreen(rect.Location, Point.Empty, rect.Size); + if (name != null) + bitmap.Save(System.IO.Path.Combine(outputDirectory, name), ImageFormat.Png); + return bitmap; + } + + private static int CountDomainColorPixels(Bitmap bitmap) + { + int count = 0; + for (int x = 10; x < bitmap.Width - 10; x += 5) + for (int y = 10; y < bitmap.Height - 10; y += 5) + { + var p = bitmap.GetPixel(x,y); + if (Math.Max(p.R,Math.Max(p.G,p.B)) - Math.Min(p.R,Math.Min(p.G,p.B)) > 45 && + Math.Max(p.R,Math.Max(p.G,p.B)) > 85) + count++; + } + return count; + } + [DllImport("libX11.so.6", EntryPoint = "XOpenDisplay")] + private static extern IntPtr ComplexXOpenDisplay(IntPtr displayName); + [DllImport("libX11.so.6", EntryPoint = "XCloseDisplay")] + private static extern int ComplexXCloseDisplay(IntPtr display); + [DllImport("libX11.so.6", EntryPoint = "XSync")] + private static extern int ComplexXSync(IntPtr display, int discard); + [DllImport("libXtst.so.6", EntryPoint = "XTestFakeMotionEvent")] + private static extern int ComplexXTestFakeMotionEvent(IntPtr display, int screen, int x, int y, ulong delay); + [DllImport("libXtst.so.6", EntryPoint = "XTestFakeButtonEvent")] + private static extern int ComplexXTestFakeButtonEvent(IntPtr display, uint button, int press, ulong delay); + + private static void ClickActualX11Button(Control button) + { + var center = button.PointToScreen(new Point(button.Width / 2, button.Height / 2)); + var display = ComplexXOpenDisplay(IntPtr.Zero); + Check(display != IntPtr.Zero, "Cannot open native X11 display for Draw mouse input."); + try + { + Check(ComplexXTestFakeMotionEvent(display, 0, center.X, center.Y, 0) != 0, + "Native mouse motion to Draw button failed."); + ComplexXSync(display, 0); + Application.DoEvents(); + Check(ComplexXTestFakeButtonEvent(display, 1, 1, 0) != 0, "X11 Draw mouse press failed."); + ComplexXSync(display, 0); + Application.DoEvents(); + Check(ComplexXTestFakeButtonEvent(display, 1, 0, 0) != 0, "X11 Draw mouse release failed."); + ComplexXSync(display, 0); + Application.DoEvents(); + } + finally { ComplexXCloseDisplay(display); } + } + +} diff --git a/build/mono/SmokeTests/Program.cs b/build/mono/SmokeTests/Program.cs index 6b5ccdeb..228ba16c 100644 --- a/build/mono/SmokeTests/Program.cs +++ b/build/mono/SmokeTests/Program.cs @@ -321,6 +321,7 @@ private static void DesktopWorkflow() Same(custom.FindForm(), window, "Custom-function editor must be the displayed editor."); Same(scripting.FindForm(), window, "Scripting editor must be displayed in MainView."); VerifyNestedScriptTree(scripting, directory); + VerifyMonoComplexChartVisible(window, bootstrapper, state, errors); var path = Path.Combine(directory, "visible-functions.tslf"); File.WriteAllText(path, "static real Probe(real x) { return x+42; }"); custom.CustomFunctionsEditor.NewDocument(path); diff --git a/build/mono/StartupProbe/Program.cs b/build/mono/StartupProbe/Program.cs index 0f21d59e..5547b22e 100644 --- a/build/mono/StartupProbe/Program.cs +++ b/build/mono/StartupProbe/Program.cs @@ -5,6 +5,7 @@ using System.IO; using System.Linq; using System.Reflection; +using System.Runtime.InteropServices; using System.Windows.Forms; internal static class Program @@ -49,6 +50,8 @@ public int Run(string applicationPath, string outputDirectory) // Reflection only provides a deterministic close after the real window paints. var watch = Stopwatch.StartNew(); var shown = false; + var complexRequested = false; + double? complexStartedAt = null; double? firstVisibleAt = null; Exception failure = null; using (var timer = new Timer { Interval = 200 }) @@ -75,6 +78,27 @@ public int Run(string applicationPath, string outputDirectory) return; } if (watch.Elapsed.TotalSeconds - firstVisibleAt.Value < 1) return; + if (complexRequested) + { + var chart = Descendants(form).Single(control => control.GetType().Name == "ComplexChart"); + using (var visible = CaptureX11Pixels(chart)) + { + var colorPixels = CountDomainColorPixels(visible); + if (colorPixels > 200 || watch.Elapsed.TotalSeconds - complexStartedAt.Value > 30) + { + visible.Save(Path.Combine(outputDirectory, "production-complex-after.png"), ImageFormat.Png); + Console.WriteLine("Production complex chart: visible=" + chart.Visible + + " bounds=" + chart.Bounds + " colored-pixels=" + colorPixels); + if (colorPixels <= 200) + throw new InvalidOperationException("Production executable did not paint z² after its real X11 Draw action; " + + DescribeComplexFunctionState(chart)); + shown = true; + timer.Stop(); + form.Close(); + } + } + return; + } AssertUsableLayout(form); foreach (var name in new[] { "Computator.NET.Desktop", "Computator.NET.Core", "Computator.NET.Charting", "Computator.NET.Engine" }) { @@ -96,9 +120,10 @@ public int Run(string applicationPath, string outputDirectory) throw new InvalidOperationException("The real application window did not paint its control tree (" + colors.Count + " sampled colors)."); bitmap.Save(Path.Combine(outputDirectory, "startup.png"), ImageFormat.Png); } - shown = true; - timer.Stop(); - form.Close(); + // Mark the transition before pumping nested X11 events during the click. + complexRequested = true; + complexStartedAt = watch.Elapsed.TotalSeconds; + BeginProductionComplexPlot(form, outputDirectory); } catch (Exception error) { @@ -125,6 +150,132 @@ public int Run(string applicationPath, string outputDirectory) } } + + private static void BeginProductionComplexPlot(Form form, string outputDirectory) + { + // Exercise mode selection and Draw in the *production* executable's + // Application.Run loop, including its real injected presenters and views. + var menuField = form.GetType().GetField("fdsfdsToolStripMenuItem", + BindingFlags.NonPublic | BindingFlags.Instance); + if (menuField == null) + throw new InvalidOperationException("Missing production complex-mode menu."); + var mode = menuField.GetValue(form) as ToolStripMenuItem; + if (mode == null) + throw new InvalidOperationException("Complex-mode menu has unexpected type."); + mode.PerformClick(); + var children = Descendants(form).ToArray(); + var chart = children.Single(control => control.GetType().Name == "ComplexChart"); + var expression = children.Single(control => control.GetType().Name == "ExpressionTextBox"); + var draw = children.Single(control => control.Name == "addToChartButton") as Button; + if (draw == null || !draw.Visible || !chart.Visible || !chart.IsHandleCreated || + chart.ClientSize.Width < 200 || chart.ClientSize.Height < 150) + throw new InvalidOperationException("Production complex chart or Draw is not actually visible: " + + "chart=" + chart.Bounds + " visible=" + chart.Visible + " draw=" + draw?.Visible); + using (var before = CaptureX11Pixels(chart)) + before.Save(Path.Combine(outputDirectory, "production-complex-before.png"), ImageFormat.Png); + expression.Text = "z²"; + var clickCount = 0; + draw.Click += (sender, args) => clickCount++; + // Button.PerformClick() is not a substitute for a native Mono/X11 + // pointer action. The previous probe could remain blank even though + // the bootstrapped desktop's XTEST Draw action painted successfully. + ClickProductionDrawOnX11(draw); + if (clickCount != 1) + throw new InvalidOperationException("X11 Draw did not reach the production button Click event; count=" + clickCount); + Console.WriteLine("Production executable real X11 Draw clicked once; bounds=" + chart.Bounds + + "; expression=" + expression.Text + "; " + DescribeComplexFunctionState(chart)); + } + + private static string DescribeComplexFunctionState(Control chart) + { + var function = chart.GetType().GetField("function", BindingFlags.Instance | BindingFlags.NonPublic); + var error = chart.GetType().GetProperty("LastRenderError", BindingFlags.Instance | BindingFlags.Public); + var type = chart.GetType(); + var redrawing = type.GetField("_redrawing", BindingFlags.Instance | BindingFlags.NonPublic); + var points = type.GetField("_pointsValues", BindingFlags.Instance | BindingFlags.NonPublic); + var cache = type.GetField("image", BindingFlags.Instance | BindingFlags.NonPublic); + var version = type.GetField("_redrawVersion", BindingFlags.Instance | BindingFlags.NonPublic); + var quality = type.GetField("quality", BindingFlags.Instance | BindingFlags.NonPublic); + var monoTask = type.GetField("_monoRenderTask", BindingFlags.Instance | BindingFlags.NonPublic); + var monoTimer = type.GetField("_monoRenderTimer", BindingFlags.Instance | BindingFlags.NonPublic); + var pending = monoTask == null ? null : monoTask.GetValue(chart) as System.Threading.Tasks.Task; + var polling = monoTimer == null ? null : monoTimer.GetValue(chart) as System.Windows.Forms.Timer; + var sampled = points == null ? null : points.GetValue(chart) as Array; + var cached = cache == null ? null : cache.GetValue(chart) as Bitmap; + return "function-assigned=" + (function != null && function.GetValue(chart) != null) + + "; async-error=" + (error == null ? "(unavailable)" : (error.GetValue(chart, null) ?? "(none)")) + + "; redraw-active=" + (redrawing == null ? "(unavailable)" : redrawing.GetValue(chart).ToString()) + + "; redraw-version=" + (version == null ? "(unavailable)" : version.GetValue(chart).ToString()) + + "; mono-task-status=" + (pending == null ? "(none)" : pending.Status.ToString()) + + "; mono-timer-enabled=" + (polling == null ? "(none)" : polling.Enabled.ToString()) + + "; sample-quality=" + (quality == null ? "(unavailable)" : quality.GetValue(chart).ToString()) + + "; sampled-grid=" + (sampled == null ? "(none)" : sampled.GetLength(0) + "x" + sampled.GetLength(1)) + + "; cached-image=" + (cached == null ? "(none)" : cached.Width + "x" + cached.Height) + + "; cached-colored-pixels=" + (cached == null ? -1 : CountDomainColorPixels(cached)) + + "; current-sync-context=" + (System.Threading.SynchronizationContext.Current == null + ? "(none)" : System.Threading.SynchronizationContext.Current.GetType().FullName); + } + + [DllImport("libX11.so.6")] + private static extern IntPtr XOpenDisplay(IntPtr displayName); + [DllImport("libX11.so.6")] + private static extern int XCloseDisplay(IntPtr display); + [DllImport("libX11.so.6")] + private static extern int XSync(IntPtr display, int discard); + [DllImport("libXtst.so.6")] + private static extern int XTestFakeMotionEvent(IntPtr display, int screen, int x, int y, ulong delay); + [DllImport("libXtst.so.6")] + private static extern int XTestFakeButtonEvent(IntPtr display, uint button, int press, ulong delay); + + private static void ClickProductionDrawOnX11(Button draw) + { + var center = draw.PointToScreen(new Point(draw.Width / 2, draw.Height / 2)); + var display = XOpenDisplay(IntPtr.Zero); + if (display == IntPtr.Zero) + throw new InvalidOperationException("Cannot open X11 display for production Draw acceptance."); + try + { + if (XTestFakeMotionEvent(display, 0, center.X, center.Y, 0) == 0) + throw new InvalidOperationException("Cannot position native pointer over production Draw."); + XSync(display, 0); + Application.DoEvents(); + if (XTestFakeButtonEvent(display, 1, 1, 0) == 0) + throw new InvalidOperationException("Native X11 Draw press failed."); + XSync(display, 0); + Application.DoEvents(); + if (XTestFakeButtonEvent(display, 1, 0, 0) == 0) + throw new InvalidOperationException("Native X11 Draw release failed."); + XSync(display, 0); + Application.DoEvents(); + } + finally { XCloseDisplay(display); } + } + + private static Bitmap CaptureX11Pixels(Control control) + { + var rectangle = control.RectangleToScreen(control.ClientRectangle); + if (rectangle.Width < 1 || rectangle.Height < 1) + throw new InvalidOperationException("Invalid native screen rectangle for " + control.Name); + var image = new Bitmap(rectangle.Width, rectangle.Height); + using (var graphics = Graphics.FromImage(image)) + graphics.CopyFromScreen(rectangle.Location, Point.Empty, rectangle.Size); + return image; + } + + private static int CountDomainColorPixels(Bitmap bitmap) + { + var pixels = 0; + for (var x = 10; x < bitmap.Width - 10; x += 5) + for (var y = 10; y < bitmap.Height - 10; y += 5) + { + var pixel = bitmap.GetPixel(x, y); + if (Math.Max(pixel.R, Math.Max(pixel.G, pixel.B)) - + Math.Min(pixel.R, Math.Min(pixel.G, pixel.B)) > 45 && + Math.Max(pixel.R, Math.Max(pixel.G, pixel.B)) > 85) + pixels++; + } + return pixels; + } private static System.Collections.Generic.IEnumerable Descendants(Control parent) { foreach (Control child in parent.Controls) diff --git a/build/mono/validate.sh b/build/mono/validate.sh index b85b35e0..8bdbd247 100644 --- a/build/mono/validate.sh +++ b/build/mono/validate.sh @@ -56,6 +56,12 @@ mono "$validation_temp/LegacyFixture.exe" "$validation_temp/legacy-settings.dat" rm -f -- "$results/mono-results.xml" "$results/startup.png" "$results/desktop-workflow.png" \ "$results/real-chart.png" "$results/complex-chart.png" "$results/unsupported-3d.png" "$results/function-reference.png" \ "$results/calculator-inputs.png" "$results/numerical-inputs.png" +# Do not mistake screenshots from a previous acceptance run for current evidence. +rm -f -- "$results/production-complex-before.png" "$results/production-complex-after.png" \ + "$results/complex-visible-before.png" "$results/complex-visible-after.png" \ + "$results/complex-visible-z-cubed.png" "$results/complex-visible-resized.png" \ + "$results/complex-visible-cleared.png" "$results/complex-visible-recovered.png" \ + "$results/complex-visible-render-failure.png" "$results/complex-visible-after-render-failure.png" status=0 COMPUTATOR_DATA_DIRECTORY="$validation_temp/startup" \ XDG_CONFIG_HOME="$validation_temp/config" XDG_CACHE_HOME="$validation_temp/cache" \