Skip to content
Open
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
145 changes: 145 additions & 0 deletions EssentialCSharp.Web.Tests/ReadingControllerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
using System.Net;
using System.Net.Http.Json;
using EssentialCSharp.Web.Controllers;

namespace EssentialCSharp.Web.Tests;

public class ReadingControllerTests : IntegrationTestBase
{

[Test]
public async Task GetBookStats_IsPublic_Returns200()
{
using HttpClient client = CreateClientWithoutAutoRedirect();
using HttpResponseMessage response = await client.GetAsync("/api/reading/book-stats");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
}

[Test]
public async Task GetBookStats_ReturnsExpectedShape()
{
using HttpClient client = CreateClientWithoutAutoRedirect();
using HttpResponseMessage response = await client.GetAsync("/api/reading/book-stats");

var body = await response.Content.ReadFromJsonAsync<BookStatsResponse>();
await Assert.That(body).IsNotNull();
await Assert.That(body!.TotalWordCount).IsGreaterThanOrEqualTo(0);
await Assert.That(body.Chapters).IsNotNull();
}

// ---- profile (requires auth) ----

[Test]
public async Task GetProfile_Anonymous_Returns401()
{
using HttpClient client = CreateClientWithoutAutoRedirect();
using HttpResponseMessage response = await client.GetAsync("/api/reading/profile");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
}

// ---- POST session (requires auth) ----

[Test]
public async Task PostSession_Anonymous_Returns401()
{
using HttpClient client = CreateClientWithoutAutoRedirect();
var intervals = new[]
{
new ReadingController.ReadingIntervalDto("page1", 60, 100, false)
};
using HttpResponseMessage response = await client.PostAsJsonAsync("/api/reading/session", intervals);
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
}

// ---- POST reset (requires auth) ----

[Test]
public async Task PostReset_Anonymous_Returns401()
{
using HttpClient client = CreateClientWithoutAutoRedirect();
using HttpResponseMessage response = await client.PostAsync("/api/reading/reset", null);
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
}

// ---- WPM algorithm unit tests (test logic directly, no HTTP) ----

[Test]
public async Task WpmAlgorithm_FastInterval_IsDiscarded()
{
// Hard discard: >900 WPM → interval dropped entirely
long discardWords = 901;
long discardSeconds = 60;
double discardWpm = discardWords / (discardSeconds / 60.0); // 901 WPM

await Assert.That(discardWpm).IsGreaterThan(900.0);

// Accepted interval: 600 WPM (below cutoff)
long acceptWords = 600;
long acceptSeconds = 60;
double acceptWpm = acceptWords / (acceptSeconds / 60.0); // 600 WPM

await Assert.That(acceptWpm).IsLessThanOrEqualTo(900.0);
}

[Test]
public async Task WpmAlgorithm_SlowInterval_IsClamped()
{
// Reference WPM = 200. An interval of 10 words in 300 seconds =
// 10 / (300/60) = 2 WPM. 0.25 * 200 = 50 WPM → interval is below threshold → clamp.
double referenceWpm = 200.0;
double slowOutlierFactor = 0.25;
double intervalWpm = 10.0 / (300.0 / 60.0); // ~2 WPM

bool shouldClamp = intervalWpm < slowOutlierFactor * referenceWpm;
await Assert.That(shouldClamp).IsTrue();

// After clamp: effectiveSeconds = words / (0.25 * referenceWpm) * 60
int words = 10;
int effectiveSeconds = (int)Math.Round(words / (slowOutlierFactor * referenceWpm) * 60.0);
double clampedWpm = words / (effectiveSeconds / 60.0);

// Clamped WPM should equal exactly 0.25 * referenceWpm (within rounding)
await Assert.That(clampedWpm).IsEqualTo(50.0).Within(1.0);
}

[Test]
public async Task WpmAlgorithm_NormalInterval_PassesThrough()
{
// 180 WPM interval with referenceWpm = 200.
// 180 > 0.25 * 200 = 50, so no clamping.
double referenceWpm = 200.0;
const double slowOutlierFactor = 0.25;
double intervalWpm = 180.0;

bool shouldClamp = intervalWpm < slowOutlierFactor * referenceWpm;
await Assert.That(shouldClamp).IsFalse();

bool shouldDiscard = intervalWpm > 900.0;
await Assert.That(shouldDiscard).IsFalse();
}

[Test]
public async Task GetBookStats_Returns200WithChapters()
{
using HttpClient client = CreateClientWithoutAutoRedirect();
using HttpResponseMessage response = await client.GetAsync("/api/reading/book-stats");
await Assert.That((int)response.StatusCode).IsEqualTo(200);

var body = await response.Content.ReadFromJsonAsync<BookStatsResponse>();
await Assert.That(body).IsNotNull();
}

// ---- DTO for deserializing book-stats response ----

private sealed class BookStatsResponse
{
public int TotalWordCount { get; set; }
public IEnumerable<ChapterInfo>? Chapters { get; set; }
}

private sealed class ChapterInfo
{
public int ChapterNumber { get; set; }
public int WordCount { get; set; }
}
}
232 changes: 232 additions & 0 deletions EssentialCSharp.Web.Tests/WordCountServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
using EssentialCSharp.Web.Services;
using Microsoft.AspNetCore.Hosting;
using Moq;

namespace EssentialCSharp.Web.Tests;

public class WordCountServiceTests
{
// ---- Helpers ----

private static (string tempDir, string filePath) WriteTempHtmlFile(string html)
{
string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
string filePath = Path.Combine(tempDir, "page.html");
File.WriteAllText(filePath, html);
return (tempDir, filePath);
}

private static SiteMapping MakeMapping(string key, string contentRoot, string relativePath, int chapter, int page, int order = 1)
{
// PagePath is relative to contentRoot as individual path segments.
string[] segments = relativePath.Split('/', '\\').Where(s => s.Length > 0).ToArray();
return new SiteMapping(
keys: [key],
primaryKey: key,
pagePath: segments,
chapterNumber: chapter,
pageNumber: page,
orderOnPage: order,
chapterTitle: $"Chapter {chapter}",
rawHeading: key,
anchorId: key,
indentLevel: 0
);
}

private static (WordCountService service, string tempDir) CreateService(IList<SiteMapping> mappings, string contentRoot)
{
Mock<ISiteMappingService> siteMappingMock = new();
siteMappingMock.Setup(s => s.SiteMappings).Returns(mappings);

Mock<IWebHostEnvironment> envMock = new();
envMock.Setup(e => e.ContentRootPath).Returns(contentRoot);
// IWebHostEnvironment also inherits IHostEnvironment, but WordCountService only uses ContentRootPath.

var service = new WordCountService(siteMappingMock.Object, envMock.Object);
return (service, contentRoot);
}

// ---- Tests ----

[Test]
public async Task GetPageWordCount_PlainProse_ReturnsCorrectCount()
{
// Arrange
const string html = "<html><body><p>Hello world this is a test.</p></body></html>";
(string tempDir, string filePath) = WriteTempHtmlFile(html);
try
{
string relativePath = Path.GetFileName(filePath);
SiteMapping mapping = MakeMapping("page1", tempDir, relativePath, 1, 1);
(WordCountService service, _) = CreateService([mapping], tempDir);

// Act
int count = service.GetPageWordCount("page1");

// Assert — "Hello world this is a test." = 6 words
await Assert.That(count).IsEqualTo(6);
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}

[Test]
public async Task GetPageWordCount_ExcludesPreAndCodeBlocks()
{
// Arrange — 3 prose words + code block that should be excluded
const string html = """
<html><body>
<p>Hello world prose.</p>
<pre>public static void Main() { var x = 1; }</pre>
<code>Console.WriteLine(x);</code>
</body></html>
""";
(string tempDir, string filePath) = WriteTempHtmlFile(html);
try
{
string relativePath = Path.GetFileName(filePath);
SiteMapping mapping = MakeMapping("page2", tempDir, relativePath, 1, 1);
(WordCountService service, _) = CreateService([mapping], tempDir);

// Act
int count = service.GetPageWordCount("page2");

// Assert — only "Hello world prose." = 3 words
await Assert.That(count).IsEqualTo(3);
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}

[Test]
public async Task GetPageWordCount_ExcludesScriptAndStyle()
{
const string html = """
<html><head><style>body { margin: 0; }</style></head>
<body>
<p>Two words.</p>
<script>var x = 1;</script>
</body></html>
""";
(string tempDir, string filePath) = WriteTempHtmlFile(html);
try
{
string relativePath = Path.GetFileName(filePath);
SiteMapping mapping = MakeMapping("page3", tempDir, relativePath, 1, 1);
(WordCountService service, _) = CreateService([mapping], tempDir);

int count = service.GetPageWordCount("page3");

// "Two words." = 2
await Assert.That(count).IsEqualTo(2);
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}

[Test]
public async Task GetPageWordCount_FileNotFound_ReturnsZero()
{
// Arrange — map a page that doesn't have a corresponding file
string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
SiteMapping mapping = MakeMapping("missing", tempDir, "nonexistent.html", 1, 1);
(WordCountService service, _) = CreateService([mapping], tempDir);

int count = service.GetPageWordCount("missing");

await Assert.That(count).IsEqualTo(0);
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}

[Test]
public async Task GetChapterWordCount_SumsAllPagesInChapter()
{
string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
// Page A: 3 words; Page B: 4 words → chapter total 7
File.WriteAllText(Path.Combine(tempDir, "a.html"), "<html><body><p>one two three</p></body></html>");
File.WriteAllText(Path.Combine(tempDir, "b.html"), "<html><body><p>four five six seven</p></body></html>");

SiteMapping[] mappings =
[
MakeMapping("a", tempDir, "a.html", 1, 1),
MakeMapping("b", tempDir, "b.html", 1, 2),
];
(WordCountService service, _) = CreateService(mappings, tempDir);

await Assert.That(service.GetChapterWordCount(1)).IsEqualTo(7);
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}

[Test]
public async Task GetBookWordCount_SumsAllChapters()
{
string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
File.WriteAllText(Path.Combine(tempDir, "ch1.html"), "<html><body><p>alpha beta</p></body></html>"); // 2 words
File.WriteAllText(Path.Combine(tempDir, "ch2.html"), "<html><body><p>gamma delta epsilon</p></body></html>"); // 3 words

SiteMapping[] mappings =
[
MakeMapping("ch1", tempDir, "ch1.html", 1, 1),
MakeMapping("ch2", tempDir, "ch2.html", 2, 1),
];
(WordCountService service, _) = CreateService(mappings, tempDir);

await Assert.That(service.GetBookWordCount()).IsEqualTo(5);
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}

[Test]
public async Task GetWordsBeforePage_ReturnsCorrectPrefixSum()
{
string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
File.WriteAllText(Path.Combine(tempDir, "p1.html"), "<html><body><p>one two</p></body></html>"); // 2 words
File.WriteAllText(Path.Combine(tempDir, "p2.html"), "<html><body><p>three</p></body></html>"); // 1 word

SiteMapping[] mappings =
[
MakeMapping("p1", tempDir, "p1.html", 1, 1),
MakeMapping("p2", tempDir, "p2.html", 1, 2),
];
(WordCountService service, _) = CreateService(mappings, tempDir);

await Assert.That(service.GetWordsBeforePage("p1")).IsEqualTo(0); // first page
await Assert.That(service.GetWordsBeforePage("p2")).IsEqualTo(2); // 2 words from p1
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
}
Loading