Skip to content

Commit 09acadd

Browse files
xenobiasoftclaude
andcommitted
Implement new card-based home page with Select Difficulty and Game List pages (#214)
Closes #214 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent bd91232 commit 09acadd

27 files changed

Lines changed: 1244 additions & 948 deletions
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
using Microsoft.AspNetCore.Components;
2+
using Microsoft.Extensions.DependencyInjection;
3+
using Microsoft.Extensions.Logging;
4+
using Sudoku.Blazor.Models;
5+
using Sudoku.Blazor.Services.Abstractions;
6+
using GameListPage = Sudoku.Blazor.Components.Pages.GameList;
7+
8+
namespace UnitTests.Blazor.Pages;
9+
10+
public class GameListPageTests : BunitContext
11+
{
12+
private readonly Mock<ILocalStorageService> _mockLocalStorage = new();
13+
private readonly Mock<IGameManager> _mockGameManager = new();
14+
private const string Alias = "test-alias";
15+
16+
public GameListPageTests()
17+
{
18+
Services.AddSingleton(_mockLocalStorage.Object);
19+
Services.AddSingleton(_mockGameManager.Object);
20+
Services.AddSingleton(new Mock<ILogger<GameListPage>>().Object);
21+
}
22+
23+
private void SetupReturningPlayer()
24+
{
25+
var profile = new ProfileInfo { ProfileId = Guid.NewGuid().ToString(), Alias = Alias };
26+
_mockLocalStorage.Setup(x => x.GetProfileAsync()).ReturnsAsync(profile);
27+
}
28+
29+
private void SetupNewPlayer()
30+
{
31+
_mockLocalStorage.Setup(x => x.GetProfileAsync()).ReturnsAsync((ProfileInfo?)null);
32+
}
33+
34+
[Fact]
35+
public void Render_ReturningPlayer_WithGames_ShowsGameThumbnails()
36+
{
37+
SetupReturningPlayer();
38+
var games = new List<GameModel>
39+
{
40+
new() { Id = Guid.NewGuid().ToString(), PlayerAlias = Alias },
41+
new() { Id = Guid.NewGuid().ToString(), PlayerAlias = Alias },
42+
};
43+
_mockGameManager.SetupLoadGamesAsync(games);
44+
45+
var component = Render<GameListPage>();
46+
47+
component.FindAll(".del-game-icon").Count.Should().Be(2);
48+
}
49+
50+
[Fact]
51+
public void Render_ReturningPlayer_NoGames_ShowsEmptyState()
52+
{
53+
SetupReturningPlayer();
54+
_mockGameManager.SetupLoadGamesAsync([]);
55+
56+
var component = Render<GameListPage>();
57+
58+
component.Markup.Should().Contain("No saved games yet");
59+
}
60+
61+
[Fact]
62+
public void DeleteGame_RemovesGameFromList()
63+
{
64+
SetupReturningPlayer();
65+
var games = new List<GameModel>
66+
{
67+
new() { Id = Guid.NewGuid().ToString(), PlayerAlias = Alias },
68+
new() { Id = Guid.NewGuid().ToString(), PlayerAlias = Alias },
69+
};
70+
_mockGameManager.SetupLoadGamesAsync(games);
71+
72+
var component = Render<GameListPage>();
73+
component.Find(".del-game-icon").Click();
74+
75+
component.FindAll(".del-game-icon").Count.Should().Be(1);
76+
}
77+
78+
[Fact]
79+
public void DeleteGame_CallsGameManagerDelete()
80+
{
81+
SetupReturningPlayer();
82+
var games = new List<GameModel>
83+
{
84+
new() { Id = Guid.NewGuid().ToString(), PlayerAlias = Alias },
85+
};
86+
_mockGameManager.SetupLoadGamesAsync(games);
87+
88+
var component = Render<GameListPage>();
89+
component.Find(".del-game-icon").Click();
90+
91+
_mockGameManager.VerifyDeleteGameAsyncCalled(Times.Once);
92+
}
93+
94+
[Fact]
95+
public void NewPlayer_RedirectsToHome()
96+
{
97+
SetupNewPlayer();
98+
var navMan = Services.GetRequiredService<NavigationManager>();
99+
Render<GameListPage>();
100+
navMan.Uri.Should().EndWith("/");
101+
}
102+
103+
[Fact]
104+
public void ClickBack_NavigatesToHome()
105+
{
106+
SetupReturningPlayer();
107+
_mockGameManager.SetupLoadGamesAsync([]);
108+
var navMan = Services.GetRequiredService<NavigationManager>();
109+
var component = Render<GameListPage>();
110+
component.Find("button:contains('← Back')").Click();
111+
navMan.Uri.Should().EndWith("/");
112+
}
113+
}
Lines changed: 75 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using Microsoft.AspNetCore.Hosting;
1+
using Microsoft.AspNetCore.Hosting;
22
using Microsoft.Extensions.DependencyInjection;
33
using Microsoft.Extensions.Logging;
44
using Sudoku.Blazor.Models;
@@ -9,117 +9,120 @@ namespace UnitTests.Blazor.Pages;
99

1010
public class IndexPageTests : BunitContext
1111
{
12-
private const string Alias = "test-alias";
13-
private readonly Mock<IGameManager> _mockGameManager = new();
14-
private readonly Mock<IPlayerManager> _mockPlayerManager = new();
12+
private readonly Mock<ILocalStorageService> _mockLocalStorage = new();
1513

1614
public IndexPageTests()
1715
{
18-
var savedGames = new List<GameModel>
19-
{
20-
new() { Id = Guid.NewGuid().ToString(), PlayerAlias = Alias},
21-
new() { Id = Guid.NewGuid().ToString(), PlayerAlias = Alias},
22-
};
23-
_mockGameManager.SetupLoadGamesAsync(savedGames);
24-
_mockPlayerManager.SetupGetCurrentPlayerAsync(Alias);
25-
Services.AddSingleton(_mockGameManager.Object);
26-
Services.AddSingleton(_mockPlayerManager.Object);
27-
28-
// Add IWebHostEnvironment mock for error boundary
16+
Services.AddSingleton(_mockLocalStorage.Object);
17+
2918
var mockWebHostEnvironment = new Mock<IWebHostEnvironment>();
3019
mockWebHostEnvironment.Setup(x => x.EnvironmentName).Returns("Test");
3120
Services.AddSingleton(mockWebHostEnvironment.Object);
32-
33-
// Add logger mocks
21+
3422
Services.AddSingleton(new Mock<ILogger<IndexPage>>().Object);
3523
Services.AddSingleton(new Mock<ILogger<Sudoku.Blazor.Components.IndexErrorBoundary>>().Object);
3624
}
3725

38-
[Fact]
39-
public void DeleteGame_RemovesGameFromList()
26+
private void SetupReturningPlayer(string alias = "test-alias")
4027
{
41-
// Arrange
42-
var component = Render<IndexPage>();
43-
var delGameElement = component.Find(".del-game-icon");
28+
var profile = new ProfileInfo { ProfileId = Guid.NewGuid().ToString(), Alias = alias };
29+
_mockLocalStorage.Setup(x => x.GetProfileAsync()).ReturnsAsync(profile);
30+
_mockLocalStorage.Setup(x => x.GetAliasAsync()).ReturnsAsync((string?)null);
31+
}
4432

45-
// Act
46-
delGameElement.Click();
33+
private void SetupNewPlayer()
34+
{
35+
_mockLocalStorage.Setup(x => x.GetProfileAsync()).ReturnsAsync((ProfileInfo?)null);
36+
_mockLocalStorage.Setup(x => x.GetAliasAsync()).ReturnsAsync((string?)null);
37+
}
4738

48-
// Assert
49-
component.FindAll(".del-game-icon").Count.Should().Be(1);
39+
private void SetupLegacyPlayer(string alias)
40+
{
41+
_mockLocalStorage.Setup(x => x.GetProfileAsync()).ReturnsAsync((ProfileInfo?)null);
42+
_mockLocalStorage.Setup(x => x.GetAliasAsync()).ReturnsAsync(alias);
43+
_mockLocalStorage.Setup(x => x.SetProfileAsync(It.IsAny<ProfileInfo>())).Returns(Task.CompletedTask);
44+
_mockLocalStorage.Setup(x => x.RemoveAliasAsync()).Returns(Task.CompletedTask);
5045
}
5146

5247
[Fact]
53-
public void DeleteGame_WhenClicked_RemovesGameFromGameStateManager()
48+
public void Render_NewPlayer_ShowsCreateProfileCard()
5449
{
55-
// Arrange
50+
SetupNewPlayer();
5651
var component = Render<IndexPage>();
57-
var delGameElement = component.Find(".del-game-icon");
58-
59-
// Act
60-
delGameElement.Click();
61-
62-
// Assert
63-
_mockGameManager.VerifyDeleteGameAsyncCalled(Times.Once);
52+
var profileButton = component.Find("button:contains('Create Profile')");
53+
Assert.NotNull(profileButton);
6454
}
6555

6656
[Fact]
67-
public void Render_WhenSavedGamesPresent_DisplaysEachSavedGame()
57+
public void Render_ReturningPlayer_ShowsManageProfileCard()
6858
{
69-
// Arrange
59+
SetupReturningPlayer();
7060
var component = Render<IndexPage>();
71-
72-
// Act
73-
var delGameElements = component.FindAll(".del-game-icon");
74-
75-
// Assert
76-
delGameElements.Count.Should().Be(2);
61+
var profileButton = component.Find("button:contains('Manage Profile')");
62+
Assert.NotNull(profileButton);
7763
}
7864

7965
[Fact]
80-
public void RendersCorrectly()
66+
public void Render_NewPlayer_StartNewGameIsDisabled()
8167
{
82-
// Arrange
68+
SetupNewPlayer();
8369
var component = Render<IndexPage>();
84-
85-
// Act
86-
var startNewGameButton = component.Find("button:contains('Start New Game')");
87-
var loadGameButton = component.Find("button:contains('Load Game')");
88-
89-
// Assert
90-
Assert.NotNull(startNewGameButton);
91-
Assert.NotNull(loadGameButton);
70+
var startBtn = component.Find("button:contains('Start New Game')");
71+
Assert.True(startBtn.HasAttribute("disabled"));
9272
}
9373

9474
[Fact]
95-
public void ShowsDifficultyOptions_WhenStartNewGameClicked()
75+
public void Render_NewPlayer_BrowseGameListIsDisabled()
9676
{
97-
// Arrange
77+
SetupNewPlayer();
9878
var component = Render<IndexPage>();
79+
var browseBtn = component.Find("button:contains('Browse Game List')");
80+
Assert.True(browseBtn.HasAttribute("disabled"));
81+
}
9982

100-
// Act
101-
component.Find("button:contains('Start New Game')").Click();
102-
var difficultyButtons = component.FindAll("button:contains('Easy'), button:contains('Medium'), button:contains('Hard')");
83+
[Fact]
84+
public void Render_ReturningPlayer_StartNewGameIsEnabled()
85+
{
86+
SetupReturningPlayer();
87+
var component = Render<IndexPage>();
88+
var startBtn = component.Find("button:contains('Start New Game')");
89+
Assert.False(startBtn.HasAttribute("disabled"));
90+
}
10391

104-
// Assert
105-
Assert.Equal(3, difficultyButtons.Count);
92+
[Fact]
93+
public void Render_ReturningPlayer_BrowseGameListIsEnabled()
94+
{
95+
SetupReturningPlayer();
96+
var component = Render<IndexPage>();
97+
var browseBtn = component.Find("button:contains('Browse Game List')");
98+
Assert.False(browseBtn.HasAttribute("disabled"));
10699
}
107100

108101
[Fact]
109-
public void ShowsSavedGames_WhenLoadGameClicked()
102+
public void Render_NewPlayer_ShowsHelperTextOnDisabledCards()
110103
{
111-
// Arrange
112-
var savedGames = new List<GameModel>
113-
{
114-
};
115-
_mockGameManager.SetupLoadGamesAsync(savedGames);
104+
SetupNewPlayer();
116105
var component = Render<IndexPage>();
106+
var helperTexts = component.FindAll(".helper-text");
107+
helperTexts.Count.Should().Be(2);
108+
}
117109

118-
// Act
119-
component.Find("button:contains('Load Game')").Click();
110+
[Fact]
111+
public void Render_DoesNotCallBackendApi()
112+
{
113+
SetupNewPlayer();
114+
Render<IndexPage>();
115+
// LocalStorageService is the only service injected — no IPlayerApiClient or IGameManager
116+
// This test verifies no backend dependency is needed
117+
_mockLocalStorage.Verify(x => x.GetProfileAsync(), Times.AtLeastOnce);
118+
}
120119

121-
// Assert
122-
var savedGameButtons = component.FindAll(".saved-game-card");
123-
savedGameButtons.Count.Should().Be(savedGames.Count);
120+
[Fact]
121+
public void LegacyMigration_WritesProfileAndRemovesAlias()
122+
{
123+
SetupLegacyPlayer("old-alias");
124+
Render<IndexPage>();
125+
_mockLocalStorage.Verify(x => x.SetProfileAsync(It.Is<ProfileInfo>(p => p.Alias == "old-alias")), Times.Once);
126+
_mockLocalStorage.Verify(x => x.RemoveAliasAsync(), Times.Once);
124127
}
125-
}
128+
}

0 commit comments

Comments
 (0)