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
@@ -0,0 +1,221 @@
using OutWit.Database.Studio.Models;
using OutWit.Database.Studio.Tests.Helpers;
using OutWit.Database.Studio.ViewModels.Tabs;

namespace OutWit.Database.Studio.Tests.ViewModels;

/// <summary>
/// A double click opens the thing the node IS.
/// </summary>
/// <remarks>
/// <para>
/// One rule rather than three exceptions. A table IS its rows, so it opens the editor (WS-19); a view
/// IS the rows it selects; and the connection IS the database, so it opens the tab that describes it
/// - the same one <i>Database…</i> opens in the menu. Asked for in chat on 2026-08-19: <i>would it
/// not be logical for a double click on the database to open its properties, as the menu item does?</i>
/// </para>
/// <para>
/// <b>The decision is in the ViewModel and this fixture is why.</b> It used to be a switch in the
/// code-behind, where the double click had already been broken once and repaired onto a route that
/// does not exist, with 1014 tests unable to say a word about any of it. A gesture belongs to the
/// view; which node opens what is a rule, and a rule written where no test can read it is a rule that
/// holds only until someone edits it.
/// </para>
/// </remarks>
[TestFixture]
public class ADoubleClickOpensWhatTheNodeIsTests
{
#region Fields

private StudioFixture m_studio = null!;

#endregion

#region Setup

[SetUp]
public async Task SetUp()
{
m_studio = await StudioFixture.CreateAsync();

await m_studio.Explorer.RefreshAsync();
}

[TearDown]
public async Task TearDown()
{
await m_studio.DisposeAsync();
}

#endregion

#region What each node opens

[Test]
public async Task ATableOpensItsRowsTest()
{
Select(DatabaseNodeType.Table, "Customers");

var opened = await OpenAsync();

Assert.Multiple(() =>
{
Assert.That(opened, Is.InstanceOf<TableEditTabViewModel>(),
"a table IS its rows, and they are opened for editing rather than for reading");

Assert.That(((TableEditTabViewModel)opened!).TableName, Is.EqualTo("Customers"));
});
}

[Test]
public async Task AViewOpensTheRowsItSelectsTest()
{
Select(DatabaseNodeType.View, "ActiveOrders");

var opened = await OpenAsync();

Assert.Multiple(() =>
{
Assert.That(opened, Is.InstanceOf<QueryTabViewModel>(),
"a view has no rows of its own to edit, so its query is opened instead");

Assert.That(opened!.Title, Does.Contain("ActiveOrders"));
});
}

/// <summary>
/// The one this fixture was written for.
/// </summary>
[Test]
public async Task TheConnectionOpensTheTabThatDescribesItTest()
{
Select(DatabaseNodeType.Database);

var opened = await OpenAsync();

Assert.That(opened, Is.InstanceOf<DatabaseTabViewModel>(),
"the connection IS the database, and the double click opens what «Database…» opens");
}

/// <summary>
/// And the same tab as the menu item, not a second one beside it.
/// </summary>
[Test]
public async Task TheDoubleClickAndTheMenuItemOpenTheSameTabTest()
{
Select(DatabaseNodeType.Database);

var fromTheDoubleClick = await OpenAsync();

await m_studio.Explorer.OpenWhatItIsAsync();

Assert.Multiple(() =>
{
Assert.That(m_studio.Workspace.Tabs.OfType<DatabaseTabViewModel>().Count(), Is.EqualTo(1),
"opening it twice is opening it once");

Assert.That(m_studio.Workspace.Tabs, Does.Contain(fromTheDoubleClick));
});
}

#endregion

#region What no node opens

[Test]
public async Task NothingElseOpensAnythingTest()
{
var nothing = new[]
{
DatabaseNodeType.TablesFolder, DatabaseNodeType.ViewsFolder,
DatabaseNodeType.IndexesFolder, DatabaseNodeType.TriggersFolder,
DatabaseNodeType.SequencesFolder, DatabaseNodeType.RoutinesFolder,
DatabaseNodeType.Index, DatabaseNodeType.Trigger, DatabaseNodeType.Column
};

var offenders = new List<string>();

foreach (var type in nothing)
{
Select(type);

var before = m_studio.Workspace.Tabs.Count;

if (m_studio.Explorer.CanOpenWhatItIs)
offenders.Add($"{type}: says it has something to open");

await m_studio.Explorer.OpenWhatItIsAsync();

if (m_studio.Workspace.Tabs.Count != before)
offenders.Add($"{type}: opened a tab anyway");
}

Assert.Multiple(() =>
{
Assert.That(offenders, Is.Empty, string.Join(Environment.NewLine, offenders));

// CONTROL: the other direction, in the same case. A property that answered false to
// everything would satisfy every assertion above.
Select(DatabaseNodeType.Table, "Customers");
Assert.That(m_studio.Explorer.CanOpenWhatItIs, Is.True,
"CONTROL: a table does have something to open");
});
}

/// <summary>
/// A node that cannot be reached because its connection has gone is not offered either - the
/// same distinction the menu makes between «does not apply» and «cannot right now».
/// </summary>
[Test]
public async Task ADisconnectedTreeOpensNothingTest()
{
Select(DatabaseNodeType.Table, "Customers");

Assume.That(m_studio.Explorer.CanOpenWhatItIs, Is.True);

await m_studio.Connections.CloseAllAsync();

Assert.That(m_studio.Explorer.CanOpenWhatItIs, Is.False,
"there is nothing left to open it in");
}

#endregion

#region Tools

/// <summary>Opens what the selected node is, and answers with the tab that appeared.</summary>
private async Task<WorkspaceTabViewModel?> OpenAsync()
{
var before = m_studio.Workspace.Tabs.ToList();

Assert.That(m_studio.Explorer.CanOpenWhatItIs, Is.True,
"this node says it has nothing to open");

await m_studio.Explorer.OpenWhatItIsAsync();

return m_studio.Workspace.Tabs.Except(before).FirstOrDefault();
}

private void Select(DatabaseNodeType type, string? named = null)
{
var node = Walk(m_studio.Explorer.Nodes).FirstOrDefault(candidate =>
candidate.NodeType == type && (named == null || candidate.Name == named));

Assert.That(node, Is.Not.Null, $"the tree has a {type} node{(named == null ? "" : " called " + named)}");

m_studio.Explorer.SelectedNode = node;
}

private static IEnumerable<DatabaseNode> Walk(IEnumerable<DatabaseNode> nodes)
{
foreach (var node in nodes)
{
yield return node;

foreach (var child in Walk(node.Children))
yield return child;
}
}

#endregion
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ private void InitCommands()
SelectTop100Command = new RelayCommand(SelectTop100);
SelectTop1000Command = new RelayCommand(SelectTop1000);
OpenDatabaseTabCommand = new RelayCommandAsync(OpenDatabaseTabAsync);
OpenWhatItIsCommand = new RelayCommandAsync(OpenWhatItIsAsync);
EditDataCommand = new RelayCommandAsync(EditDataAsync);
ViewStructureCommand = new RelayCommandAsync(ViewStructureAsync);
ViewDefinitionCommand = new RelayCommandAsync(ViewDefinitionAsync);
Expand Down Expand Up @@ -254,6 +255,49 @@ private async Task EditDataAsync()
Logger.LogInformation("Edit data for table {TableName} in {Connection}", tableName, session.DisplayName);
}

/// <summary>
/// What a double click opens: the thing the node IS.
/// </summary>
/// <remarks>
/// <para>
/// One rule rather than three exceptions. A table IS its rows, so it opens the editor (WS-19); a
/// view IS the rows it selects, so it opens them; and the connection IS the database, so it opens
/// the tab that describes it - the same one <i>Database…</i> opens in the menu. Everything else
/// answers <see cref="CanOpenWhatItIs"/> with false and keeps the tree's own behaviour, which for
/// a folder is to open and close.
/// </para>
/// <para>
/// It lives here rather than in the code-behind because the code-behind is the half no test can
/// reach, and this is a decision - which node opens what - rather than a gesture.
/// </para>
/// </remarks>
public bool CanOpenWhatItIs => SelectedNode?.NodeType switch
{
DatabaseNodeType.Table => CanEditData,
DatabaseNodeType.View => CanBrowseData,
DatabaseNodeType.Database => CanOpenDatabaseTab,
_ => false
};

/// <inheritdoc cref="CanOpenWhatItIs"/>
public async Task OpenWhatItIsAsync()
{
switch (SelectedNode?.NodeType)
{
case DatabaseNodeType.Table when CanEditData:
await EditDataAsync();
break;

case DatabaseNodeType.View when CanBrowseData:
SelectTopRows(1000);
break;

case DatabaseNodeType.Database when CanOpenDatabaseTab:
await OpenDatabaseTabAsync();
break;
}
}

/// <summary>
/// Opens the storage tab of the selected connection (WS-54).
/// </summary>
Expand Down Expand Up @@ -1451,6 +1495,9 @@ private void OnPropertyChangedInternal(object? sender, PropertyChangedEventArgs

public ICommand OpenDatabaseTabCommand { get; private set; } = null!;

/// <summary>The double click, which opens the thing the node is.</summary>
public ICommand OpenWhatItIsCommand { get; private set; } = null!;

[Notify]
public ICommand EditDataCommand { get; private set; } = null!;

Expand Down
26 changes: 10 additions & 16 deletions Tools/OutWit.Database.Studio/Views/DatabaseExplorer.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ public DatabaseExplorer()
#region Event Handlers

/// <summary>
/// A double click opens the DATA of a table, not its structure (WS-19).
/// A double click opens the thing the node IS - a table's data rather than its structure
/// (WS-19), a view's rows, and a connection's own tab.
///
/// It used to open the structure, with the data hidden in the context menu - while looking at the
/// data is what people come to a database tool to do, by an order of magnitude. The structure is
Expand All @@ -116,10 +117,10 @@ public DatabaseExplorer()
/// application's behaviour.
/// </para>
/// </summary>
private void OpenTheDataUnderThePointer(PointerPressedEventArgs e)
private void OpenWhatIsUnderThePointer(PointerPressedEventArgs e)
{
// The chevron is a control of its own, and two clicks on it are two toggles rather than a
// request for the data.
// request for what is in the row.
if (PressedOnTheChevron(e))
return;

Expand All @@ -133,19 +134,12 @@ private void OpenTheDataUnderThePointer(PointerPressedEventArgs e)
// order the tree does its own work in.
explorer.SelectedNode = node;

switch (node.NodeType)
{
case DatabaseNodeType.Table when explorer.CanEditData:
explorer.EditDataCommand.Execute(null);
break;

case DatabaseNodeType.View when explorer.CanBrowseData:
explorer.SelectTop1000Command.Execute(null);
break;
// WHAT is opened is the ViewModel's decision, not this handler's: a gesture belongs here, a
// rule does not, and a rule written here is a rule no test can read.
if (!explorer.CanOpenWhatItIs)
return;

default:
return;
}
explorer.OpenWhatItIsCommand.Execute(null);

// The tap that follows this press will toggle the row. Remember what to put back.
m_rowToLeaveAsItWas = item;
Expand Down Expand Up @@ -213,7 +207,7 @@ private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
}

if (properties.IsLeftButtonPressed && e.ClickCount == 2)
OpenTheDataUnderThePointer(e);
OpenWhatIsUnderThePointer(e);
}

/// <summary>The row the pointer is over, if it is over one.</summary>
Expand Down
Loading