diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d7cf80..5b38618 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,7 @@ env: src/SharpEquations.Avalonia/SharpEquations.Avalonia.csproj TEST_PROJECT: tests/SharpEquations.Tests/SharpEquations.Tests.csproj EDITOR_TEST_PROJECT: tests/SharpEquations.Avalonia.Tests/SharpEquations.Avalonia.Tests.csproj + MATH_TOOLS_TEST_PROJECT: tests/SharpEquations.MathTools.Tests/SharpEquations.MathTools.Tests.csproj FEED_URL: https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json # Stages: Resolve Version -> Test (Linux, Windows, macOS) -> Pack NuGet @@ -172,6 +173,14 @@ jobs: echo "::endgroup::" done + # the math tools without their views (samples/SharpEquations.MathTools.*, net10.0 only) + - name: Run math tools tests + shell: bash + run: | + dotnet run --project "$MATH_TOOLS_TEST_PROJECT" -c Release -f net10.0 --no-build -- \ + --report-trx --report-trx-filename "${{ matrix.rid }}.mathtools.net10.0.trx" \ + --results-directory "$GITHUB_WORKSPACE/TestResults" + - name: Upload test results if: always() uses: actions/upload-artifact@v7 diff --git a/AGENTS.md b/AGENTS.md index cbdd5ab..a57ac32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,11 +19,14 @@ generated parser, on netstandard2.0): keep it so, and keep containers and other | `src/SharpEquations.Avalonia/` | The controls for Avalonia: `MathEditBox` (the editor), `MathView`; embeds Libertinus Math | | `samples/SharpEquations.Avalonia.Demo/` | A window with the editor (`DemoView`, shared with the web demo) | | `samples/SharpEquations.Avalonia.Browser.Demo/` | The same demo on WebAssembly (needs the `wasm-tools` workload, so not in the solution; `.github/workflows/demo.yml` publishes it to GitHub Pages) | -| `samples/SharpEquations.Avalonia.MathTools.Demo/` | A second app, math tools around the editor (`MathToolsView`, a page each: the scientific keypad, the library of formulas, the unit converter, the solver of triangles; the calculator computes with AngouriMath, through `MathReader`, which reads what the normalized presentation MathML means) | +| `samples/SharpEquations.Avalonia.MathTools.Demo/` | A second app, math tools around the editor: its views (`MathToolsView`, a page each: the scientific keypad, the library of formulas, the unit converter, the solver of triangles) and the desktop window | +| `samples/SharpEquations.MathTools.Core/` | The math tools without a user interface: `ICasEngine` (`AngouriMathEngine`, through `MathReader`, which reads what the normalized presentation MathML means), the formulas, units and triangles, and the services the views ask the platform for (`ISettingsStore`: a file on the desktop, the local storage in the browser) | +| `samples/SharpEquations.MathTools.Graphing/` | The graphs of the math tools without a user interface: the region shown (`Viewport`) and the marks of the axes | | `samples/SharpEquations.Avalonia.Browser.MathTools.Demo/` | The math tools on WebAssembly (as the web demo, not in the solution build; `demo.yml` publishes them under `math/`) | | `benchmarks/SharpEquations.Benchmarks/` | BenchmarkDotNet benchmarks of reading, writing and converting the corpora | | `tests/SharpEquations.Tests/` | TUnit tests, in folders by subject (PublicApi, MathML, Omml, Mtef, UnicodeMath, LaTeX, Property, Regression) | -| `tests/SharpEquations.Avalonia.Tests/` | TUnit tests of the editor (Editing, Rendering, and the controls in a headless Avalonia session with Skia) | +| `tests/SharpEquations.Avalonia.Tests/` | TUnit tests of the editor (Editing, Rendering, and the controls in a headless Avalonia session with Skia), and of the views of the math tools | +| `tests/SharpEquations.MathTools.Tests/` | TUnit tests of `SharpEquations.MathTools.Core` and `.Graphing` (net10.0) | | `tests/data/` | Test data and its provenance (`tests/data/README.md`) | ## Code Style diff --git a/README.md b/README.md index 71c3c0d..e60d8e2 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,9 @@ UnicodeMath and LaTeX, and `samples/SharpEquations.Avalonia.Browser.Demo` the sa second app around the editor, a set of math tools (a scientific keypad that types into the editor and computes what is typed with [AngouriMath](https://github.com/asc-community/AngouriMath), a library of formulas, a unit converter, a solver of triangles), with -`samples/SharpEquations.Avalonia.Browser.MathTools.Demo` in the browser (published under `math/` of the same site). +`samples/SharpEquations.Avalonia.Browser.MathTools.Demo` in the browser (published under `math/` of the same site); what +they compute is in `samples/SharpEquations.MathTools.Core` and `samples/SharpEquations.MathTools.Graphing`, which have +no user interface. ## Architecture diff --git a/SharpEquations.slnx b/SharpEquations.slnx index 221091a..850f3ce 100644 --- a/SharpEquations.slnx +++ b/SharpEquations.slnx @@ -9,6 +9,8 @@ + + @@ -32,6 +34,7 @@ + diff --git a/samples/SharpEquations.Avalonia.Browser.MathTools.Demo/BrowserSettingsStore.cs b/samples/SharpEquations.Avalonia.Browser.MathTools.Demo/BrowserSettingsStore.cs new file mode 100644 index 0000000..364fc42 --- /dev/null +++ b/samples/SharpEquations.Avalonia.Browser.MathTools.Demo/BrowserSettingsStore.cs @@ -0,0 +1,32 @@ +namespace SharpEquations.Avalonia.MathTools +{ + using System.Runtime.InteropServices.JavaScript; + using System.Runtime.Versioning; + using SharpEquations.MathTools; + + /// Settings kept in the local storage of the page, under keys that start with mathtools.. + [SupportedOSPlatform("browser")] + public sealed partial class BrowserSettingsStore : ISettingsStore + { + private const string Prefix = "mathtools."; + + public string? Get(string key) => GetItem(Prefix + key); + + public void Set(string key, string? value) + { + if (value == null) + RemoveItem(Prefix + key); + else + SetItem(Prefix + key, value); + } + + [JSImport("globalThis.localStorage.getItem")] + private static partial string? GetItem(string key); + + [JSImport("globalThis.localStorage.setItem")] + private static partial void SetItem(string key, string value); + + [JSImport("globalThis.localStorage.removeItem")] + private static partial void RemoveItem(string key); + } +} diff --git a/samples/SharpEquations.Avalonia.Browser.MathTools.Demo/Program.cs b/samples/SharpEquations.Avalonia.Browser.MathTools.Demo/Program.cs index eed1006..ad4638a 100644 --- a/samples/SharpEquations.Avalonia.Browser.MathTools.Demo/Program.cs +++ b/samples/SharpEquations.Avalonia.Browser.MathTools.Demo/Program.cs @@ -29,7 +29,7 @@ public sealed class App : Application public override void OnFrameworkInitializationCompleted() { if (ApplicationLifetime is ISingleViewApplicationLifetime view) - view.MainView = new MathToolsView(); + view.MainView = new MathToolsView(new BrowserSettingsStore()); base.OnFrameworkInitializationCompleted(); } } diff --git a/samples/SharpEquations.Avalonia.Browser.MathTools.Demo/SharpEquations.Avalonia.Browser.MathTools.Demo.csproj b/samples/SharpEquations.Avalonia.Browser.MathTools.Demo/SharpEquations.Avalonia.Browser.MathTools.Demo.csproj index 71816c7..8019bc4 100644 --- a/samples/SharpEquations.Avalonia.Browser.MathTools.Demo/SharpEquations.Avalonia.Browser.MathTools.Demo.csproj +++ b/samples/SharpEquations.Avalonia.Browser.MathTools.Demo/SharpEquations.Avalonia.Browser.MathTools.Demo.csproj @@ -17,7 +17,8 @@ - + @@ -25,11 +26,12 @@ - + + diff --git a/samples/SharpEquations.Avalonia.MathTools.Demo/AlgebraPanel.cs b/samples/SharpEquations.Avalonia.MathTools.Demo/AlgebraPanel.cs index ccb63c2..6539a61 100644 --- a/samples/SharpEquations.Avalonia.MathTools.Demo/AlgebraPanel.cs +++ b/samples/SharpEquations.Avalonia.MathTools.Demo/AlgebraPanel.cs @@ -4,43 +4,45 @@ namespace SharpEquations.Avalonia.MathTools using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; - using AngouriMath; using global::Avalonia; using global::Avalonia.Controls; using global::Avalonia.Layout; using global::Avalonia.Threading; using SharpEquations; + using SharpEquations.MathTools; /// - /// What the calculator computes from the equation of the editor, with AngouriMath: its value as it is typed, and on a - /// click its solutions, its simplified, expanded or factored form, its derivative and its integral, each result - /// drawn and ready to go back to the editor. + /// What the calculator computes from the equation of the editor, with a computer algebra system (): + /// its value as it is typed, and on a click its solutions, its simplified, expanded or factored form, its derivative + /// and its integral, each result drawn and ready to go back to the editor. /// public sealed class AlgebraPanel : UserControl { private static readonly TimeSpan Delay = TimeSpan.FromMilliseconds(300); private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10); - private static readonly (string Action, string Face, string Tip)[] Buttons = + private static readonly (CasAction Action, string Face, string Tip)[] Buttons = { - (Algebra.Solve, "Solve", "The solutions of the equation, or where the expression is 0"), - (Algebra.Simplify, "Simplify", "The expression in a simpler form"), - (Algebra.Expand, "Expand", "Products and powers multiplied out"), - (Algebra.Factor, "Factor", "The expression as a product"), - (Algebra.Differentiate, "d/dx", "The derivative by the letter chosen"), - (Algebra.Integrate, "∫ dx", "An antiderivative by the letter chosen"), + (CasAction.Solve, "Solve", "The solutions of the equation, or where the expression is 0"), + (CasAction.Simplify, "Simplify", "The expression in a simpler form"), + (CasAction.Expand, "Expand", "Products and powers multiplied out"), + (CasAction.Factor, "Factor", "The expression as a product"), + (CasAction.Differentiate, "d/dx", "The derivative by the letter chosen"), + (CasAction.Integrate, "∫ dx", "An antiderivative by the letter chosen"), }; private readonly MathEditBox editor; + private readonly ICasEngine engine; private readonly MathView answer = new MathView { FontSize = 26, HorizontalAlignment = HorizontalAlignment.Left }; private readonly TextBlock answerNote = Ui.Muted("", 12.5); private readonly StackPanel results = new StackPanel { Spacing = 12 }; private readonly DispatcherTimer timer; private int version; - public AlgebraPanel(MathEditBox editor) + public AlgebraPanel(MathEditBox editor, ICasEngine engine) { this.editor = editor; + this.engine = engine; Letter = new ComboBox { MinWidth = 70 }; timer = new DispatcherTimer { Interval = Delay }; timer.Tick += async (_, _) => @@ -83,40 +85,40 @@ public AlgebraPanel(MathEditBox editor) public ComboBox Letter { get; } /// The value of the equation of the editor, as it was last computed. - public AlgebraResult? Answer { get; private set; } + public CasResult? Answer { get; private set; } /// The results of the actions, the last first. - public IReadOnlyList Results => history; + public IReadOnlyList Results => history; - private readonly List history = new List(); + private readonly List history = new List(); /// Computes the value of the equation of the editor now, as typing does after a pause. public async Task RefreshAnswer() { timer.Stop(); var current = ++version; - if (!TryRead(out var expression, out var error)) + if (!TryRead(out var equation, out var letters, out var error)) { - UpdateLetters(null); + UpdateLetters(Array.Empty()); ShowAnswer(null, error); return; } - UpdateLetters(expression); - var (result, message) = await Compute(Algebra.Evaluate, expression!, null).ConfigureAwait(true); + UpdateLetters(letters); + var (result, message) = await Compute(CasAction.Evaluate, equation!, null).ConfigureAwait(true); if (current == version) ShowAnswer(result, message); } /// Does an action to the equation of the editor, as its button does, and adds the result to the list. - public async Task RunAsync(string action) + public async Task RunAsync(CasAction action) { - if (!TryRead(out var expression, out var error)) + if (!TryRead(out var equation, out _, out var error)) { AddMessage(error!); return null; } var letter = Letter.SelectedItem as string; - var (result, message) = await Compute(action, expression!, letter).ConfigureAwait(true); + var (result, message) = await Compute(action, equation!, letter).ConfigureAwait(true); if (result == null) { AddMessage(message!); @@ -129,11 +131,11 @@ public async Task RefreshAnswer() return result; } - private bool TryRead(out Entity? expression, out string? error) + private bool TryRead(out Equation? equation, out IReadOnlyList letters, out string? error) { - expression = null; + letters = Array.Empty(); error = null; - var equation = editor.Equation; + equation = editor.Equation; if (equation == null) { error = "The value of what is typed shows here."; @@ -146,7 +148,7 @@ private bool TryRead(out Entity? expression, out string? error) } try { - expression = MathReader.Read(equation); + letters = engine.Variables(equation); return true; } catch (NotSupportedException e) @@ -156,27 +158,26 @@ private bool TryRead(out Entity? expression, out string? error) } } - // on another thread, since AngouriMath can take long (it cannot be stopped: a result too late is dropped) - private static async Task<(AlgebraResult? Result, string? Message)> Compute(string action, Entity expression, string? letter) + // on another thread, since the engine can take long (it cannot be stopped: a result too late is dropped) + private async Task<(CasResult? Result, string? Message)> Compute(CasAction action, Equation equation, string? letter) { - var work = Task.Run(() => Algebra.Run(action, expression, letter)); + var work = Task.Run(() => engine.Run(action, equation, letter)); if (await Task.WhenAny(work, Task.Delay(Timeout)).ConfigureAwait(true) != work) - return (null, "It takes too long: AngouriMath found no result in " + Timeout.TotalSeconds + " seconds."); + return (null, "It takes too long: " + engine.Name + " found no result in " + Timeout.TotalSeconds + " seconds."); try { return (await work.ConfigureAwait(true), null); } -#pragma warning disable CA1031 // what AngouriMath cannot do, it says with exceptions of many kinds +#pragma warning disable CA1031 // what the engine cannot do, it says with exceptions of many kinds catch (Exception e) #pragma warning restore CA1031 { - return (null, "AngouriMath cannot do it: " + e.Message); + return (null, engine.Name + " cannot do it: " + e.Message); } } - private void UpdateLetters(Entity? expression) + private void UpdateLetters(IReadOnlyList letters) { - var letters = expression == null ? Array.Empty() : Algebra.Variables(expression); var selected = Letter.SelectedItem as string; if (Letter.ItemsSource is IReadOnlyList old && old.SequenceEqual(letters)) return; @@ -184,7 +185,7 @@ private void UpdateLetters(Entity? expression) Letter.SelectedItem = selected != null && letters.Contains(selected) ? selected : letters.Count > 0 ? letters[0] : null; } - private void ShowAnswer(AlgebraResult? result, string? message) + private void ShowAnswer(CasResult? result, string? message) { Answer = result; answer.Equation = result?.Math; @@ -200,7 +201,7 @@ private void AddMessage(string message) results.Children.RemoveAt(results.Children.Count - 1); } - private Border ResultCard(AlgebraResult result, string label) + private Border ResultCard(CasResult result, string label) { Control math = result.Math != null ? new MathView { FontSize = 22, Equation = result.Math, HorizontalAlignment = HorizontalAlignment.Left } @@ -222,12 +223,12 @@ private Border ResultCard(AlgebraResult result, string label) return card; } - private static string Label(string action, string letter) => action switch + private static string Label(CasAction action, string letter) => action switch { - Algebra.Solve => "Solve for " + letter, - Algebra.Differentiate => "Derivative by " + letter, - Algebra.Integrate => "Antiderivative by " + letter, - _ => action, + CasAction.Solve => "Solve for " + letter, + CasAction.Differentiate => "Derivative by " + letter, + CasAction.Integrate => "Antiderivative by " + letter, + _ => action.ToString(), }; } } diff --git a/samples/SharpEquations.Avalonia.MathTools.Demo/CalculatorPage.cs b/samples/SharpEquations.Avalonia.MathTools.Demo/CalculatorPage.cs index 8faeb4d..7cd6a38 100644 --- a/samples/SharpEquations.Avalonia.MathTools.Demo/CalculatorPage.cs +++ b/samples/SharpEquations.Avalonia.MathTools.Demo/CalculatorPage.cs @@ -8,6 +8,7 @@ namespace SharpEquations.Avalonia.MathTools using global::Avalonia.Layout; using global::Avalonia.Media; using SharpEquations; + using SharpEquations.MathTools; /// /// The scientific keypad: the keys type into the equation editor, which the keyboard types into too; what the @@ -18,7 +19,7 @@ public sealed class CalculatorPage : UserControl private readonly TextBox unicodeMath = Code(); private readonly TextBox latex = Code(); - public CalculatorPage() + public CalculatorPage(ICasEngine engine) { Editor = new MathEditBox { @@ -54,7 +55,7 @@ public CalculatorPage() foreach (var key in CalculatorKeys.Editing) editing.Children.Add(Key(key, "KeyBrush")); - Solver = new AlgebraPanel(Editor); + Solver = new AlgebraPanel(Editor, engine); var formats = new Grid { @@ -72,7 +73,7 @@ public CalculatorPage() Spacing = 16, Children = { frame, keypad, editing }, }), - Ui.Card("Compute", "With AngouriMath: the value of what is typed, and its solutions, simpler forms, derivative and antiderivative", Solver), + Ui.Card("Compute", "With " + engine.Name + ": the value of what is typed, and its solutions, simpler forms, derivative and antiderivative", Solver), Ui.Card("As text", "The equation in the linear formats, as it is typed", formats), }, }; diff --git a/samples/SharpEquations.Avalonia.MathTools.Demo/FormulasPage.cs b/samples/SharpEquations.Avalonia.MathTools.Demo/FormulasPage.cs index 1820ded..6f63cbc 100644 --- a/samples/SharpEquations.Avalonia.MathTools.Demo/FormulasPage.cs +++ b/samples/SharpEquations.Avalonia.MathTools.Demo/FormulasPage.cs @@ -9,6 +9,7 @@ namespace SharpEquations.Avalonia.MathTools using global::Avalonia.Layout; using global::Avalonia.Media; using SharpEquations; + using SharpEquations.MathTools; /// /// The library of formulas: each one drawn by a , filtered by subject and by name, and opened in diff --git a/samples/SharpEquations.Avalonia.MathTools.Demo/MainWindow.cs b/samples/SharpEquations.Avalonia.MathTools.Demo/MainWindow.cs index 558b530..ec1bdbc 100644 --- a/samples/SharpEquations.Avalonia.MathTools.Demo/MainWindow.cs +++ b/samples/SharpEquations.Avalonia.MathTools.Demo/MainWindow.cs @@ -1,16 +1,17 @@ namespace SharpEquations.Avalonia.MathTools { using global::Avalonia.Controls; + using SharpEquations.MathTools; - /// The window of the desktop app: the view the WebAssembly page shows too. + /// The window of the desktop app: the view the WebAssembly page shows too, with the settings kept where the store says (none by default). public sealed class MainWindow : Window { - public MainWindow() + public MainWindow(ISettingsStore? settings = null) { Title = "SharpEquations Math Tools"; Width = 1180; Height = 860; - Content = new MathToolsView(); + Content = new MathToolsView(settings); } } } diff --git a/samples/SharpEquations.Avalonia.MathTools.Demo/MathToolsView.cs b/samples/SharpEquations.Avalonia.MathTools.Demo/MathToolsView.cs index ba76d3e..4362586 100644 --- a/samples/SharpEquations.Avalonia.MathTools.Demo/MathToolsView.cs +++ b/samples/SharpEquations.Avalonia.MathTools.Demo/MathToolsView.cs @@ -1,25 +1,37 @@ namespace SharpEquations.Avalonia.MathTools { using System; + using System.Globalization; using global::Avalonia; using global::Avalonia.Controls; using global::Avalonia.Layout; using global::Avalonia.Media; using global::Avalonia.Styling; using SharpEquations; + using SharpEquations.MathTools; /// /// The math tools, a page each: the scientific keypad around the equation editor, the library of formulas, the - /// unit converter and the solver of triangles. The desktop window and the WebAssembly page show the same view. + /// unit converter and the solver of triangles. The desktop window and the WebAssembly page show the same view, each + /// with the services of its platform: where the settings are kept (the page shown, the equation of the calculator, + /// the theme), and the computer algebra system. /// public sealed class MathToolsView : UserControl { private const string RepositoryUrl = "https://github.com/simonebortolin/SharpEquations"; - public MathToolsView() + private const string TabKey = "tab"; + private const string EquationKey = "equation"; + private const string ThemeKey = "theme"; + + private readonly ISettingsStore settings; + + public MathToolsView(ISettingsStore? settings = null, ICasEngine? engine = null) { + this.settings = settings ?? new MemorySettingsStore(); + Engine = engine ?? new AngouriMathEngine(); Ui.AddBrushes(Resources); - Calculator = new CalculatorPage(); + Calculator = new CalculatorPage(Engine); Converter = new UnitsPage(); TriangleSolver = new TrianglePage(); Tabs = new TabControl { Margin = new Thickness(20, 12, 20, 0) }; @@ -37,11 +49,18 @@ public MathToolsView() Tab("Triangles", TriangleSolver), }; + Restore(); + Tabs.SelectionChanged += (_, _) => this.settings.Set(TabKey, Tabs.SelectedIndex.ToString(CultureInfo.InvariantCulture)); + Calculator.Editor.EquationChanged += (_, _) => this.settings.Set(EquationKey, Calculator.Editor.Equation?.ToString(EquationFormat.UnicodeMath)); + var root = new DockPanel { Children = { Ui.Docked(Header(), Dock.Top), Tabs } }; root.Bind(Panel.BackgroundProperty, root.GetResourceObservable("PageBrush")); Content = root; } + /// The computer algebra system of the calculator. + public ICasEngine Engine { get; } + /// The page of the scientific keypad. public CalculatorPage Calculator { get; } @@ -57,6 +76,17 @@ public MathToolsView() /// The tabs of the pages. public TabControl Tabs { get; } + // the page, the equation and the theme of the last session + private void Restore() + { + if (int.TryParse(settings.Get(TabKey), NumberStyles.None, CultureInfo.InvariantCulture, out var tab) && tab < Tabs.ItemCount) + Tabs.SelectedIndex = tab; + if (settings.Get(EquationKey) is { } text && Equation.TryParse(text, EquationFormat.UnicodeMath, new EquationOptions { Display = true }, out var equation)) + Calculator.Editor.Equation = equation; + if (Application.Current is { } app && settings.Get(ThemeKey) is { } theme) + app.RequestedThemeVariant = theme == "dark" ? ThemeVariant.Dark : ThemeVariant.Light; + } + private static TabItem Tab(string header, Control page) => new TabItem { Header = new TextBlock { Text = header, FontSize = 15, FontWeight = FontWeight.SemiBold }, @@ -99,7 +129,11 @@ private Border Header() theme.Click += (_, _) => { if (Application.Current is { } app) - app.RequestedThemeVariant = app.ActualThemeVariant == ThemeVariant.Dark ? ThemeVariant.Light : ThemeVariant.Dark; + { + var dark = app.ActualThemeVariant != ThemeVariant.Dark; + app.RequestedThemeVariant = dark ? ThemeVariant.Dark : ThemeVariant.Light; + settings.Set(ThemeKey, dark ? "dark" : "light"); + } }; var github = HeaderButton("GitHub ↗", RepositoryUrl); github.Click += async (_, _) => diff --git a/samples/SharpEquations.Avalonia.MathTools.Demo/Program.cs b/samples/SharpEquations.Avalonia.MathTools.Demo/Program.cs index 85b9c18..6ab154a 100644 --- a/samples/SharpEquations.Avalonia.MathTools.Demo/Program.cs +++ b/samples/SharpEquations.Avalonia.MathTools.Demo/Program.cs @@ -3,6 +3,7 @@ namespace SharpEquations.Avalonia.MathTools using System; using global::Avalonia; using global::Avalonia.Controls.ApplicationLifetimes; + using SharpEquations.MathTools; public static class Program { @@ -19,7 +20,7 @@ public sealed class App : Application public override void OnFrameworkInitializationCompleted() { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) - desktop.MainWindow = new MainWindow(); + desktop.MainWindow = new MainWindow(new FileSettingsStore(FileSettingsStore.DefaultPath)); base.OnFrameworkInitializationCompleted(); } } diff --git a/samples/SharpEquations.Avalonia.MathTools.Demo/SharpEquations.Avalonia.MathTools.Demo.csproj b/samples/SharpEquations.Avalonia.MathTools.Demo/SharpEquations.Avalonia.MathTools.Demo.csproj index a54a8bb..032dc72 100644 --- a/samples/SharpEquations.Avalonia.MathTools.Demo/SharpEquations.Avalonia.MathTools.Demo.csproj +++ b/samples/SharpEquations.Avalonia.MathTools.Demo/SharpEquations.Avalonia.MathTools.Demo.csproj @@ -1,6 +1,7 @@ - + WinExe net10.0 @@ -12,11 +13,12 @@ - + + diff --git a/samples/SharpEquations.Avalonia.MathTools.Demo/TrianglePage.cs b/samples/SharpEquations.Avalonia.MathTools.Demo/TrianglePage.cs index 33df727..0044faf 100644 --- a/samples/SharpEquations.Avalonia.MathTools.Demo/TrianglePage.cs +++ b/samples/SharpEquations.Avalonia.MathTools.Demo/TrianglePage.cs @@ -9,6 +9,7 @@ namespace SharpEquations.Avalonia.MathTools using global::Avalonia.Layout; using global::Avalonia.Media; using SharpEquations; + using SharpEquations.MathTools; /// /// The solver of triangles: three of the sides and angles give the others, the area, the perimeter and the radii of @@ -107,11 +108,6 @@ public void Fill(IReadOnlyList values) Solve(); } - /// The values of a triangle as UnicodeMath: a=3, b=4, c=5 and α=36.8699°, β=53.1301°, γ=90°. - public static (string Sides, string Angles) Describe(Triangle triangle) => ( - string.Join(", ", Enumerable.Range(0, 3).Select(i => Triangles.SideNames[i] + "=" + Units.Format(triangle.Side(i)))), - string.Join(", ", Enumerable.Range(0, 3).Select(i => Triangles.AngleNames[i] + "=" + Units.Format(triangle.Angle(i)) + "°"))); - private void Solve() { if (updating) @@ -160,7 +156,7 @@ private void Show(TriangleSolution solution) private static Grid Result(Triangle triangle, string? title) { - var (sides, angles) = Describe(triangle); + var (sides, angles) = Triangles.Describe(triangle); var values = new StackPanel { Spacing = 10, VerticalAlignment = VerticalAlignment.Center }; if (title != null) values.Children.Add(Ui.Small(title)); diff --git a/samples/SharpEquations.Avalonia.MathTools.Demo/UnitsPage.cs b/samples/SharpEquations.Avalonia.MathTools.Demo/UnitsPage.cs index 9b683c9..2dfed11 100644 --- a/samples/SharpEquations.Avalonia.MathTools.Demo/UnitsPage.cs +++ b/samples/SharpEquations.Avalonia.MathTools.Demo/UnitsPage.cs @@ -7,6 +7,7 @@ namespace SharpEquations.Avalonia.MathTools using global::Avalonia.Layout; using global::Avalonia.Media; using SharpEquations; + using SharpEquations.MathTools; /// /// The unit converter: a value in a unit of a quantity, in another unit and in all of them, the results written in diff --git a/samples/SharpEquations.Avalonia.MathTools.Demo/Algebra.cs b/samples/SharpEquations.MathTools.Core/Cas/AngouriMathEngine.cs similarity index 54% rename from samples/SharpEquations.Avalonia.MathTools.Demo/Algebra.cs rename to samples/SharpEquations.MathTools.Core/Cas/AngouriMathEngine.cs index 814a8c9..24efe02 100644 --- a/samples/SharpEquations.Avalonia.MathTools.Demo/Algebra.cs +++ b/samples/SharpEquations.MathTools.Core/Cas/AngouriMathEngine.cs @@ -1,4 +1,4 @@ -namespace SharpEquations.Avalonia.MathTools +namespace SharpEquations.MathTools { using System; using System.Collections.Generic; @@ -7,72 +7,63 @@ namespace SharpEquations.Avalonia.MathTools using AngouriMath; using SharpEquations; - /// A result of : what was done, and the result as an equation (null if it cannot be drawn) and as text. - public sealed record AlgebraResult(string Action, Equation? Math, string Text); - /// - /// What the calculator computes with AngouriMath: the value of an expression (exact, and as a decimal number), - /// the solutions of an equation, simplification, expansion, factoring, derivatives and integrals. The results come - /// back through LaTeX, which AngouriMath writes and SharpEquations reads. + /// The computer algebra of the math tools, with AngouriMath: the equation is read by , and + /// the results come back through LaTeX, which AngouriMath writes and SharpEquations reads. /// - public static class Algebra + public sealed class AngouriMathEngine : ICasEngine { - public const string Evaluate = "Evaluate"; - public const string Solve = "Solve"; - public const string Simplify = "Simplify"; - public const string Expand = "Expand"; - public const string Factor = "Factor"; - public const string Differentiate = "Differentiate"; - public const string Integrate = "Integrate"; + public string Name => "AngouriMath"; + + public IReadOnlyList Variables(Equation equation) => Variables(MathReader.Read(equation)); - /// The actions, in the order of the buttons. - public static readonly IReadOnlyList Actions = new[] { Evaluate, Solve, Simplify, Expand, Factor, Differentiate, Integrate }; + public CasResult Run(CasAction action, Equation equation, string? variable = null) => Run(action, MathReader.Read(equation), variable); /// The letters of an expression, x first when there is one. public static IReadOnlyList Variables(Entity expression) => expression.Vars.Select(v => v.Name).Distinct().OrderBy(v => v == "x" ? 0 : 1).ThenBy(v => v, StringComparer.Ordinal).ToList(); - /// Does an action to an expression read by ; is the letter to solve for, or to differentiate and integrate by. - public static AlgebraResult Run(string action, Entity expression, string? variable) + /// Does an action to an expression of AngouriMath. + public static CasResult Run(CasAction action, Entity expression, string? variable) { var letters = Variables(expression); var x = MathS.Var(variable ?? (letters.Count > 0 ? letters[0] : "x")); switch (action) { - case Evaluate: + case CasAction.Evaluate: return Value(expression); - case Solve: + case CasAction.Solve: var equation = expression is Entity.Equalsf ? expression : expression.Equalizes(0); var solutions = equation.Solve(x).Simplify(); - return Result(Solve, Latex(x) + @"\in " + Latex(solutions), x.ToString() + " ∈ " + solutions.ToString()); - case Simplify: - return Result(Simplify, expression.Simplify()); - case Expand: - return Result(Expand, expression.Expand()); - case Factor: - return Result(Factor, expression.Factorize()); - case Differentiate: - return Result(Differentiate, expression.Differentiate(x).Simplify()); - case Integrate: - return Result(Integrate, expression.Integrate(x).Simplify()); + return Result(action, Latex(x) + @"\in " + Latex(solutions), x.ToString() + " ∈ " + solutions.ToString()); + case CasAction.Simplify: + return Result(action, expression.Simplify()); + case CasAction.Expand: + return Result(action, expression.Expand()); + case CasAction.Factor: + return Result(action, expression.Factorize()); + case CasAction.Differentiate: + return Result(action, expression.Differentiate(x).Simplify()); + case CasAction.Integrate: + return Result(action, expression.Integrate(x).Simplify()); default: - throw new ArgumentOutOfRangeException(nameof(action), action, "Not an action of the calculator."); + throw new ArgumentOutOfRangeException(nameof(action), action, "Not an action of the engine."); } } // the exact value, and the decimal one when it differs: 1/3 ≈ 0.333333, √2 ≈ 1.41421 - private static AlgebraResult Value(Entity expression) + private static CasResult Value(Entity expression) { if (expression is Entity.Equalsf || expression.Vars.Any()) - return Result(Evaluate, expression.Simplify()); + return Result(CasAction.Evaluate, expression.Simplify()); var exact = expression.Simplify(); var approximate = Decimal(exact.EvalNumerical()); var exactLatex = Latex(exact); var exactText = exact.ToString(); if (approximate == null || approximate == exactText) - return Result(Evaluate, exactLatex, exactText); - return Result(Evaluate, exactLatex + @"\approx " + approximate, exactText + " ≈ " + approximate); + return Result(CasAction.Evaluate, exactLatex, exactText); + return Result(CasAction.Evaluate, exactLatex + @"\approx " + approximate, exactText + " ≈ " + approximate); } /// A number of AngouriMath as the calculator shows it: ten significant digits, a+bi for a complex one; null for what is not a number. @@ -97,16 +88,16 @@ private static AlgebraResult Value(Entity expression) private static string Format(double value) => double.IsInfinity(value) ? (value > 0 ? "∞" : "-∞") : value.ToString("G10", CultureInfo.InvariantCulture); - private static AlgebraResult Result(string action, Entity value) => Result(action, Latex(value), value.ToString()); + private static CasResult Result(CasAction action, Entity value) => Result(action, Latex(value), value.ToString()); - private static AlgebraResult Result(string action, string latex, string text) + private static CasResult Result(CasAction action, string latex, string text) { var options = new EquationOptions { Display = true }; var math = Equation.TryParse(latex, EquationFormat.LaTeX, options, out var parsed) && !parsed!.ToMathML().Descendants().Any(e => e.Name.LocalName == "merror") ? parsed : null; - return new AlgebraResult(action, math, text); + return new CasResult(action, math, text); } // the LaTeX of AngouriMath, with its constants as the LaTeX reader knows them diff --git a/samples/SharpEquations.MathTools.Core/Cas/ICasEngine.cs b/samples/SharpEquations.MathTools.Core/Cas/ICasEngine.cs new file mode 100644 index 0000000..0f1f11f --- /dev/null +++ b/samples/SharpEquations.MathTools.Core/Cas/ICasEngine.cs @@ -0,0 +1,52 @@ +namespace SharpEquations.MathTools +{ + using System; + using System.Collections.Generic; + using SharpEquations; + + /// What a computer algebra system does to an equation of the editor. + public enum CasAction + { + /// The value: exact, and as a decimal number when it differs. + Evaluate, + + /// The solutions of an equation, or where an expression is 0. + Solve, + + Simplify, + + /// Products and powers multiplied out. + Expand, + + /// The expression as a product. + Factor, + + /// The derivative by a letter. + Differentiate, + + /// An antiderivative by a letter. + Integrate, + } + + /// A result of an : what was done, the result as an equation (null if it cannot be drawn) and as text. + public sealed record CasResult(CasAction Action, Equation? Math, string Text); + + /// + /// A computer algebra system behind the math tools: it reads what an equation of the editor means and computes with + /// it. is the one of every platform; others can stand beside it (to compare results). + /// + public interface ICasEngine + { + /// The name of the engine, as the app shows it. + string Name { get; } + + /// The letters of an equation, x first when there is one. + /// The equation has something the engine cannot read, as the message says. + IReadOnlyList Variables(Equation equation); + + /// Does an action to an equation; is the letter to solve for, or to differentiate and integrate by (the first of if null). + /// The equation has something the engine cannot read, as the message says. + /// It can take long, and throw exceptions of other kinds for what the engine cannot do. + CasResult Run(CasAction action, Equation equation, string? variable = null); + } +} diff --git a/samples/SharpEquations.Avalonia.MathTools.Demo/MathReader.cs b/samples/SharpEquations.MathTools.Core/Cas/MathReader.cs similarity index 99% rename from samples/SharpEquations.Avalonia.MathTools.Demo/MathReader.cs rename to samples/SharpEquations.MathTools.Core/Cas/MathReader.cs index a006aba..ffb82c6 100644 --- a/samples/SharpEquations.Avalonia.MathTools.Demo/MathReader.cs +++ b/samples/SharpEquations.MathTools.Core/Cas/MathReader.cs @@ -1,4 +1,4 @@ -namespace SharpEquations.Avalonia.MathTools +namespace SharpEquations.MathTools { using System; using System.Collections.Generic; diff --git a/samples/SharpEquations.MathTools.Core/Services/FileSettingsStore.cs b/samples/SharpEquations.MathTools.Core/Services/FileSettingsStore.cs new file mode 100644 index 0000000..4033097 --- /dev/null +++ b/samples/SharpEquations.MathTools.Core/Services/FileSettingsStore.cs @@ -0,0 +1,55 @@ +namespace SharpEquations.MathTools +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Text.Json; + + /// Settings kept in a JSON file, written again at each change (the store of the desktop app). + public sealed class FileSettingsStore : ISettingsStore + { + private readonly string path; + private readonly Dictionary values; + + public FileSettingsStore(string path) + { + this.path = path; + values = Load(path); + } + + /// The file of the settings of the desktop app, in the application data of the user. + public static string DefaultPath { get; } = + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "SharpEquations", "MathTools", "settings.json"); + + public string? Get(string key) => values.TryGetValue(key, out var value) ? value : null; + + public void Set(string key, string? value) + { + if (value == null ? !values.Remove(key) : values.TryGetValue(key, out var old) && old == value) + return; + if (value != null) + values[key] = value; + try + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, JsonSerializer.Serialize(values)); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + // settings that cannot be written are kept for the session + } + } + + private static Dictionary Load(string path) + { + try + { + return File.Exists(path) ? JsonSerializer.Deserialize>(File.ReadAllText(path)) ?? new() : new(); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException or JsonException) + { + return new Dictionary(); + } + } + } +} diff --git a/samples/SharpEquations.MathTools.Core/Services/ISettingsStore.cs b/samples/SharpEquations.MathTools.Core/Services/ISettingsStore.cs new file mode 100644 index 0000000..b595b1d --- /dev/null +++ b/samples/SharpEquations.MathTools.Core/Services/ISettingsStore.cs @@ -0,0 +1,33 @@ +namespace SharpEquations.MathTools +{ + using System.Collections.Generic; + + /// + /// Where the app keeps its settings between sessions, as text by key: a file on the desktop, the local storage of + /// the page in a browser, nothing in the tests. + /// + public interface ISettingsStore + { + /// The value of a key, or null if it has none. + string? Get(string key); + + /// Sets the value of a key; null removes it. + void Set(string key, string? value); + } + + /// Settings kept in memory only, for the session. + public sealed class MemorySettingsStore : ISettingsStore + { + private readonly Dictionary values = new Dictionary(); + + public string? Get(string key) => values.TryGetValue(key, out var value) ? value : null; + + public void Set(string key, string? value) + { + if (value == null) + values.Remove(key); + else + values[key] = value; + } + } +} diff --git a/samples/SharpEquations.MathTools.Core/SharpEquations.MathTools.Core.csproj b/samples/SharpEquations.MathTools.Core/SharpEquations.MathTools.Core.csproj new file mode 100644 index 0000000..5559665 --- /dev/null +++ b/samples/SharpEquations.MathTools.Core/SharpEquations.MathTools.Core.csproj @@ -0,0 +1,21 @@ + + + + + net10.0 + false + enable + SharpEquations.MathTools + + + + + + + + + + + diff --git a/samples/SharpEquations.Avalonia.MathTools.Demo/Formulas.cs b/samples/SharpEquations.MathTools.Core/Tools/Formulas.cs similarity index 99% rename from samples/SharpEquations.Avalonia.MathTools.Demo/Formulas.cs rename to samples/SharpEquations.MathTools.Core/Tools/Formulas.cs index 3d59723..df06ca0 100644 --- a/samples/SharpEquations.Avalonia.MathTools.Demo/Formulas.cs +++ b/samples/SharpEquations.MathTools.Core/Tools/Formulas.cs @@ -1,4 +1,4 @@ -namespace SharpEquations.Avalonia.MathTools +namespace SharpEquations.MathTools { using System; using System.Collections.Generic; diff --git a/samples/SharpEquations.Avalonia.MathTools.Demo/Triangles.cs b/samples/SharpEquations.MathTools.Core/Tools/Triangles.cs similarity index 94% rename from samples/SharpEquations.Avalonia.MathTools.Demo/Triangles.cs rename to samples/SharpEquations.MathTools.Core/Tools/Triangles.cs index 3bf6d60..af7adc1 100644 --- a/samples/SharpEquations.Avalonia.MathTools.Demo/Triangles.cs +++ b/samples/SharpEquations.MathTools.Core/Tools/Triangles.cs @@ -1,4 +1,4 @@ -namespace SharpEquations.Avalonia.MathTools +namespace SharpEquations.MathTools { using System; using System.Collections.Generic; @@ -44,6 +44,11 @@ public static class Triangles public static double Degrees(double radians) => radians * 180 / Math.PI; + /// The values of a triangle as UnicodeMath: a=3, b=4, c=5 and α=36.8699°, β=53.1301°, γ=90°. + public static (string Sides, string Angles) Describe(Triangle triangle) => ( + string.Join(", ", Enumerable.Range(0, 3).Select(i => Triangles.SideNames[i] + "=" + Units.Format(triangle.Side(i)))), + string.Join(", ", Enumerable.Range(0, 3).Select(i => Triangles.AngleNames[i] + "=" + Units.Format(triangle.Angle(i)) + "°"))); + /// Solves the triangle of the values given (sides, and angles in degrees; null for the unknown ones). public static TriangleSolution Solve(double? a, double? b, double? c, double? alpha, double? beta, double? gamma) { diff --git a/samples/SharpEquations.Avalonia.MathTools.Demo/Units.cs b/samples/SharpEquations.MathTools.Core/Tools/Units.cs similarity index 99% rename from samples/SharpEquations.Avalonia.MathTools.Demo/Units.cs rename to samples/SharpEquations.MathTools.Core/Tools/Units.cs index 4c609a3..0d117d5 100644 --- a/samples/SharpEquations.Avalonia.MathTools.Demo/Units.cs +++ b/samples/SharpEquations.MathTools.Core/Tools/Units.cs @@ -1,4 +1,4 @@ -namespace SharpEquations.Avalonia.MathTools +namespace SharpEquations.MathTools { using System; using System.Collections.Generic; diff --git a/samples/SharpEquations.MathTools.Graphing/SharpEquations.MathTools.Graphing.csproj b/samples/SharpEquations.MathTools.Graphing/SharpEquations.MathTools.Graphing.csproj new file mode 100644 index 0000000..0de858e --- /dev/null +++ b/samples/SharpEquations.MathTools.Graphing/SharpEquations.MathTools.Graphing.csproj @@ -0,0 +1,11 @@ + + + + + net10.0 + false + enable + SharpEquations.MathTools.Graphing + + + diff --git a/samples/SharpEquations.MathTools.Graphing/Ticks.cs b/samples/SharpEquations.MathTools.Graphing/Ticks.cs new file mode 100644 index 0000000..f44f33b --- /dev/null +++ b/samples/SharpEquations.MathTools.Graphing/Ticks.cs @@ -0,0 +1,36 @@ +namespace SharpEquations.MathTools.Graphing +{ + using System; + using System.Collections.Generic; + + /// The marks of an axis: round values (1, 2 or 5 times a power of ten) about count of them in a range. + public static class Ticks + { + /// The step between the marks: 1, 2 or 5 times a power of ten, closest to a range divided in . + public static double Step(double min, double max, int count = 8) + { + if (!(max > min) || count < 1) + throw new ArgumentOutOfRangeException(nameof(max), "The range is empty, or there is no mark to put."); + var rough = (max - min) / count; + var power = Math.Pow(10, Math.Floor(Math.Log10(rough))); + var fraction = rough / power; + return power * fraction switch + { + < 1.5 => 1, + < 3.5 => 2, + < 7.5 => 5, + _ => 10, + }; + } + + /// The multiples of between and , with 0 exactly 0. + public static IReadOnlyList Values(double min, double max, int count = 8) + { + var step = Step(min, max, count); + var values = new List(); + for (var i = Math.Ceiling(min / step); i * step <= max; i++) + values.Add(i == 0 ? 0 : i * step); + return values; + } + } +} diff --git a/samples/SharpEquations.MathTools.Graphing/Viewport.cs b/samples/SharpEquations.MathTools.Graphing/Viewport.cs new file mode 100644 index 0000000..8efb1a3 --- /dev/null +++ b/samples/SharpEquations.MathTools.Graphing/Viewport.cs @@ -0,0 +1,49 @@ +namespace SharpEquations.MathTools.Graphing +{ + using System; + + /// + /// The region of the plane a graph shows, from (, ) to + /// (, ), and how it moves: panned by a fraction of its size, zoomed around a point. + /// + public sealed record Viewport(double XMin, double XMax, double YMin, double YMax) + { + /// The region of a new graph: −10 to 10 on both axes. + public static Viewport Default { get; } = new Viewport(-10, 10, -10, 10); + + public double Width => XMax - XMin; + + public double Height => YMax - YMin; + + /// Whether the region has a size on both axes, and finite bounds. + public bool IsValid => Width > 0 && Height > 0 && double.IsFinite(Width) && double.IsFinite(Height); + + /// The region moved by a fraction of its width and height (positive: right and up). + public Viewport Pan(double dx, double dy) => this with + { + XMin = XMin + dx * Width, + XMax = XMax + dx * Width, + YMin = YMin + dy * Height, + YMax = YMax + dy * Height, + }; + + /// The region scaled by around (, ), which stays where it is: below 1 zooms in. + public Viewport Zoom(double factor, double x, double y) + { + if (!(factor > 0) || double.IsInfinity(factor)) + throw new ArgumentOutOfRangeException(nameof(factor), factor, "The factor of a zoom is a positive number."); + return new Viewport(x + (XMin - x) * factor, x + (XMax - x) * factor, y + (YMin - y) * factor, y + (YMax - y) * factor); + } + + /// The region scaled around its center. + public Viewport Zoom(double factor) => Zoom(factor, (XMin + XMax) / 2, (YMin + YMax) / 2); + + /// A point of the plane on a surface of × pixels, y down. + public (double X, double Y) ToScreen(double x, double y, double width, double height) => + ((x - XMin) / Width * width, (YMax - y) / Height * height); + + /// The point of the plane at a pixel of a surface of × pixels. + public (double X, double Y) FromScreen(double px, double py, double width, double height) => + (XMin + px / width * Width, YMax - py / height * Height); + } +} diff --git a/tests/SharpEquations.Avalonia.Tests/MathTools/MathToolsWindowTests.cs b/tests/SharpEquations.Avalonia.Tests/MathTools/MathToolsWindowTests.cs index e0c489d..4f40c04 100644 --- a/tests/SharpEquations.Avalonia.Tests/MathTools/MathToolsWindowTests.cs +++ b/tests/SharpEquations.Avalonia.Tests/MathTools/MathToolsWindowTests.cs @@ -10,6 +10,7 @@ namespace SharpEquations.Avalonia.Tests.MathTools using global::Avalonia.VisualTree; using SharpEquations; using SharpEquations.Avalonia.MathTools; + using SharpEquations.MathTools; /// The window of the math tools opens, and its pages work (a picture of each with SHARPEQUATIONS_SCREENSHOTS). [NotInParallel("headless")] @@ -46,6 +47,30 @@ public async Task KeypadTypesIntoTheEditor() await Assert.That(unicodeMath).IsNotNull(); } + /// The page and the equation of the last session come back from the settings, and the new ones go there. + [Test] + public async Task SettingsAreKept() + { + var settings = new MemorySettingsStore(); + settings.Set("tab", "3"); + settings.Set("equation", "x^2-1"); + var (tab, equation, savedTab, savedEquation) = await Headless.Run(() => + { + var window = new MainWindow(settings); + window.Show(); + var view = window.GetVisualDescendants().OfType().Single(); + var restored = (view.Tabs.SelectedIndex, view.Calculator.Editor.Equation); + view.Tabs.SelectedIndex = 1; + view.Calculator.Editor.Equation = Equation.Parse("a+b", EquationFormat.UnicodeMath); + window.Close(); + return (restored.SelectedIndex, restored.Equation, settings.Get("tab"), settings.Get("equation")); + }); + await Assert.That(tab).IsEqualTo(3); + await Assert.That(equation).IsEqualTo(Equation.Parse("x^2-1", EquationFormat.UnicodeMath, new EquationOptions { Display = true })); + await Assert.That(savedTab).IsEqualTo("1"); + await Assert.That(Equation.Parse(savedEquation!, EquationFormat.UnicodeMath)).IsEqualTo(Equation.Parse("a+b", EquationFormat.UnicodeMath)); + } + [Test] public async Task FormulaOpensInTheCalculator() { @@ -145,10 +170,10 @@ public async Task CalculatorComputes() var first = page.Solver.Answer; page.Editor.Equation = Equation.Parse("x^2-5x+6=0", EquationFormat.UnicodeMath, display); await page.Solver.RefreshAnswer(); - var second = await page.Solver.RunAsync(Algebra.Solve); + var second = await page.Solver.RunAsync(CasAction.Solve); page.Editor.Equation = Equation.Parse("x^3 sin x", EquationFormat.UnicodeMath, display); await page.Solver.RefreshAnswer(); - var third = await page.Solver.RunAsync(Algebra.Differentiate); + var third = await page.Solver.RunAsync(CasAction.Differentiate); window.Height = 1500; Save(window, "math-tools-compute"); page.Editor.Equation = Equation.Parse("\"text\"", EquationFormat.UnicodeMath, display); diff --git a/tests/SharpEquations.Avalonia.Tests/MathTools/AlgebraTests.cs b/tests/SharpEquations.MathTools.Tests/AngouriMathEngineTests.cs similarity index 69% rename from tests/SharpEquations.Avalonia.Tests/MathTools/AlgebraTests.cs rename to tests/SharpEquations.MathTools.Tests/AngouriMathEngineTests.cs index f0f4d1e..cbd0910 100644 --- a/tests/SharpEquations.Avalonia.Tests/MathTools/AlgebraTests.cs +++ b/tests/SharpEquations.MathTools.Tests/AngouriMathEngineTests.cs @@ -1,15 +1,14 @@ -#if NET10_0_OR_GREATER -namespace SharpEquations.Avalonia.Tests.MathTools +namespace SharpEquations.MathTools.Tests { using System; using System.Linq; using System.Threading.Tasks; using AngouriMath; using SharpEquations; - using SharpEquations.Avalonia.MathTools; + using SharpEquations.MathTools; - /// What an equation means, read from its presentation MathML, and what the calculator computes from it with AngouriMath. - public class AlgebraTests + /// What an equation means, read from its presentation MathML, and what the engine of the calculator computes from it with AngouriMath. + public class AngouriMathEngineTests { private static Entity Read(string unicodeMath) => MathReader.Read(Equation.Parse(unicodeMath, EquationFormat.UnicodeMath)); @@ -48,7 +47,7 @@ public async Task Evaluates(string unicodeMath, string expected) var value = Read(unicodeMath); if (value.Vars.Any()) value = value.Simplify(); - await Assert.That(Algebra.Decimal(value.EvalNumerical())).IsEqualTo(expected); + await Assert.That(AngouriMathEngine.Decimal(value.EvalNumerical())).IsEqualTo(expected); } [Test] @@ -61,25 +60,25 @@ public async Task Solves(string unicodeMath, string variable, string[] real) var expression = Read(unicodeMath); var equation = expression is Entity.Equalsf ? expression : expression.Equalizes(0); var solutions = ((Entity.Set.FiniteSet)equation.Solve(variable).Simplify()) - .Select(s => Algebra.Decimal(s.EvalNumerical())).ToList(); + .Select(s => AngouriMathEngine.Decimal(s.EvalNumerical())).ToList(); foreach (var solution in real) await Assert.That(solutions).Contains(solution); } /// The result of each action, drawn: it reads back from the LaTeX of AngouriMath without errors. [Test] - [Arguments("Evaluate", "1/3+1/6", "1/2 ≈ 0.5")] - [Arguments("Evaluate", "√8", "2 * sqrt(2) ≈ 2.828427125")] - [Arguments("Solve", "x^2-5x+6=0", null)] - [Arguments("Simplify", "(x^2-1)/(x-1)", null)] - [Arguments("Expand", "(x+1)^3", null)] - [Arguments("Factor", "x^2-1", null)] - [Arguments("Differentiate", "x^3 sin x", null)] - [Arguments("Integrate", "x e^x", null)] - [Arguments("Evaluate", "d/dx x^3", null)] - public async Task ResultIsDrawn(string action, string unicodeMath, string? text) + [Arguments(CasAction.Evaluate, "1/3+1/6", "1/2 ≈ 0.5")] + [Arguments(CasAction.Evaluate, "√8", "2 * sqrt(2) ≈ 2.828427125")] + [Arguments(CasAction.Solve, "x^2-5x+6=0", null)] + [Arguments(CasAction.Simplify, "(x^2-1)/(x-1)", null)] + [Arguments(CasAction.Expand, "(x+1)^3", null)] + [Arguments(CasAction.Factor, "x^2-1", null)] + [Arguments(CasAction.Differentiate, "x^3 sin x", null)] + [Arguments(CasAction.Integrate, "x e^x", null)] + [Arguments(CasAction.Evaluate, "d/dx x^3", null)] + public async Task ResultIsDrawn(CasAction action, string unicodeMath, string? text) { - var result = Algebra.Run(action, Read(unicodeMath), null); + var result = new AngouriMathEngine().Run(action, Equation.Parse(unicodeMath, EquationFormat.UnicodeMath)); await Assert.That(result.Math).IsNotNull().Because(result.Text); if (text != null) await Assert.That(result.Text).IsEqualTo(text); @@ -88,9 +87,10 @@ public async Task ResultIsDrawn(string action, string unicodeMath, string? text) [Test] public async Task SolvesForTheLetterAsked() { - var expression = Read("a x+b=0"); - await Assert.That(Algebra.Variables(expression)).IsEquivalentTo(new[] { "x", "a", "b" }); - var result = Algebra.Run(Algebra.Solve, expression, "x"); + var engine = new AngouriMathEngine(); + var equation = Equation.Parse("a x+b=0", EquationFormat.UnicodeMath); + await Assert.That(engine.Variables(equation)).IsEquivalentTo(new[] { "x", "a", "b" }); + var result = engine.Run(CasAction.Solve, equation, "x"); await Assert.That(result.Text).Contains("b").And.Contains("a"); } @@ -106,4 +106,3 @@ public async Task SaysWhatItCannotRead(string unicodeMath) } } } -#endif diff --git a/tests/SharpEquations.Avalonia.Tests/MathTools/FormulasTests.cs b/tests/SharpEquations.MathTools.Tests/FormulasTests.cs similarity index 94% rename from tests/SharpEquations.Avalonia.Tests/MathTools/FormulasTests.cs rename to tests/SharpEquations.MathTools.Tests/FormulasTests.cs index f725fcb..6be978e 100644 --- a/tests/SharpEquations.Avalonia.Tests/MathTools/FormulasTests.cs +++ b/tests/SharpEquations.MathTools.Tests/FormulasTests.cs @@ -1,10 +1,9 @@ -#if NET10_0_OR_GREATER -namespace SharpEquations.Avalonia.Tests.MathTools +namespace SharpEquations.MathTools.Tests { using System.Linq; using System.Threading.Tasks; using SharpEquations; - using SharpEquations.Avalonia.MathTools; + using SharpEquations.MathTools; /// The formulas of the library parse without errors, and their UnicodeMath and LaTeX read back. public class FormulasTests @@ -38,4 +37,3 @@ public async Task FindBySubjectAndName() } } } -#endif diff --git a/tests/SharpEquations.MathTools.Tests/GraphingTests.cs b/tests/SharpEquations.MathTools.Tests/GraphingTests.cs new file mode 100644 index 0000000..ee0aa06 --- /dev/null +++ b/tests/SharpEquations.MathTools.Tests/GraphingTests.cs @@ -0,0 +1,48 @@ +namespace SharpEquations.MathTools.Tests +{ + using System.Threading.Tasks; + using SharpEquations.MathTools.Graphing; + + /// The region of the plane a graph shows, and the marks of its axes. + public class GraphingTests + { + [Test] + [Arguments(-10, 10, 8, 2)] + [Arguments(0, 1, 8, 0.1)] + [Arguments(0, 1, 4, 0.2)] + [Arguments(-3, 3, 8, 1)] + [Arguments(0, 1000, 5, 200)] + [Arguments(0, 0.003, 6, 0.0005)] + public async Task StepIsRound(double min, double max, int count, double step) => + await Assert.That(Ticks.Step(min, max, count)).IsEqualTo(step).Within(1e-12); + + [Test] + public async Task ValuesHaveZero() + { + var values = Ticks.Values(-1, 1, 10); + await Assert.That(values.Count).IsEqualTo(11); + await Assert.That(values[5]).IsEqualTo(0); + await Assert.That(values[0]).IsEqualTo(-1).Within(1e-12); + } + + [Test] + public async Task ZoomKeepsThePoint() + { + var zoomed = Viewport.Default.Zoom(0.5, 4, -2); + await Assert.That(zoomed).IsEqualTo(new Viewport(-3, 7, -6, 4)); + var (x, y) = zoomed.FromScreen(350, 200, 500, 500); + await Assert.That(x).IsEqualTo(4); + await Assert.That(y).IsEqualTo(0); + } + + [Test] + public async Task ScreenRoundTrips() + { + var viewport = new Viewport(-2, 6, -1, 3).Pan(0.25, -0.5); + await Assert.That(viewport).IsEqualTo(new Viewport(0, 8, -3, 1)); + var (px, py) = viewport.ToScreen(2, 0, 800, 400); + await Assert.That(px).IsEqualTo(200); + await Assert.That(py).IsEqualTo(100); + } + } +} diff --git a/tests/SharpEquations.MathTools.Tests/SettingsTests.cs b/tests/SharpEquations.MathTools.Tests/SettingsTests.cs new file mode 100644 index 0000000..43c0f5b --- /dev/null +++ b/tests/SharpEquations.MathTools.Tests/SettingsTests.cs @@ -0,0 +1,49 @@ +namespace SharpEquations.MathTools.Tests +{ + using System.IO; + using System.Threading.Tasks; + using SharpEquations.MathTools; + + /// The settings of the desktop app, kept in a file from one session to the next. + public class SettingsTests + { + [Test] + public async Task FileKeepsTheSettings() + { + var directory = Directory.CreateTempSubdirectory(); + try + { + var path = Path.Combine(directory.FullName, "sub", "settings.json"); + var store = new FileSettingsStore(path); + store.Set("tab", "2"); + store.Set("equation", "x^2-1"); + store.Set("equation", null); + var again = new FileSettingsStore(path); + await Assert.That(again.Get("tab")).IsEqualTo("2"); + await Assert.That(again.Get("equation")).IsNull(); + } + finally + { + directory.Delete(true); + } + } + + [Test] + public async Task UnreadableFileIsNoSettings() + { + var path = Path.GetTempFileName(); + try + { + File.WriteAllText(path, "{ not json"); + var store = new FileSettingsStore(path); + await Assert.That(store.Get("tab")).IsNull(); + store.Set("tab", "1"); + await Assert.That(new FileSettingsStore(path).Get("tab")).IsEqualTo("1"); + } + finally + { + File.Delete(path); + } + } + } +} diff --git a/tests/SharpEquations.MathTools.Tests/SharpEquations.MathTools.Tests.csproj b/tests/SharpEquations.MathTools.Tests/SharpEquations.MathTools.Tests.csproj new file mode 100644 index 0000000..cc0ee8e --- /dev/null +++ b/tests/SharpEquations.MathTools.Tests/SharpEquations.MathTools.Tests.csproj @@ -0,0 +1,22 @@ + + + + + net10.0 + Exe + false + SharpEquations.MathTools.Tests + enable + + + + + + + + + + + + diff --git a/tests/SharpEquations.Avalonia.Tests/MathTools/TrianglesTests.cs b/tests/SharpEquations.MathTools.Tests/TrianglesTests.cs similarity index 95% rename from tests/SharpEquations.Avalonia.Tests/MathTools/TrianglesTests.cs rename to tests/SharpEquations.MathTools.Tests/TrianglesTests.cs index 6fe287d..b7dbf4b 100644 --- a/tests/SharpEquations.Avalonia.Tests/MathTools/TrianglesTests.cs +++ b/tests/SharpEquations.MathTools.Tests/TrianglesTests.cs @@ -1,12 +1,11 @@ -#if NET10_0_OR_GREATER -namespace SharpEquations.Avalonia.Tests.MathTools +namespace SharpEquations.MathTools.Tests { using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using SharpEquations; - using SharpEquations.Avalonia.MathTools; + using SharpEquations.MathTools; /// The cases of the solver of triangles, and the laws it writes as UnicodeMath. public class TrianglesTests @@ -136,7 +135,7 @@ public async Task StepsAndValuesParse() texts.AddRange(solution.Steps); foreach (var t in solution.Triangles) { - var (sides, angles) = TrianglePage.Describe(t); + var (sides, angles) = Triangles.Describe(t); texts.Add(sides); texts.Add(angles); } @@ -147,8 +146,7 @@ public async Task StepsAndValuesParse() await Assert.That(equation.ToMathML().Descendants().Any(e => e.Name.LocalName == "merror")).IsFalse().Because(text); await Assert.That(Equation.Parse(equation.ToString(EquationFormat.UnicodeMath), EquationFormat.UnicodeMath)).IsEqualTo(equation).Because(text); } - await Assert.That(TrianglePage.Describe(Triangles.Solve(3, 4, 5, null, null, null).Triangles[0])).IsEqualTo(("a=3, b=4, c=5", "α=36.8699°, β=53.1301°, γ=90°")); + await Assert.That(Triangles.Describe(Triangles.Solve(3, 4, 5, null, null, null).Triangles[0])).IsEqualTo(("a=3, b=4, c=5", "α=36.8699°, β=53.1301°, γ=90°")); } } } -#endif diff --git a/tests/SharpEquations.Avalonia.Tests/MathTools/UnitsTests.cs b/tests/SharpEquations.MathTools.Tests/UnitsTests.cs similarity index 97% rename from tests/SharpEquations.Avalonia.Tests/MathTools/UnitsTests.cs rename to tests/SharpEquations.MathTools.Tests/UnitsTests.cs index 222fb02..cb31dd1 100644 --- a/tests/SharpEquations.Avalonia.Tests/MathTools/UnitsTests.cs +++ b/tests/SharpEquations.MathTools.Tests/UnitsTests.cs @@ -1,11 +1,10 @@ -#if NET10_0_OR_GREATER -namespace SharpEquations.Avalonia.Tests.MathTools +namespace SharpEquations.MathTools.Tests { using System; using System.Linq; using System.Threading.Tasks; using SharpEquations; - using SharpEquations.Avalonia.MathTools; + using SharpEquations.MathTools; /// The conversions of the unit converter, and the values written as UnicodeMath. public class UnitsTests @@ -92,4 +91,3 @@ public async Task EverySymbolIsUniqueAndParses() } } } -#endif