diff --git a/Tools/OutWit.Database.Studio.Tests/Models/DatabaseNodeTests.cs b/Tools/OutWit.Database.Studio.Tests/Models/DatabaseNodeTests.cs index 31a9b79..ee58830 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 0000000..03ef714 --- /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 2c3e38d..6ebb000 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 0000000..90c505a --- /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 c156dfc..ab953b8 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 7862ee3..bac88e9 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 60e72cf..6cbd6aa 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 f031b08..82e4124 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 65c58ee..71dbaa1 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 @@ -