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
40 changes: 40 additions & 0 deletions BusLane.Tests/Models/PinnedEntityTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
namespace BusLane.Tests.Models;

using BusLane.Models;
using FluentAssertions;

public class PinnedEntityTests
{
[Fact]
public void Constructor_SubscriptionWithoutTopicName_Throws()
{
// Act
var act = () => new PinnedEntity("workspace-a", PinnedEntityType.Subscription, "processor", null);

// Assert
act.Should().Throw<ArgumentException>()
.WithMessage("*TopicName*");
}

[Fact]
public void Constructor_QueueWithTopicName_Throws()
{
// Act
var act = () => new PinnedEntity("workspace-a", PinnedEntityType.Queue, "orders", "topic-a");

// Assert
act.Should().Throw<ArgumentException>()
.WithMessage("*TopicName*");
}

[Fact]
public void Constructor_TopicWithTopicName_Throws()
{
// Act
var act = () => new PinnedEntity("workspace-a", PinnedEntityType.Topic, "orders-topic", "topic-a");

// Assert
act.Should().Throw<ArgumentException>()
.WithMessage("*TopicName*");
}
}
20 changes: 20 additions & 0 deletions BusLane.Tests/Services/Infrastructure/PreferencesServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,4 +111,24 @@ public void SaveAndReload_ShouldRoundTripTerminalPreferences()
reloaded.TerminalDockHeight.Should().Be(320);
reloaded.TerminalWindowBoundsJson.Should().Be("{\"X\":120,\"Y\":140,\"Width\":900,\"Height\":420}");
}

[Fact]
public void SaveAndReload_ShouldRoundTripPinnedEntitiesJson()
{
// Arrange
var sut = new PreferencesService
{
PinnedEntitiesJson = """
[{"WorkspaceId":"workspace-a","Type":"Queue","Name":"orders","TopicName":null}]
"""
};

// Act
sut.Save();
var reloaded = new PreferencesService();

// Assert
reloaded.PinnedEntitiesJson.Should().Contain("workspace-a");
reloaded.PinnedEntitiesJson.Should().Contain("orders");
}
}
34 changes: 34 additions & 0 deletions BusLane.Tests/ViewModels/Core/ConnectionTabViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,40 @@ public async Task ConnectWithConnectionStringAsync_SetsConnectionState()
tab.SavedConnection.Should().Be(connection);
}

[Fact]
public async Task ConnectWithConnectionStringAsync_LoadsPinsAfterEntityLoad()
{
// Arrange
var preferencesService = Substitute.For<IPreferencesService>();
preferencesService.PinnedEntitiesJson.Returns("""
[{"WorkspaceId":"conn-1","Type":"Queue","Name":"orders","TopicName":null}]
""");
var logSink = CreateMockLogSink();
var operationsFactory = Substitute.For<IServiceBusOperationsFactory>();
var operations = Substitute.For<IConnectionStringOperations>();
var queue = new QueueInfo("orders", 1, 1, 0, 0, 1, null, false, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));

operationsFactory.CreateFromConnectionString(Arg.Any<string>()).Returns(operations);
operations.GetQueuesAsync().Returns([queue]);
operations.GetTopicsAsync().Returns([]);

var connection = new SavedConnection
{
Id = "conn-1",
Name = "Test Connection",
ConnectionString = "Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=test",
Type = ConnectionType.Namespace
};

var tab = new ConnectionTabViewModel("test-id", "Test Tab", "test.servicebus.windows.net", preferencesService, logSink);

// Act
await tab.ConnectWithConnectionStringAsync(connection, operationsFactory);

// Assert
tab.Navigation.IsPinned(queue).Should().BeTrue();
}

[Fact]
public async Task DisconnectAsync_ClearsConnectionState()
{
Expand Down
200 changes: 200 additions & 0 deletions BusLane.Tests/ViewModels/Core/NavigationStatePinningTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
namespace BusLane.Tests.ViewModels.Core;

using System.Collections.ObjectModel;
using BusLane.Models;
using BusLane.Services.Abstractions;
using BusLane.ViewModels.Core;
using FluentAssertions;

public class NavigationStatePinningTests
{
[Fact]
public void TogglePin_Queue_AddsScopedPinAndPersistsJson()
{
// Arrange
var preferences = new TestPreferencesService();
var sut = new NavigationState(preferences);
sut.SetPinScope("workspace-a");
var queue = CreateQueue("orders");

// Act
sut.TogglePin(queue);

// Assert
sut.IsPinned(queue).Should().BeTrue();
sut.PinnedEntities.Should().ContainSingle(pin =>
pin.WorkspaceId == "workspace-a" &&
pin.Type == PinnedEntityType.Queue &&
pin.Name == "orders");
preferences.PinnedEntitiesJson.Should().Contain("orders");
preferences.SaveCount.Should().Be(1);
}

[Fact]
public void TogglePin_PinnedQueue_RemovesScopedPinAndPersistsJson()
{
// Arrange
var preferences = new TestPreferencesService
{
PinnedEntitiesJson = """
[{"WorkspaceId":"workspace-a","Type":"Queue","Name":"orders","TopicName":null}]
"""
};
var sut = new NavigationState(preferences);
sut.SetPinScope("workspace-a");
var queue = CreateQueue("orders");

// Act
sut.TogglePin(queue);

// Assert
sut.IsPinned(queue).Should().BeFalse();
sut.PinnedEntities.Should().BeEmpty();
preferences.PinnedEntitiesJson.Should().Be("[]");
preferences.SaveCount.Should().Be(1);
}

[Fact]
public void FilteredQueues_PutsPinnedQueuesFirst()
{
// Arrange
var preferences = new TestPreferencesService();
var sut = new NavigationState(preferences);
sut.SetPinScope("workspace-a");
var alpha = CreateQueue("alpha");
var beta = CreateQueue("beta");
sut.Queues.Add(alpha);
sut.Queues.Add(beta);
sut.TogglePin(beta);

// Act
var result = sut.FilteredQueues.Select(queue => queue.Name);

// Assert
result.Should().Equal("beta", "alpha");
}

[Fact]
public void SetPinScope_LoadsOnlyPinsForCurrentWorkspace()
{
// Arrange
var preferences = new TestPreferencesService
{
PinnedEntitiesJson = """
[
{"WorkspaceId":"workspace-a","Type":"Queue","Name":"orders","TopicName":null},
{"WorkspaceId":"workspace-b","Type":"Queue","Name":"billing","TopicName":null}
]
"""
};
var sut = new NavigationState(preferences);

// Act
sut.SetPinScope("workspace-b");

// Assert
sut.PinnedEntities.Should().ContainSingle(pin => pin.Name == "billing");
}

[Fact]
public void TogglePin_Subscription_UsesTopicNameInIdentity()
{
// Arrange
var preferences = new TestPreferencesService();
var sut = new NavigationState(preferences);
sut.SetPinScope("workspace-a");
var subscription = new SubscriptionInfo("processor", "orders-topic", 1, 1, 0, null, false);

// Act
sut.TogglePin(subscription);

// Assert
sut.IsPinned(subscription).Should().BeTrue();
sut.PinnedEntities.Should().ContainSingle(pin =>
pin.Type == PinnedEntityType.Subscription &&
pin.TopicName == "orders-topic" &&
pin.Name == "processor");
}

[Fact]
public void TogglePin_WhenSaveFails_RollsBackInMemoryPinState()
{
// Arrange
var preferences = new TestPreferencesService { ThrowOnSave = true };
var sut = new NavigationState(preferences);
sut.SetPinScope("workspace-a");
var queue = CreateQueue("orders");

// Act
var act = () => sut.TogglePin(queue);
act.Should().Throw<InvalidOperationException>();
sut.SetPinScope("workspace-a");

// Assert
sut.IsPinned(queue).Should().BeFalse();
sut.PinnedEntities.Should().BeEmpty();
preferences.PinnedEntitiesJson.Should().Be("[]");
}

[Fact]
public void PinnedEntities_ShouldExposeReadOnlyCollection()
{
// Arrange
var sut = new NavigationState(new TestPreferencesService());

// Assert
sut.PinnedEntities.Should().BeAssignableTo<ReadOnlyObservableCollection<PinnedEntity>>();
}

private static QueueInfo CreateQueue(string name) =>
new(name, 1, 1, 0, 0, 1, null, false, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));

private sealed class TestPreferencesService : IPreferencesService
{
public bool ConfirmBeforeDelete { get; set; } = true;
public bool ConfirmBeforePurge { get; set; } = true;
public bool AutoRefreshMessages { get; set; }
public int AutoRefreshIntervalSeconds { get; set; } = 30;
public int DefaultMessageCount { get; set; } = 100;
public int MessagesPerPage { get; set; } = 100;
public int MaxTotalMessages { get; set; } = 500;
public bool ShowDeadLetterBadges { get; set; } = true;
public bool EnableMessagePreview { get; set; } = true;
public bool ShowNavigationPanel { get; set; } = true;
public bool ShowTerminalPanel { get; set; }
public bool TerminalIsDocked { get; set; } = true;
public double TerminalDockHeight { get; set; } = 260;
public string? TerminalWindowBoundsJson { get; set; }
public string Theme { get; set; } = "System";
public int LiveStreamPollingIntervalSeconds { get; set; } = 1;
public bool RestoreTabsOnStartup { get; set; } = true;
public string OpenTabsJson { get; set; } = "[]";
public string PinnedEntitiesJson { get; set; } = "[]";
public bool EnableTelemetry { get; set; }
public bool AutoCheckForUpdates { get; set; } = true;
public string? SkippedUpdateVersion { get; set; }
public DateTime? UpdateRemindLaterDate { get; set; }
public int SaveCount { get; private set; }
public bool ThrowOnSave { get; init; }

public event EventHandler? PreferencesChanged
{
add { }
remove { }
}

public void Save()
{
if (ThrowOnSave)
{
throw new InvalidOperationException("Save failed");
}

SaveCount++;
}

public void Load()
{
}
}
}
1 change: 1 addition & 0 deletions BusLane.Tests/ViewModels/MainWindowViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -813,6 +813,7 @@ private sealed class TestPreferencesService : IPreferencesService
public int LiveStreamPollingIntervalSeconds { get; set; } = 1;
public bool RestoreTabsOnStartup { get; set; } = true;
public string OpenTabsJson { get; set; } = "[]";
public string PinnedEntitiesJson { get; set; } = "[]";
public bool EnableTelemetry { get; set; }
public bool AutoCheckForUpdates { get; set; } = true;
public string? SkippedUpdateVersion { get; set; }
Expand Down
16 changes: 16 additions & 0 deletions BusLane.Tests/Views/EntityTreeViewTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,22 @@ public void EntityTreeViews_UseSharedSearchSurfaceClass()
File.ReadAllText(GetAzureTreePath()).Should().Contain("Classes=\"pane-search-surface\"");
}

[Fact]
public void EntityTreeViews_ExposePinningControls()
{
// Arrange
var connectionTree = File.ReadAllText(GetConnectionTreePath());
var azureTree = File.ReadAllText(GetAzureTreePath());

// Assert
connectionTree.Should().Contain("ToggleSelectedEntityPinCommand");
connectionTree.Should().Contain("CurrentNavigation.PinnedEntities");
connectionTree.Should().Contain("SelectPinnedEntityCommand");
azureTree.Should().Contain("ToggleSelectedEntityPinCommand");
azureTree.Should().Contain("CurrentNavigation.PinnedEntities");
azureTree.Should().Contain("SelectPinnedEntityCommand");
}

private static string GetStylesPath()
{
return Path.GetFullPath(Path.Combine(
Expand Down
53 changes: 53 additions & 0 deletions BusLane/Models/PinnedEntity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
namespace BusLane.Models;

using System.Text.Json.Serialization;

/// <summary>
/// Identifies a pinned Service Bus entity within a saved workspace.
/// </summary>
public record PinnedEntity
{
public PinnedEntity(
string workspaceId,
PinnedEntityType type,
string name,
string? topicName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(workspaceId);
ArgumentException.ThrowIfNullOrWhiteSpace(name);

if (type == PinnedEntityType.Subscription && string.IsNullOrWhiteSpace(topicName))
{
throw new ArgumentException("TopicName is required for subscription pins.", nameof(topicName));
}

if (type != PinnedEntityType.Subscription && !string.IsNullOrWhiteSpace(topicName))
{
throw new ArgumentException("TopicName is only valid for subscription pins.", nameof(topicName));
}

WorkspaceId = workspaceId;
Type = type;
Name = name;
TopicName = topicName;
}

public string WorkspaceId { get; init; }
public PinnedEntityType Type { get; init; }
public string Name { get; init; }
public string? TopicName { get; init; }

public string DisplayName => Type == PinnedEntityType.Subscription && !string.IsNullOrWhiteSpace(TopicName)
? $"{TopicName}/{Name}"
: Name;

public string TypeLabel => Type.ToString();
}

[JsonConverter(typeof(JsonStringEnumConverter))]
public enum PinnedEntityType
{
Queue,
Topic,
Subscription
}
Loading
Loading