Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public void CloneCreatesExactCopyTest()
Name = "TestTable",
NodeType = DatabaseNodeType.Table,
IsExpanded = true,
Children = new List<DatabaseNode>
Children = new System.Collections.ObjectModel.ObservableCollection<DatabaseNode>
{
new DatabaseNode { Name = "Column1", NodeType = DatabaseNodeType.Table },
new DatabaseNode { Name = "Column2", NodeType = DatabaseNodeType.Table }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
using System.Xml.Linq;

namespace OutWit.Database.Studio.Tests;

/// <summary>
/// Text that asks to wrap or to trim is not put where it cannot.
/// </summary>
/// <remarks>
/// <para>
/// <b>A horizontal <c>StackPanel</c> measures its children with INFINITE width.</b> A
/// <c>TextBlock</c> inside one is never told there is an edge, so <c>TextWrapping="Wrap"</c> never
/// wraps and <c>TextTrimming</c> 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.
/// </para>
/// <para>
/// <b>Three findings, one shape</b>, and it took a driving pass to see the third:
/// </para>
/// <list type="bullet">
/// <item>26 - the import result painted across the line saying what the file held;</item>
/// <item>31 - the conflict message painted across the hint bar, both at full opacity;</item>
/// <item>A3 - the read-only banner cut off mid-sentence: <i>«ImportStaging» has no primary ke</i>.
/// That one had asked to wrap since it was written.</item>
/// </list>
/// <para>
/// The fix in every case is a <c>Grid</c> with an <c>Auto</c> column for the icon and a <c>*</c>
/// column for the words. This rule is what stops the fourth.
/// </para>
/// </remarks>
[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<string>();
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// The two halves of the tree's binding, both of which were broken and neither of which a test could
/// see.
/// </summary>
/// <remarks>
/// <para>
/// <b>Measured by driving Studio on 2026-08-19</b>, 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:
/// </para>
/// <list type="number">
/// <item><b>view → model.</b> The row's <c>IsExpanded</c> 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.</item>
/// <item><b>model → view.</b> <c>Children</c> was a <c>List</c>, so replacing the placeholder with
/// the columns notified nobody and the row went on showing the placeholder.</item>
/// </list>
/// <para>
/// <b>Every test read <c>Children</c> directly and set <c>IsExpanded</c> itself</b> - 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.
/// </para>
/// </remarks>
[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<ObservableCollection<DatabaseNode>>(),
"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<DatabaseNode>)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

/// <summary>
/// The window tells the node when its row is opened, because the binding does not.
/// </summary>
[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
}
14 changes: 12 additions & 2 deletions Tools/OutWit.Database.Studio/Models/DatabaseNode.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.ObjectModel;
using OutWit.Common.Abstract;
using OutWit.Common.Aspects;
using OutWit.Common.Values;
Expand Down Expand Up @@ -32,7 +33,7 @@ public override DatabaseNode Clone()
NodeType = NodeType,
ConnectionId = ConnectionId,
IsExpanded = IsExpanded,
Children = Children.Select(node => node.Clone()).ToList()
Children = new ObservableCollection<DatabaseNode>(Children.Select(node => node.Clone()))
};
}

Expand Down Expand Up @@ -91,7 +92,16 @@ public override DatabaseNode Clone()
/// <summary>
/// Gets or sets the child nodes.
/// </summary>
public List<DatabaseNode> Children { get; set; } = [];
/// <summary>
/// What the node opens into.
/// </summary>
/// <remarks>
/// <b>Observable, and measured to need to be.</b> It was a <c>List</c>, 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
/// <c>Children</c> directly and saw the columns; the window is the only thing that could not.
/// </remarks>
public ObservableCollection<DatabaseNode> Children { get; set; } = [];

/// <summary>
/// True while F2 is open on this row: the name is replaced by a box holding
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 36 additions & 1 deletion Tools/OutWit.Database.Studio/Views/DatabaseExplorer.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,47 @@ public partial class DatabaseExplorer : UserControl

#endregion

#region Static

/// <summary>
/// Tells the node that its row has been opened.
/// </summary>
/// <remarks>
/// <para>
/// <b>Measured in the running application, 2026-08-19.</b> The tree binds a row's
/// <c>IsExpanded</c> 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 <c>IsExpanded</c> on the node themselves - which is the
/// ViewModel's side of a binding that only ever worked one way.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
static DatabaseExplorer()
{
TreeViewItem.IsExpandedProperty.Changed.AddClassHandler<TreeViewItem>((item, e) =>
{
if (item.DataContext is DatabaseNode node && e.GetNewValue<bool>())
node.IsExpanded = true;
});
}

#endregion
#region Constructors

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
Expand Down Expand Up @@ -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;
}
}
Expand Down
9 changes: 5 additions & 4 deletions Tools/OutWit.Database.Studio/Views/Dialogs/ExportDialog.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -157,14 +157,15 @@
CornerRadius="4"
Padding="12,8"
IsVisible="{Binding ErrorMessage, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Orientation="Horizontal" Spacing="8">
<PathIcon Data="{x:Static ui:StudioIcons.COMMON_WARNING}"
<!-- A Grid: a horizontal StackPanel measures with infinite width, so the Wrap below could never happen and a long message drew over what was beside it. -->
<Grid ColumnDefinitions="Auto,*">
<PathIcon Grid.Column="0" Data="{x:Static ui:StudioIcons.COMMON_WARNING}"
Width="{DynamicResource Wit.Icon.Header}" Height="{DynamicResource Wit.Icon.Header}"
Foreground="{DynamicResource Wit.Error.Text}"/>
<TextBlock Text="{Binding ErrorMessage}"
<TextBlock Grid.Column="1" Margin="8,0,0,0" Text="{Binding ErrorMessage}"
Foreground="{DynamicResource Wit.Error.Text}"
TextWrapping="Wrap"/>
</StackPanel>
</Grid>
</Border>

</StackPanel>
Expand Down
Loading
Loading