From a3f2ac1860a8b0337e33c36a90abf5333a41f2a9 Mon Sep 17 00:00:00 2001 From: Dmitry Ratner <6830384+dmitrat@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:30:10 +0300 Subject: [PATCH] The tree opened into nothing, and 1003 tests could not see it Phase 0 of the fix plan: the probes that need a running Studio. Studio 1007 green, 1003 before - and the four numbers are the point, because every defect below was invisible to every one of them. **Finding 20 is answered by measurement.** A parse error IS marked: a squiggle one character wide, under the first letter of the token, while a missing table is underlined across its whole name. That is why the report said nothing was marked. The fix is the token's length rather than the wiring, and it is NOT in this commit - it belongs with the underline work. **The tree opened into an empty row.** Phase 5 gave a table a placeholder so that it would draw an expander, and the expander opened onto nothing. Two links were missing, one in each direction: - view to model: the row's IsExpanded is bound to the node's in a STYLE setter, and a binding in a style setter does not push back. The row opened; the model never heard; the columns were never read. - model to view: Children was a List, so replacing the placeholder with the columns notified nobody. Every test read Children directly and set IsExpanded itself - which is the ViewModel's side of a binding that only ever worked one way. Both halves are now held by a fixture that says so. **Create was hidden exactly where its only item applies.** Phase 3's two halves contradicted each other: a trigger is created on a table, and the submenu holding that item was not shown on one. **The read-only banner was cut off mid-sentence**, and it had asked to wrap since it was written. The cause is the shape findings 26 and 31 had: text that wraps or trims inside a horizontal StackPanel, which measures its children with infinite width - so it can do neither. A rule now names that shape, and it found four more of it: the export, import and query error banners, and the query read-only banner. An element with a width of its own is exempt, because a width is an edge - which is how the nine settings labels of phase 6 work. **The strikethrough did nothing.** A decoration set from a row style never reaches the cell text, because the cells build their own TextBlocks. What a deleted row actually gets - dimmed, with the warning colour down its left edge - is what the screen shows, and the style that claimed the rest is deleted rather than left saying something untrue. The case records the measurement. What automation could NOT answer: the MCP click prefers the Invoke pattern, which bypasses real pointer routing, so the palette's click-outside and click-to-run and the double click that opens a table's data are unverified. The double click was seen to be broken by the placeholder - the row toggles its own expansion - and the change for it is written but unmeasured. Both need a hand on the mouse. Co-Authored-By: Claude Opus 5 (1M context) --- .../Models/DatabaseNodeTests.cs | 2 +- .../NoWrappingTextInAHorizontalStackTests.cs | 119 ++++++++++++++++++ .../ChangedAndDeletedRowsAreVisibleTests.cs | 9 +- .../TheTreeAndTheWindowAgreeTests.cs | 118 +++++++++++++++++ .../Models/DatabaseNode.cs | 14 ++- .../ViewModels/DatabaseExplorerViewModel.cs | 4 + .../Views/DatabaseExplorer.axaml.cs | 37 +++++- .../Views/Dialogs/ExportDialog.axaml | 9 +- .../Views/Dialogs/ImportDialog.axaml | 9 +- .../Views/Query/QueryEditor.axaml | 18 +-- .../Views/Workspace/TableEditView.axaml | 16 +-- 11 files changed, 326 insertions(+), 29 deletions(-) create mode 100644 Tools/OutWit.Database.Studio.Tests/NoWrappingTextInAHorizontalStackTests.cs create mode 100644 Tools/OutWit.Database.Studio.Tests/ViewModels/TheTreeAndTheWindowAgreeTests.cs diff --git a/Tools/OutWit.Database.Studio.Tests/Models/DatabaseNodeTests.cs b/Tools/OutWit.Database.Studio.Tests/Models/DatabaseNodeTests.cs index 31a9b79c..ee58830f 100644 --- a/Tools/OutWit.Database.Studio.Tests/Models/DatabaseNodeTests.cs +++ b/Tools/OutWit.Database.Studio.Tests/Models/DatabaseNodeTests.cs @@ -18,7 +18,7 @@ public void CloneCreatesExactCopyTest() Name = "TestTable", NodeType = DatabaseNodeType.Table, IsExpanded = true, - Children = new List + Children = new System.Collections.ObjectModel.ObservableCollection { new DatabaseNode { Name = "Column1", NodeType = DatabaseNodeType.Table }, new DatabaseNode { Name = "Column2", NodeType = DatabaseNodeType.Table } diff --git a/Tools/OutWit.Database.Studio.Tests/NoWrappingTextInAHorizontalStackTests.cs b/Tools/OutWit.Database.Studio.Tests/NoWrappingTextInAHorizontalStackTests.cs new file mode 100644 index 00000000..03ef7143 --- /dev/null +++ b/Tools/OutWit.Database.Studio.Tests/NoWrappingTextInAHorizontalStackTests.cs @@ -0,0 +1,119 @@ +using System.Xml.Linq; + +namespace OutWit.Database.Studio.Tests; + +/// +/// Text that asks to wrap or to trim is not put where it cannot. +/// +/// +/// +/// A horizontal StackPanel measures its children with INFINITE width. A +/// TextBlock inside one is never told there is an edge, so TextWrapping="Wrap" never +/// wraps and TextTrimming never trims: the text is laid out at its full length and drawn over +/// whatever is beside it, or off the end of the window. +/// +/// +/// Three findings, one shape, and it took a driving pass to see the third: +/// +/// +/// 26 - the import result painted across the line saying what the file held; +/// 31 - the conflict message painted across the hint bar, both at full opacity; +/// A3 - the read-only banner cut off mid-sentence: «ImportStaging» has no primary ke. +/// That one had asked to wrap since it was written. +/// +/// +/// The fix in every case is a Grid with an Auto column for the icon and a * +/// column for the words. This rule is what stops the fourth. +/// +/// +[TestFixture] +public class NoWrappingTextInAHorizontalStackTests +{ + [Test] + public void NoTextThatWrapsOrTrimsIsInAHorizontalStackPanelTest() + { + var root = FindStudioProject(); + + Assert.That(root, Is.Not.Null, + "the Studio project was not found from " + AppContext.BaseDirectory); + + var offenders = new List(); + var scanned = 0; + var considered = 0; + + foreach (var file in Directory.EnumerateFiles(root!, "*.axaml", SearchOption.AllDirectories)) + { + if (file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}") + || file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}")) + continue; + + scanned++; + + var document = XDocument.Load(file); + + foreach (var text in document.Descendants().Where(element => element.Name.LocalName == "TextBlock")) + { + var wraps = (string?)text.Attribute("TextWrapping") == "Wrap"; + var trims = text.Attribute("TextTrimming") != null; + + if (!wraps && !trims) + continue; + + considered++; + + // A width of its own is an edge: the panel offers infinity and the element takes + // what it was given, so the words wrap inside it. That is how the nine settings + // labels of phase 6 work, and they are not what this rule is about. + if (text.Attribute("Width") != null || text.Attribute("MaxWidth") != null) + continue; + + var parent = text.Parent; + + if (parent == null || parent.Name.LocalName != "StackPanel") + continue; + + // A StackPanel is vertical unless it says otherwise, and a vertical one measures its + // children with the width it has - which is what wrapping needs. + if ((string?)parent.Attribute("Orientation") != "Horizontal") + continue; + + var what = (string?)text.Attribute("Text") ?? "(bound)"; + + offenders.Add($"{Path.GetRelativePath(root!, file)}: {what}"); + } + } + + Assert.Multiple(() => + { + // CONTROL: a walk that read no markup would report no offenders either. + Assert.That(scanned, Is.GreaterThan(20), + "CONTROL: too few views scanned - the walk is looking in the wrong place"); + + // CONTROL: and one that found no wrapping text at all would pass without looking. + Assert.That(considered, Is.GreaterThan(10), + "CONTROL: no text that wraps or trims was found - the attributes stopped matching"); + + Assert.That(offenders, Is.Empty, + "these ask to wrap or to trim inside a horizontal StackPanel, which offers them " + + "infinite width - so they do neither:" + Environment.NewLine + + string.Join(Environment.NewLine, offenders)); + }); + } + + private static string? FindStudioProject() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, "Tools", "OutWit.Database.Studio"); + + if (Directory.Exists(candidate)) + return candidate; + + directory = directory.Parent; + } + + return null; + } +} diff --git a/Tools/OutWit.Database.Studio.Tests/ViewModels/ChangedAndDeletedRowsAreVisibleTests.cs b/Tools/OutWit.Database.Studio.Tests/ViewModels/ChangedAndDeletedRowsAreVisibleTests.cs index 2c3e38de..6ebb0004 100644 --- a/Tools/OutWit.Database.Studio.Tests/ViewModels/ChangedAndDeletedRowsAreVisibleTests.cs +++ b/Tools/OutWit.Database.Studio.Tests/ViewModels/ChangedAndDeletedRowsAreVisibleTests.cs @@ -118,8 +118,13 @@ public void TheGridDrawsTheMarksTest() Assert.That(markup, Does.Contain("row-changed"), "and so is a row that was changed"); - Assert.That(markup, Does.Contain("Strikethrough"), - "struck through, which is the decision taken"); + // NOT a strikethrough, and the reason is a measurement: driving Studio on 2026-08-19 + // showed that a decoration set from a row style never reaches the cell text, because + // the TextBlocks live inside the cell templates. What a deleted row actually gets is + // what was seen on screen - dimmed, with the warning colour down its left edge - and + // the style that claimed the rest is gone rather than left saying something untrue. + Assert.That(markup, Does.Contain("Opacity"), + "dimmed, which is what the row is drawn as"); }); } diff --git a/Tools/OutWit.Database.Studio.Tests/ViewModels/TheTreeAndTheWindowAgreeTests.cs b/Tools/OutWit.Database.Studio.Tests/ViewModels/TheTreeAndTheWindowAgreeTests.cs new file mode 100644 index 00000000..90c505a3 --- /dev/null +++ b/Tools/OutWit.Database.Studio.Tests/ViewModels/TheTreeAndTheWindowAgreeTests.cs @@ -0,0 +1,118 @@ +using System.Collections.ObjectModel; +using OutWit.Database.Studio.Models; +using OutWit.Database.Studio.Tests.Helpers; + +namespace OutWit.Database.Studio.Tests.ViewModels; + +/// +/// The two halves of the tree's binding, both of which were broken and neither of which a test could +/// see. +/// +/// +/// +/// Measured by driving Studio on 2026-08-19, with 1003 tests green. Phase 5 gave a table a +/// placeholder child so that it would draw an expander - and opening one showed an empty row and +/// nothing else. Two independent links were missing, one in each direction: +/// +/// +/// view → model. The row's IsExpanded is bound to the node's in a STYLE setter, +/// and a binding in a style setter does not push back. The row opened; the model never heard; the +/// columns were never read. +/// model → view. Children was a List, so replacing the placeholder with +/// the columns notified nobody and the row went on showing the placeholder. +/// +/// +/// Every test read Children directly and set IsExpanded itself - which is the +/// ViewModel's side of a binding that only ever worked one way. This fixture holds both mechanisms, +/// because neither can be seen from where the tests stand. +/// +/// +[TestFixture] +public class TheTreeAndTheWindowAgreeTests +{ + #region model to view + + [Test] + public void TheChildrenOfANodeNotifyWhenTheyChangeTest() + { + var node = new DatabaseNode { Name = "Orders", NodeType = DatabaseNodeType.Table }; + + Assert.That(node.Children, Is.InstanceOf>(), + "a plain List binds once and is never heard from again - the columns were read and the " + + "row went on showing the placeholder"); + } + + [Test] + public async Task ReplacingThePlaceholderRaisesTheCollectionChangeTest() + { + await using var studio = await StudioFixture.CreateAsync(); + + await studio.Explorer.RefreshAsync(); + + var table = studio.Explorer.Nodes + .SelectMany(root => root.Children) + .Where(folder => folder.NodeType == DatabaseNodeType.TablesFolder) + .SelectMany(folder => folder.Children) + .First(node => node.Name == "Orders"); + + var changes = 0; + + ((ObservableCollection)table.Children).CollectionChanged += (_, _) => changes++; + + await studio.Explorer.ExpandNodeAsync(table); + + Assert.That(changes, Is.GreaterThan(0), + "the window is told that the placeholder went and the columns arrived"); + } + + #endregion + + #region view to model + + /// + /// The window tells the node when its row is opened, because the binding does not. + /// + [Test] + public void TheWindowTellsTheNodeItWasOpenedTest() + { + var code = Source("Views/DatabaseExplorer.axaml.cs"); + + Assert.Multiple(() => + { + Assert.That(code, Does.Contain("TreeViewItem.IsExpandedProperty.Changed"), + "the view watches the row's own expansion"); + + Assert.That(code, Does.Contain("node.IsExpanded = true"), + "and writes it into the node, which is what starts the read"); + }); + } + + #endregion + + #region Tools + + private static string Source(string relative) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, "Tools", "OutWit.Database.Studio"); + + if (Directory.Exists(Path.Combine(candidate, "Views"))) + { + var path = Path.Combine(candidate, relative.Replace('/', Path.DirectorySeparatorChar)); + + Assert.That(File.Exists(path), Is.True, $"{relative} must be where this fixture says"); + + return File.ReadAllText(path); + } + + directory = directory.Parent; + } + + throw new AssertionException("the Studio project was not found from " + AppContext.BaseDirectory); + } + + #endregion +} diff --git a/Tools/OutWit.Database.Studio/Models/DatabaseNode.cs b/Tools/OutWit.Database.Studio/Models/DatabaseNode.cs index c156dfce..ab953b87 100644 --- a/Tools/OutWit.Database.Studio/Models/DatabaseNode.cs +++ b/Tools/OutWit.Database.Studio/Models/DatabaseNode.cs @@ -1,3 +1,4 @@ +using System.Collections.ObjectModel; using OutWit.Common.Abstract; using OutWit.Common.Aspects; using OutWit.Common.Values; @@ -32,7 +33,7 @@ public override DatabaseNode Clone() NodeType = NodeType, ConnectionId = ConnectionId, IsExpanded = IsExpanded, - Children = Children.Select(node => node.Clone()).ToList() + Children = new ObservableCollection(Children.Select(node => node.Clone())) }; } @@ -91,7 +92,16 @@ public override DatabaseNode Clone() /// /// Gets or sets the child nodes. /// - public List Children { get; set; } = []; + /// + /// What the node opens into. + /// + /// + /// Observable, and measured to need to be. It was a List, so the tree bound to + /// it once and never heard again: the columns of a table were read, the placeholder was + /// replaced - and the row on screen went on showing the placeholder. Every test read + /// Children directly and saw the columns; the window is the only thing that could not. + /// + public ObservableCollection Children { get; set; } = []; /// /// True while F2 is open on this row: the name is replaced by a box holding diff --git a/Tools/OutWit.Database.Studio/ViewModels/DatabaseExplorerViewModel.cs b/Tools/OutWit.Database.Studio/ViewModels/DatabaseExplorerViewModel.cs index 7862ee3e..bac88e96 100644 --- a/Tools/OutWit.Database.Studio/ViewModels/DatabaseExplorerViewModel.cs +++ b/Tools/OutWit.Database.Studio/ViewModels/DatabaseExplorerViewModel.cs @@ -1182,7 +1182,11 @@ private void UpdateCommandStates() // emptied or dropped. ShowsDatabaseActions = nodeType == DatabaseNodeType.Database; + // A TABLE is here because a trigger is created on one: without it the Create submenu was + // hidden exactly where the only item it could offer applies. Measured in the running + // application on 2026-08-19 - the two halves of phase 3 contradicted each other. ShowsCreate = nodeType is DatabaseNodeType.Database + or DatabaseNodeType.Table or DatabaseNodeType.TablesFolder or DatabaseNodeType.ViewsFolder or DatabaseNodeType.IndexesFolder diff --git a/Tools/OutWit.Database.Studio/Views/DatabaseExplorer.axaml.cs b/Tools/OutWit.Database.Studio/Views/DatabaseExplorer.axaml.cs index 60e72cf0..6cbd6aa2 100644 --- a/Tools/OutWit.Database.Studio/Views/DatabaseExplorer.axaml.cs +++ b/Tools/OutWit.Database.Studio/Views/DatabaseExplorer.axaml.cs @@ -27,6 +27,35 @@ public partial class DatabaseExplorer : UserControl #endregion + #region Static + + /// + /// Tells the node that its row has been opened. + /// + /// + /// + /// Measured in the running application, 2026-08-19. The tree binds a row's + /// IsExpanded to the node's in a STYLE setter, and a binding in a style setter does not + /// push back: the row opened, the model never heard, and the columns of a table were never + /// read. Nothing had noticed because until the placeholder child arrived there was no expander + /// to press, and the tests set IsExpanded on the node themselves - which is the + /// ViewModel's side of a binding that only ever worked one way. + /// + /// + /// A class handler rather than a per-item subscription: the containers are recycled as the + /// tree scrolls, so subscribing when one is prepared means unsubscribing when it is not. + /// + /// + static DatabaseExplorer() + { + TreeViewItem.IsExpandedProperty.Changed.AddClassHandler((item, e) => + { + if (item.DataContext is DatabaseNode node && e.GetNewValue()) + node.IsExpanded = true; + }); + } + + #endregion #region Constructors public DatabaseExplorer() @@ -34,7 +63,11 @@ public DatabaseExplorer() InitializeComponent(); DataContext = ApplicationViewModel.Instance; - DoubleTapped += OnDoubleTapped; + // TUNNELLING since 2026-08-19. A TreeViewItem toggles its own expansion on a double + // click, and a table has had a child to expand since the placeholder arrived - so the + // bubbling handler ran after the row had opened, and opening the DATA (WS-19) stopped + // happening. Measured in the running application. + AddHandler(DoubleTappedEvent, OnDoubleTapped, RoutingStrategies.Tunnel); KeyDown += OnKeyDown; // Tunnelling, because a TreeViewItem handles the pointer for its own selection and a @@ -78,10 +111,12 @@ private void OnDoubleTapped(object? sender, Avalonia.Input.TappedEventArgs e) { case Models.DatabaseNodeType.Table when explorer.CanEditData: explorer.EditDataCommand.Execute(null); + e.Handled = true; break; case Models.DatabaseNodeType.View when explorer.CanBrowseData: explorer.SelectTop1000Command.Execute(null); + e.Handled = true; break; } } diff --git a/Tools/OutWit.Database.Studio/Views/Dialogs/ExportDialog.axaml b/Tools/OutWit.Database.Studio/Views/Dialogs/ExportDialog.axaml index f031b08d..82e41242 100644 --- a/Tools/OutWit.Database.Studio/Views/Dialogs/ExportDialog.axaml +++ b/Tools/OutWit.Database.Studio/Views/Dialogs/ExportDialog.axaml @@ -157,14 +157,15 @@ CornerRadius="4" Padding="12,8" IsVisible="{Binding ErrorMessage, Converter={x:Static ObjectConverters.IsNotNull}}"> - - + + - - + diff --git a/Tools/OutWit.Database.Studio/Views/Dialogs/ImportDialog.axaml b/Tools/OutWit.Database.Studio/Views/Dialogs/ImportDialog.axaml index 65c58ee3..71dbaa12 100644 --- a/Tools/OutWit.Database.Studio/Views/Dialogs/ImportDialog.axaml +++ b/Tools/OutWit.Database.Studio/Views/Dialogs/ImportDialog.axaml @@ -256,14 +256,15 @@ Margin="0,16,0,0" IsVisible="{Binding ErrorMessage, Converter={x:Static ObjectConverters.IsNotNull}}"> - - + + - - + - - + + - - + + + - - + - - + + - - + @@ -321,9 +326,6 @@ -