diff --git a/source/Demo/Lexicala.NET.Demo.Api/Game/GameServiceHelpers.cs b/source/Demo/Lexicala.NET.Demo.Api/Game/GameServiceHelpers.cs new file mode 100644 index 0000000..6c21a5f --- /dev/null +++ b/source/Demo/Lexicala.NET.Demo.Api/Game/GameServiceHelpers.cs @@ -0,0 +1,22 @@ +using Lexicala.NET.Response; + +namespace Lexicala.NET.Demo.Api.Game; + +internal static class GameServiceHelpers +{ + internal static RateLimitDebugResponse? BuildRateLimit(ResponseMetadata? metadata) + { + var limits = metadata?.RateLimits; + if (limits is null) + { + return null; + } + + if (limits.Limit < 0 || limits.LimitRemaining < 0 || limits.Reset < 0) + { + return null; + } + + return new RateLimitDebugResponse(limits.Limit, limits.LimitRemaining, limits.Reset); + } +} diff --git a/source/Demo/Lexicala.NET.Demo.Api/Game/ITranslationQuizGameService.cs b/source/Demo/Lexicala.NET.Demo.Api/Game/ITranslationQuizGameService.cs new file mode 100644 index 0000000..43aa4b7 --- /dev/null +++ b/source/Demo/Lexicala.NET.Demo.Api/Game/ITranslationQuizGameService.cs @@ -0,0 +1,14 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Lexicala.NET.Demo.Api.Game; + +public interface ITranslationQuizGameService +{ + Task CreateRoundAsync(string? targetLanguage = null, CancellationToken cancellationToken = default); + + Task SubmitAnswerAsync(Guid roundId, string choice, CancellationToken cancellationToken = default); + + Task ExpireRoundAsync(Guid roundId, CancellationToken cancellationToken = default); +} diff --git a/source/Demo/Lexicala.NET.Demo.Api/Game/SenseSprintGameService.cs b/source/Demo/Lexicala.NET.Demo.Api/Game/SenseSprintGameService.cs index a2915e3..37cf782 100644 --- a/source/Demo/Lexicala.NET.Demo.Api/Game/SenseSprintGameService.cs +++ b/source/Demo/Lexicala.NET.Demo.Api/Game/SenseSprintGameService.cs @@ -157,19 +157,31 @@ public Task SubmitGuessAsync(Guid roundId, string guess, Cancella public Task GiveUpAsync(Guid roundId, CancellationToken cancellationToken = default) { var round = GetRequiredRound(roundId); - EnsureRoundIsActive(roundId, round); + + if (round.IsCompleted) + { + return Task.FromResult(new GuessResponse( + roundId, + false, + "completed", + 0, + round.CurrentClueIndex, + round.Answer, + "Round already completed. Start a new round.")); + } round.IsCompleted = true; _cache.Set(roundId, round, round.ExpiresAtUtc); + var isExpired = DateTimeOffset.UtcNow > round.ExpiresAtUtc; return Task.FromResult(new GuessResponse( roundId, false, - "lost", + isExpired ? "expired" : "lost", 0, round.CurrentClueIndex, round.Answer, - "Round ended. Better luck next time.")); + isExpired ? "Time's up! The answer was revealed." : "Round ended. Better luck next time.")); } private async Task TryGenerateRoundAsync(CancellationToken cancellationToken) @@ -208,26 +220,10 @@ public Task GiveUpAsync(Guid roundId, CancellationToken cancellat CurrentClueIndex = 0, IsCompleted = false, ExpiresAtUtc = DateTimeOffset.UtcNow.AddSeconds(RoundSeconds), - RateLimit = BuildRateLimit(entry.Metadata) ?? BuildRateLimit(fluky.Metadata) + RateLimit = GameServiceHelpers.BuildRateLimit(entry.Metadata) ?? GameServiceHelpers.BuildRateLimit(fluky.Metadata) }; } - private static RateLimitDebugResponse? BuildRateLimit(ResponseMetadata? metadata) - { - var limits = metadata?.RateLimits; - if (limits is null) - { - return null; - } - - if (limits.Limit < 0 || limits.LimitRemaining < 0 || limits.Reset < 0) - { - return null; - } - - return new RateLimitDebugResponse(limits.Limit, limits.LimitRemaining, limits.Reset); - } - private static List BuildClues(Entry entry, Sense sense, string answer) { var clues = new List(capacity: 4) diff --git a/source/Demo/Lexicala.NET.Demo.Api/Game/TranslationQuizContracts.cs b/source/Demo/Lexicala.NET.Demo.Api/Game/TranslationQuizContracts.cs new file mode 100644 index 0000000..3503070 --- /dev/null +++ b/source/Demo/Lexicala.NET.Demo.Api/Game/TranslationQuizContracts.cs @@ -0,0 +1,25 @@ +using System; + +namespace Lexicala.NET.Demo.Api.Game; + +public sealed record CreateQuizRoundResponse( + Guid RoundId, + string SourceWord, + string SourceLanguage, + string TargetLanguage, + string[] Choices, + DateTimeOffset ExpiresAtUtc, + int RoundSeconds, + RateLimitDebugResponse? RateLimit +); + +public sealed record QuizAnswerRequest(string Choice); + +public sealed record QuizAnswerResponse( + Guid RoundId, + bool IsCorrect, + string RoundStatus, + int AwardedPoints, + string CorrectAnswer, + string? Message +); diff --git a/source/Demo/Lexicala.NET.Demo.Api/Game/TranslationQuizGameService.cs b/source/Demo/Lexicala.NET.Demo.Api/Game/TranslationQuizGameService.cs new file mode 100644 index 0000000..366e77a --- /dev/null +++ b/source/Demo/Lexicala.NET.Demo.Api/Game/TranslationQuizGameService.cs @@ -0,0 +1,330 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Lexicala.NET.Request; +using Lexicala.NET.Response.Entries; +using Lexicala.NET.Response.Search; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; + +namespace Lexicala.NET.Demo.Api.Game; + +public sealed class TranslationQuizGameService : ITranslationQuizGameService +{ + private const string LanguagesCacheKey = "translation-quiz-languages"; + private const int MaxGenerationAttempts = 8; + private const int QuizRoundSeconds = 30; + private const int ChoiceCount = 4; + // Keeps rounds accessible for a short window after the game timer elapses so + // that /expire and late /answer calls succeed even when the client sends the + // request a few seconds after the server-side deadline. + private static readonly TimeSpan RoundCacheGracePeriod = TimeSpan.FromMinutes(5); + private static readonly TimeSpan ExpireEarlyTolerance = TimeSpan.FromSeconds(3); + + private readonly ILexicalaClient _lexicalaClient; + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + + public TranslationQuizGameService( + ILexicalaClient lexicalaClient, + IMemoryCache cache, + ILogger logger) + { + _lexicalaClient = lexicalaClient; + _cache = cache; + _logger = logger; + } + + public async Task CreateRoundAsync(string? targetLanguage = null, CancellationToken cancellationToken = default) + { + var availableLanguages = await GetTargetLanguagesAsync(cancellationToken); + + string resolvedLanguage; + if (string.IsNullOrWhiteSpace(targetLanguage)) + { + if (availableLanguages.Length == 0) + { + throw new InvalidOperationException("No target languages are available. Try again later."); + } + + resolvedLanguage = availableLanguages[Random.Shared.Next(availableLanguages.Length)]; + } + else + { + resolvedLanguage = targetLanguage.Trim().ToLowerInvariant(); + } + + for (var attempt = 1; attempt <= MaxGenerationAttempts; attempt++) + { + var generated = await TryGenerateRoundAsync(resolvedLanguage, cancellationToken); + if (generated is null) + { + _logger.LogDebug("Translation Quiz generation attempt {Attempt} failed quality filters", attempt); + continue; + } + + var roundId = Guid.NewGuid(); + _cache.Set(roundId, generated, generated.ExpiresAtUtc + RoundCacheGracePeriod); + + return new CreateQuizRoundResponse( + roundId, + generated.SourceWord, + "en", + resolvedLanguage, + generated.Choices, + generated.ExpiresAtUtc, + QuizRoundSeconds, + generated.RateLimit); + } + + throw new InvalidOperationException("Could not generate a playable round. Try again."); + } + + public Task SubmitAnswerAsync(Guid roundId, string choice, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(choice); + + var round = GetRequiredRound(roundId); + + if (round.IsCompleted) + { + return Task.FromResult(new QuizAnswerResponse( + roundId, + false, + "completed", + 0, + round.CorrectAnswer, + "Round already completed. Start a new round.")); + } + + round.IsCompleted = true; + _cache.Set(roundId, round, round.ExpiresAtUtc + RoundCacheGracePeriod); + + var isExpired = DateTimeOffset.UtcNow > round.ExpiresAtUtc; + if (isExpired) + { + return Task.FromResult(new QuizAnswerResponse( + roundId, + false, + "expired", + 0, + round.CorrectAnswer, + "Time's up! Start a new round.")); + } + + var isCorrect = string.Equals(round.CorrectAnswer, choice.Trim(), StringComparison.OrdinalIgnoreCase); + if (isCorrect) + { + return Task.FromResult(new QuizAnswerResponse( + roundId, + true, + "won", + 1, + round.CorrectAnswer, + "Correct!")); + } + + return Task.FromResult(new QuizAnswerResponse( + roundId, + false, + "lost", + 0, + round.CorrectAnswer, + $"Incorrect. The correct translation was: {round.CorrectAnswer}")); + } + + public Task ExpireRoundAsync(Guid roundId, CancellationToken cancellationToken = default) + { + var round = GetRequiredRound(roundId); + + if (round.IsCompleted) + { + return Task.FromResult(new QuizAnswerResponse( + roundId, + false, + "completed", + 0, + round.CorrectAnswer, + "Round already completed. Start a new round.")); + } + + round.IsCompleted = true; + _cache.Set(roundId, round, round.ExpiresAtUtc + RoundCacheGracePeriod); + + // Allow a small tolerance for client-side timer rounding; reject if called well before expiry + var isNearOrPastExpiry = DateTimeOffset.UtcNow >= round.ExpiresAtUtc - ExpireEarlyTolerance; + if (!isNearOrPastExpiry) + { + return Task.FromResult(new QuizAnswerResponse( + roundId, + false, + "lost", + 0, + round.CorrectAnswer, + "Round expiry requested before the timer has elapsed.")); + } + + return Task.FromResult(new QuizAnswerResponse( + roundId, + false, + "expired", + 0, + round.CorrectAnswer, + "Time's up!")); + } + + public async Task GetTargetLanguagesAsync(CancellationToken cancellationToken) + { + if (_cache.TryGetValue(LanguagesCacheKey, out string[]? cached) && cached is not null) + { + return cached; + } + + try + { + var languages = await _lexicalaClient.LanguagesAsync(cancellationToken); + + // Prefer explicit target_languages; fall back to source_languages excluding "en" + var targetLanguages = (languages.Resources?.Global?.TargetLanguages is { Length: > 0 } tl ? tl + : languages.Resources?.Global?.SourceLanguages ?? []) + .Select(l => l.Trim().ToLowerInvariant()) + .Where(l => l.Length > 0 && l != "en") + .Distinct() + .OrderBy(l => l) + .ToArray(); + + if (targetLanguages.Length > 0) + { + _cache.Set(LanguagesCacheKey, targetLanguages, TimeSpan.FromHours(1)); + } + + return targetLanguages; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch languages from API; using fallback list"); + return ["de", "nl", "fr", "es"]; + } + } + + private async Task TryGenerateRoundAsync(string targetLanguage, CancellationToken cancellationToken) + { + // Get a random English word via FlukySearch + var fluky = await _lexicalaClient.FlukySearchAsync(Sources.Global, "en", cancellationToken: cancellationToken); + var candidate = fluky.Results.FirstOrDefault(); + if (candidate?.Id is null) + { + return null; + } + + // Fetch the full entry to access its translations + var entry = await _lexicalaClient.GetEntryAsync(candidate.Id, cancellationToken: cancellationToken); + var sourceWord = entry.Headwords.FirstOrDefault()?.Text; + if (string.IsNullOrWhiteSpace(sourceWord)) + { + return null; + } + + // Find a translation in the target language from any sense + var correctTranslation = FindTranslation(entry, targetLanguage); + if (string.IsNullOrWhiteSpace(correctTranslation)) + { + return null; + } + + // Fetch distractors in parallel: FlukySearch in the target language, use headwords as wrong choices + var distractorTasks = Enumerable.Range(0, ChoiceCount - 1) + .Select(_ => _lexicalaClient.FlukySearchAsync(Sources.Global, targetLanguage, cancellationToken: cancellationToken)) + .ToArray(); + + var distractorResults = await Task.WhenAll(distractorTasks); + + var distractors = distractorResults + .Select(r => GetFirstHeadword(r.Results.FirstOrDefault())) + .OfType() + .Where(d => !string.IsNullOrWhiteSpace(d) && !string.Equals(d, correctTranslation, StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(ChoiceCount - 1) + .ToList(); + + if (distractors.Count < ChoiceCount - 1) + { + return null; + } + + // Build shuffled choices (1 correct + 3 distractors) + var choices = distractors + .Append(correctTranslation) + .OrderBy(_ => Random.Shared.Next()) + .ToArray(); + + return new TranslationQuizRoundState + { + SourceWord = sourceWord, + CorrectAnswer = correctTranslation, + Choices = choices, + ExpiresAtUtc = DateTimeOffset.UtcNow.AddSeconds(QuizRoundSeconds), + IsCompleted = false, + RateLimit = GameServiceHelpers.BuildRateLimit(entry.Metadata) ?? GameServiceHelpers.BuildRateLimit(fluky.Metadata) + }; + } + + private static string? FindTranslation(Lexicala.NET.Response.Entries.Entry entry, string targetLanguage) + { + foreach (var sense in entry.Senses) + { + if (sense.Translations.TryGetValue(targetLanguage, out var translationObj)) + { + var text = translationObj.Translation?.Text + ?? translationObj.Translations?.FirstOrDefault()?.Text; + + if (!string.IsNullOrWhiteSpace(text)) + { + return text; + } + } + } + + return null; + } + + private static string? GetFirstHeadword(Lexicala.NET.Response.Search.Result? result) + { + if (result is null) + { + return null; + } + + var hw = result.Headword; + return hw.HeadwordElementArray is { Length: > 0 } + ? hw.HeadwordElementArray[0].Text + : hw.Headword?.Text; + } + + private TranslationQuizRoundState GetRequiredRound(Guid roundId) + { + if (!_cache.TryGetValue(roundId, out TranslationQuizRoundState? round) || round is null) + { + throw new KeyNotFoundException("Round not found. Start a new round."); + } + + return round; + } + + private sealed class TranslationQuizRoundState + { + public required string SourceWord { get; init; } + + public required string CorrectAnswer { get; init; } + + public required string[] Choices { get; init; } + + public required DateTimeOffset ExpiresAtUtc { get; init; } + + public bool IsCompleted { get; set; } + + public RateLimitDebugResponse? RateLimit { get; init; } + } +} diff --git a/source/Demo/Lexicala.NET.Demo.Api/Program.cs b/source/Demo/Lexicala.NET.Demo.Api/Program.cs index f9efa56..552cd6d 100644 --- a/source/Demo/Lexicala.NET.Demo.Api/Program.cs +++ b/source/Demo/Lexicala.NET.Demo.Api/Program.cs @@ -29,6 +29,7 @@ public static async Task Main(string[] args) builder.Services.RegisterLexicala(builder.Configuration); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddCors(options => { @@ -174,6 +175,52 @@ await client.FlukySearchAsync(source ?? "global", language, etag, cancellationTo }) .WithName("SenseSprintGiveUp"); + app.MapPost("/game/translation-quiz/rounds", async (ITranslationQuizGameService gameService, string? targetLanguage, CancellationToken cancellationToken) => + { + try + { + var response = await gameService.CreateRoundAsync(targetLanguage, cancellationToken); + return Results.Ok(response); + } + catch (InvalidOperationException ex) + { + return Results.Problem(ex.Message, statusCode: StatusCodes.Status503ServiceUnavailable); + } + }) + .WithName("TranslationQuizCreateRound"); + + app.MapPost("/game/translation-quiz/rounds/{roundId:guid}/answer", async (ITranslationQuizGameService gameService, Guid roundId, QuizAnswerRequest request, CancellationToken cancellationToken) => + { + try + { + var response = await gameService.SubmitAnswerAsync(roundId, request.Choice, cancellationToken); + return Results.Ok(response); + } + catch (KeyNotFoundException ex) + { + return Results.NotFound(new ProblemDetails { Title = "Round not found", Detail = ex.Message }); + } + catch (ArgumentException ex) + { + return Results.BadRequest(new ProblemDetails { Title = "Invalid answer", Detail = ex.Message }); + } + }) + .WithName("TranslationQuizSubmitAnswer"); + + app.MapPost("/game/translation-quiz/rounds/{roundId:guid}/expire", async (ITranslationQuizGameService gameService, Guid roundId, CancellationToken cancellationToken) => + { + try + { + var response = await gameService.ExpireRoundAsync(roundId, cancellationToken); + return Results.Ok(response); + } + catch (KeyNotFoundException ex) + { + return Results.NotFound(new ProblemDetails { Title = "Round not found", Detail = ex.Message }); + } + }) + .WithName("TranslationQuizExpireRound"); + await app.RunAsync(); } } diff --git a/source/Demo/sense-sprint-web/.gitignore b/source/Demo/sense-sprint-web/.gitignore index a547bf3..dd439d9 100644 --- a/source/Demo/sense-sprint-web/.gitignore +++ b/source/Demo/sense-sprint-web/.gitignore @@ -22,3 +22,7 @@ dist-ssr *.njsproj *.sln *.sw? + +# Playwright +playwright-report +test-results diff --git a/source/Demo/sense-sprint-web/package-lock.json b/source/Demo/sense-sprint-web/package-lock.json index 0670654..e5fc2b2 100644 --- a/source/Demo/sense-sprint-web/package-lock.json +++ b/source/Demo/sense-sprint-web/package-lock.json @@ -13,6 +13,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@playwright/test": "^1.59.1", "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -573,6 +574,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.17", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", @@ -666,9 +683,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -686,9 +700,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -706,9 +717,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -726,9 +734,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -746,9 +751,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -766,9 +768,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2022,9 +2021,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2046,9 +2042,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2070,9 +2063,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2094,9 +2084,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2324,6 +2311,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.12", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", diff --git a/source/Demo/sense-sprint-web/package.json b/source/Demo/sense-sprint-web/package.json index f588460..a2ce46f 100644 --- a/source/Demo/sense-sprint-web/package.json +++ b/source/Demo/sense-sprint-web/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test:e2e": "playwright test" }, "dependencies": { "react": "^19.2.5", @@ -15,6 +16,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@playwright/test": "^1.59.1", "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/source/Demo/sense-sprint-web/playwright.config.ts b/source/Demo/sense-sprint-web/playwright.config.ts new file mode 100644 index 0000000..f39fed8 --- /dev/null +++ b/source/Demo/sense-sprint-web/playwright.config.ts @@ -0,0 +1,26 @@ +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: 'http://localhost:4173', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'npm run preview', + url: 'http://localhost:4173', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}) diff --git a/source/Demo/sense-sprint-web/src/App.css b/source/Demo/sense-sprint-web/src/App.css index a0153e9..6287f64 100644 --- a/source/Demo/sense-sprint-web/src/App.css +++ b/source/Demo/sense-sprint-web/src/App.css @@ -735,3 +735,150 @@ grid-template-columns: 1fr; } } + +/* ── Mode Tabs ─────────────────────────────────────────────────────────────── */ + +.mode-tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.mode-tab { + flex: 1; + border: 2px solid rgba(255, 255, 255, 0.25); + background: rgba(255, 255, 255, 0.08); + color: #d2dae5; + border-radius: 14px; + padding: 0.65rem 1rem; + font-size: 0.92rem; + font-weight: 700; + cursor: pointer; + transition: background 180ms ease, color 180ms ease, border-color 180ms ease; +} + +.mode-tab:hover { + background: rgba(255, 255, 255, 0.15); + color: #fff; +} + +.mode-tab-active { + background: rgba(255, 255, 255, 0.92); + color: #111827; + border-color: transparent; +} + +/* ── Translation Quiz ──────────────────────────────────────────────────────── */ + +.quiz-header { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.quiz-lang-label { + font-size: 0.88rem; + font-weight: 700; + color: var(--muted); + font-family: var(--mono); + white-space: nowrap; +} + +.quiz-lang-select { + border: 2px solid #d9d9d9; + border-radius: 10px; + padding: 0.45rem 0.7rem; + font-size: 0.92rem; + font-family: var(--sans); + background: #fff; + cursor: pointer; +} + +.quiz-lang-select:focus { + outline: none; + border-color: #2f80ed; +} + +.quiz-word-panel { + padding: 1rem; + border-radius: 14px; + background: #fff; + border: 1px solid #e7e7e7; + text-align: center; + animation: popIn 280ms ease-out; +} + +.quiz-word-label { + margin: 0 0 0.4rem; + font-size: 0.82rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + font-family: var(--mono); +} + +.quiz-word { + margin: 0; + font-size: 2rem; + font-weight: 900; + color: #1f2937; +} + +.quiz-word-empty { + margin: 0; + color: var(--muted); + font-size: 0.94rem; +} + +.quiz-choices { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.7rem; +} + +.quiz-choice { + background: #f8fafc; + border: 2px solid #e2e8f0; + color: #1f2937; + padding: 0.9rem 0.75rem; + font-size: 1rem; + font-weight: 700; + text-align: center; + border-radius: 14px; + cursor: pointer; + transition: background 140ms ease, border-color 140ms ease, transform 140ms ease; +} + +.quiz-choice:hover:not(:disabled) { + background: #eff6ff; + border-color: #93c5fd; + transform: translateY(-2px); +} + +.quiz-choice:disabled { + opacity: 0.7; + cursor: default; + transform: none; +} + +.quiz-choice-correct { + background: #dcfce7 !important; + border-color: #22c55e !important; + color: #14532d !important; +} + +.quiz-choice-wrong { + background: #fef2f2 !important; + border-color: #f87171 !important; + color: #7f1d1d !important; +} + +@media (max-width: 480px) { + .quiz-choices { + grid-template-columns: 1fr; + } + + .mode-tabs { + flex-direction: column; + } +} diff --git a/source/Demo/sense-sprint-web/src/App.tsx b/source/Demo/sense-sprint-web/src/App.tsx index 92c7c90..9a52ab0 100644 --- a/source/Demo/sense-sprint-web/src/App.tsx +++ b/source/Demo/sense-sprint-web/src/App.tsx @@ -1,6 +1,9 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import './App.css' +// ─── Shared types ───────────────────────────────────────────────────────────── + +type GameMode = 'sense-sprint' | 'translation-quiz' type RoundStatus = 'in-progress' | 'won' | 'lost' | 'expired' | 'completed' type FeedbackTone = 'info' | 'success' | 'warning' | 'error' type CelebrationLevel = 'none' | 'win' | 'major' @@ -69,6 +72,7 @@ type LanguagesResponse = { resources: { global?: { source_languages?: string[] + target_languages?: string[] } } } @@ -103,8 +107,52 @@ type StoredSession = { guess: string } +// ─── Translation Quiz types ─────────────────────────────────────────────────── + +type ActiveQuizRound = { + roundId: string + sourceWord: string + sourceLanguage: string + targetLanguage: string + choices: string[] + expiresAtUtc: string + roundStatus: RoundStatus + correctAnswer: string | null + selectedChoice: string | null +} + +type CreateQuizRoundResponse = { + roundId: string + sourceWord: string + sourceLanguage: string + targetLanguage: string + choices: string[] + expiresAtUtc: string + roundSeconds: number + rateLimit: RateLimitDebug | null +} + +type QuizAnswerResponse = { + roundId: string + isCorrect: boolean + roundStatus: RoundStatus + awardedPoints: number + correctAnswer: string + message: string | null +} + +type QuizStats = { + currentScore: number + highScore: number + roundsPlayed: number + roundsWon: number + currentStreak: number + highestStreak: number +} + const statsStorageKey = 'sense-sprint-stats-v1' const sessionStorageKey = 'sense-sprint-session-v1' +const quizStatsStorageKey = 'translation-quiz-stats-v1' const defaultStats: GameStats = { currentScore: 0, @@ -167,6 +215,34 @@ function isValidStoredRound(value: unknown): value is ActiveRound { ) } +// ─── Translation Quiz stats helpers ────────────────────────────────────────── + +const defaultQuizStats: QuizStats = { + currentScore: 0, + highScore: 0, + roundsPlayed: 0, + roundsWon: 0, + currentStreak: 0, + highestStreak: 0, +} + +function loadQuizStats(): QuizStats { + if (typeof window === 'undefined') return { ...defaultQuizStats } + const raw = window.localStorage.getItem(quizStatsStorageKey) + if (!raw) return { ...defaultQuizStats } + try { + return { ...defaultQuizStats, ...(JSON.parse(raw) as Partial) } + } catch { + return { ...defaultQuizStats } + } +} + +function saveQuizStats(stats: QuizStats): void { + if (typeof window !== 'undefined') { + window.localStorage.setItem(quizStatsStorageKey, JSON.stringify(stats)) + } +} + function loadStoredSession(): StoredSession | null { if (typeof window === 'undefined') { return null @@ -204,7 +280,7 @@ function clearStoredSession(): void { } function getSecondsRemaining(expiresAtUtc: string): number { - return Math.max(0, Math.floor((new Date(expiresAtUtc).getTime() - Date.now()) / 1000)) + return Math.max(0, Math.ceil((new Date(expiresAtUtc).getTime() - Date.now()) / 1000)) } function getStreakMultiplier(streak: number): number { @@ -294,6 +370,37 @@ const api = { return parseResponse(response) }, + + async createQuizRound(targetLanguage?: string): Promise> { + const url = targetLanguage + ? `/game/translation-quiz/rounds?targetLanguage=${encodeURIComponent(targetLanguage)}` + : '/game/translation-quiz/rounds' + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }) + + return parseResponse(response) + }, + + async submitQuizAnswer(roundId: string, choice: string): Promise> { + const response = await fetch(`/game/translation-quiz/rounds/${roundId}/answer`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ choice }), + }) + + return parseResponse(response) + }, + + async expireQuizRound(roundId: string): Promise> { + const response = await fetch(`/game/translation-quiz/rounds/${roundId}/expire`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }) + + return parseResponse(response) + }, } function getRateLimitFromHeaders(headers: Headers): RateLimitDebug | null { @@ -330,7 +437,9 @@ async function parseResponse(response: Response): Promise> { } } -function App() { +// ─── SenseSprint component ──────────────────────────────────────────────────── + +function SenseSprint() { const [round, setRound] = useState(null) const [guess, setGuess] = useState('') const [selectedLanguage, setSelectedLanguage] = useState('en') @@ -853,24 +962,15 @@ function App() { } return ( -
-
- Lexicala Game -

Sense Sprint

-

- Guess the hidden English word from lexical clues generated with Fluky Search. -

-
- -
-
setStatsOpen(e.currentTarget.open)} className="stats-collapsible"> - Performance Snapshot -
-
-

Total Points

-

{stats.currentScore}

-
-
+
+
setStatsOpen(e.currentTarget.open)} className="stats-collapsible"> + Performance Snapshot +
+
+

Total Points

+

{stats.currentScore}

+
+

High Score

{stats.highScore}

@@ -1076,7 +1176,393 @@ function App() {

-
+
+ ) +} + +// ─── Translation Quiz component ─────────────────────────────────────────────── + +function TranslationQuiz() { + const [round, setRound] = useState(null) + const [targetLanguage, setTargetLanguage] = useState('') + const [quizLanguageOptions, setQuizLanguageOptions] = useState([]) + const [loadingQuizLanguages, setLoadingQuizLanguages] = useState(true) + const [statusMessage, setStatusMessage] = useState('Choose a target language (or leave on Random) and start a round.') + const [feedbackTone, setFeedbackTone] = useState('info') + const [stats, setStats] = useState(() => loadQuizStats()) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [timeLeft, setTimeLeft] = useState(0) + const [rateLimitDebug, setRateLimitDebug] = useState(null) + const [debugCallCount, setDebugCallCount] = useState(0) + const [debugLastUpdated, setDebugLastUpdated] = useState('-') + const [debugPulseTick, setDebugPulseTick] = useState(0) + const expiryHandledRef = useRef(false) + + useEffect(() => { + saveQuizStats(stats) + }, [stats]) + + // Load available target languages from the API + useEffect(() => { + let isCancelled = false + + const loadLanguages = async () => { + setLoadingQuizLanguages(true) + try { + const result = await api.languages() + if (isCancelled) return + + const languageNames = result.data.language_names ?? {} + // Prefer explicit target_languages; fall back to source_languages, exclude 'en' + const rawList = [ + ...new Set( + (result.data.resources?.global?.target_languages ?? result.data.resources?.global?.source_languages ?? []) + .map((code) => code.trim().toLowerCase()) + .filter((code) => code.length > 0 && code !== 'en'), + ), + ].sort() + const options = rawList.map((code) => ({ code, name: languageNames[code] ?? code.toUpperCase() })) + setQuizLanguageOptions(options) + } catch { + if (!isCancelled) { + setQuizLanguageOptions([ + { code: 'de', name: 'German' }, + { code: 'nl', name: 'Dutch' }, + { code: 'fr', name: 'French' }, + { code: 'es', name: 'Spanish' }, + ]) + } + } finally { + if (!isCancelled) setLoadingQuizLanguages(false) + } + } + + void loadLanguages() + return () => { isCancelled = true } + }, []) + + useEffect(() => { + if (!round || round.roundStatus !== 'in-progress') { + return + } + + const timer = window.setInterval(() => { + const diff = Math.max(0, Math.ceil((new Date(round.expiresAtUtc).getTime() - Date.now()) / 1000)) + setTimeLeft(diff) + }, 250) + + return () => window.clearInterval(timer) + }, [round]) + + // Reveal answer when timer expires + useEffect(() => { + if (timeLeft !== 0 || round?.roundStatus !== 'in-progress' || expiryHandledRef.current) { + return + } + + expiryHandledRef.current = true + const roundId = round.roundId + + setLoading(true) + api + .expireQuizRound(roundId) + .then((result) => { + const data = result.data + setRound((current) => { + if (!current) return current + return { ...current, roundStatus: data.roundStatus, correctAnswer: data.correctAnswer } + }) + if (result.rateLimit) setRateLimitDebug(result.rateLimit) + setDebugCallCount((n) => n + 1) + setDebugLastUpdated(new Date().toLocaleTimeString()) + setDebugPulseTick((n) => n + 1) + setStatusMessage(`Time's up! The correct answer was: ${data.correctAnswer}`) + setFeedbackTone('warning') + setStats((current) => ({ ...current, currentStreak: 0 })) + }) + .catch(() => { + setRound((current) => { + if (!current) return current + return { ...current, roundStatus: 'expired' } + }) + setStatusMessage("Time's up!") + setFeedbackTone('warning') + setStats((current) => ({ ...current, currentStreak: 0 })) + }) + .finally(() => { + setLoading(false) + }) + // expiryHandledRef guards against re-entry; loading is intentionally omitted + // from deps to prevent the effect cleanup from cancelling an in-flight API call. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [timeLeft, round?.roundStatus, round?.roundId]) + + function getLanguageName(code: string): string { + return quizLanguageOptions.find((o) => o.code === code)?.name ?? code.toUpperCase() + } + + function refreshDebug(rateLimit: RateLimitDebug | null): void { + setDebugCallCount((current) => current + 1) + setDebugLastUpdated(new Date().toLocaleTimeString()) + setDebugPulseTick((current) => current + 1) + if (rateLimit) { + setRateLimitDebug(rateLimit) + } + } + + async function startRound(): Promise { + setLoading(true) + setError('') + expiryHandledRef.current = false + + try { + // Empty string → server picks a random language + const result = await api.createQuizRound(targetLanguage || undefined) + const created = result.data + setRound({ + roundId: created.roundId, + sourceWord: created.sourceWord, + sourceLanguage: created.sourceLanguage, + targetLanguage: created.targetLanguage, + choices: created.choices, + expiresAtUtc: created.expiresAtUtc, + roundStatus: 'in-progress', + correctAnswer: null, + selectedChoice: null, + }) + setTimeLeft(Math.max(0, Math.ceil((new Date(created.expiresAtUtc).getTime() - Date.now()) / 1000))) + refreshDebug(result.rateLimit ?? created.rateLimit) + setStats((current) => ({ ...current, roundsPlayed: current.roundsPlayed + 1 })) + setStatusMessage(`Choose the correct ${getLanguageName(created.targetLanguage)} translation.`) + setFeedbackTone('info') + } catch (err) { + const message = err instanceof Error ? err.message : 'Unable to start round.' + setError(message) + setFeedbackTone('error') + } finally { + setLoading(false) + } + } + + async function submitChoice(choice: string): Promise { + if (!round || round.roundStatus !== 'in-progress' || loading) { + return + } + + setLoading(true) + setError('') + + try { + const result = await api.submitQuizAnswer(round.roundId, choice) + const data = result.data + + if (data.isCorrect) { + const nextStreak = stats.currentStreak + 1 + setStats((current) => ({ + ...current, + currentScore: current.currentScore + data.awardedPoints, + highScore: Math.max(current.highScore, data.awardedPoints), + roundsWon: current.roundsWon + 1, + currentStreak: nextStreak, + highestStreak: Math.max(current.highestStreak, nextStreak), + })) + setStatusMessage(`Correct! +${data.awardedPoints} ${data.awardedPoints === 1 ? 'point' : 'points'}. The translation is: ${data.correctAnswer}`) + setFeedbackTone('success') + } else { + setStats((current) => ({ ...current, currentStreak: 0 })) + if (data.roundStatus === 'expired') { + setStatusMessage(`Time's up! The correct answer was: ${data.correctAnswer}`) + } else { + setStatusMessage(`Incorrect. The correct answer was: ${data.correctAnswer}`) + } + setFeedbackTone('warning') + } + + setRound((current) => { + if (!current) return current + return { + ...current, + roundStatus: data.roundStatus, + correctAnswer: data.correctAnswer, + selectedChoice: choice, + } + }) + refreshDebug(result.rateLimit) + } catch (err) { + const message = err instanceof Error ? err.message : 'Unable to submit answer.' + setError(message) + setFeedbackTone('error') + } finally { + setLoading(false) + } + } + + const canAnswer = Boolean(round && round.roundStatus === 'in-progress' && !loading) + const usedRequests = rateLimitDebug ? Math.max(0, rateLimitDebug.limit - rateLimitDebug.limitRemaining) : null + + return ( +
+
+ + +
+ +
+
+

Total Points

+

{stats.currentScore}

+
+
+

High Score

+

{stats.highScore}

+
+
+

Win Streak 🔥

+

{stats.currentStreak}

+
+
+

Best Streak

+

{stats.highestStreak}

+
+
+

Rounds

+

{stats.roundsPlayed}

+
+
+

Time Left

+

{round ? `${timeLeft}s` : '-'}

+
+
+ +
+ {round ? ( + <> +

What is the {getLanguageName(round.targetLanguage)} translation of:

+

{round.sourceWord}

+ + ) : ( +

No active round yet.

+ )} +
+ +
+ {round?.choices.map((choice) => { + const isSelected = round.selectedChoice === choice + const isCorrect = round.correctAnswer === choice + const isFinished = round.roundStatus !== 'in-progress' + let choiceClass = 'button quiz-choice' + if (isFinished) { + if (isCorrect) choiceClass += ' quiz-choice-correct' + else if (isSelected) choiceClass += ' quiz-choice-wrong' + } + return ( + + ) + }) ??

Start a round to see choices.

} +
+ +
+ +
+ +

+ Status: {statusMessage} +

+ {round?.roundStatus === 'won' ?

Correct! Streak: {stats.currentStreak} 🔥

: null} + {error ?

{error}

: null} + +
+ Debug Info +
+

+ Round ID: {round?.roundId ?? '-'} +

+

+ Round Status: {round?.roundStatus ?? '-'} +

+

+ Rate Limit: {rateLimitDebug ? `${rateLimitDebug.limitRemaining}/${rateLimitDebug.limit} remaining` : 'not available yet'} +

+

+ Rate Used: {usedRequests ?? '-'} +

+

+ Rate Reset (sec): {rateLimitDebug?.resetSeconds ?? '-'} +

+

+ API Calls: {debugCallCount} +

+

+ Debug Updated: {debugLastUpdated} +

+
+
+
+ ) +} + +// ─── App ────────────────────────────────────────────────────────────────────── + +function App() { + const [gameMode, setGameMode] = useState('sense-sprint') + + return ( +
+
+ Lexicala Game +

{gameMode === 'sense-sprint' ? 'Sense Sprint' : 'Translation Quiz'}

+

+ {gameMode === 'sense-sprint' + ? 'Guess the hidden word from lexical clues generated with Fluky Search.' + : 'Select the correct translation from four options — race against the clock!'} +

+
+ +
+ + +
+ + {gameMode === 'sense-sprint' ? : }
) } diff --git a/source/Demo/sense-sprint-web/tests/e2e/mocks.ts b/source/Demo/sense-sprint-web/tests/e2e/mocks.ts new file mode 100644 index 0000000..189c93b --- /dev/null +++ b/source/Demo/sense-sprint-web/tests/e2e/mocks.ts @@ -0,0 +1,72 @@ +/** + * Shared mock responses used by the E2E tests so that all tests run without a + * live Lexicala backend. Every route intercept is set up via Playwright's + * `page.route()` API before the page is loaded. + */ + +export const mockLanguagesResponse = { + language_names: { + en: 'English', + de: 'German', + nl: 'Dutch', + fr: 'French', + es: 'Spanish', + ja: 'Japanese', + }, + resources: { + global: { + source_languages: ['en', 'de', 'nl', 'fr', 'es', 'ja'], + target_languages: ['de', 'nl', 'fr', 'es', 'ja'], + }, + }, +} + +export const mockSenseSprintRound = { + roundId: 'aaaaaaaa-1111-1111-1111-aaaaaaaaaaaa', + language: 'en', + expiresAtUtc: new Date(Date.now() + 60_000).toISOString(), + clueIndex: 0, + clue: 'a building for human habitation', + scoreIfCorrect: 10, + maxClues: 5, + roundSeconds: 60, + rateLimit: null, +} + +export const mockQuizRound = { + roundId: 'bbbbbbbb-2222-2222-2222-bbbbbbbbbbbb', + sourceWord: 'house', + sourceLanguage: 'en', + targetLanguage: 'de', + choices: ['Haus', 'Auto', 'Baum', 'Buch'], + expiresAtUtc: new Date(Date.now() + 30_000).toISOString(), + roundSeconds: 30, + rateLimit: null, +} + +export const mockQuizCorrectAnswer = { + roundId: 'bbbbbbbb-2222-2222-2222-bbbbbbbbbbbb', + isCorrect: true, + roundStatus: 'won', + awardedPoints: 1, + correctAnswer: 'Haus', + message: 'Correct!', +} + +export const mockQuizWrongAnswer = { + roundId: 'bbbbbbbb-2222-2222-2222-bbbbbbbbbbbb', + isCorrect: false, + roundStatus: 'lost', + awardedPoints: 0, + correctAnswer: 'Haus', + message: 'Incorrect. The correct translation was: Haus', +} + +export const mockQuizExpired = { + roundId: 'bbbbbbbb-2222-2222-2222-bbbbbbbbbbbb', + isCorrect: false, + roundStatus: 'expired', + awardedPoints: 0, + correctAnswer: 'Haus', + message: "Time's up!", +} diff --git a/source/Demo/sense-sprint-web/tests/e2e/translation-quiz.spec.ts b/source/Demo/sense-sprint-web/tests/e2e/translation-quiz.spec.ts new file mode 100644 index 0000000..499a844 --- /dev/null +++ b/source/Demo/sense-sprint-web/tests/e2e/translation-quiz.spec.ts @@ -0,0 +1,234 @@ +import { test, expect, Page } from '@playwright/test' +import { + mockLanguagesResponse, + mockQuizRound, + mockQuizCorrectAnswer, + mockQuizWrongAnswer, + mockQuizExpired, +} from './mocks' + +// ─── helpers ───────────────────────────────────────────────────────────────── + +async function setupLanguagesMock(page: Page) { + await page.route('/languages', (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockLanguagesResponse) }), + ) +} + +async function setupQuizRoundMock(page: Page) { + await page.route('/game/translation-quiz/rounds', (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockQuizRound) }), + ) +} + +async function setupAnswerMock(page: Page, response: object) { + await page.route(/\/game\/translation-quiz\/rounds\/.*\/answer/, (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(response) }), + ) +} + +async function setupExpireMock(page: Page, response: object) { + await page.route(/\/game\/translation-quiz\/rounds\/.*\/expire/, (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(response) }), + ) +} + +async function openTranslationQuizTab(page: Page) { + await page.getByRole('tab', { name: 'Translation Quiz' }).click() +} + +// ─── tests ──────────────────────────────────────────────────────────────────── + +test.describe('Translation Quiz — language dropdown', () => { + test('shows "Random (any language)" as first option', async ({ page }) => { + await setupLanguagesMock(page) + await page.goto('/') + + await openTranslationQuizTab(page) + + const select = page.locator('#quiz-target-language') + await expect(select).toBeVisible() + + // First option should be the random placeholder with empty value + const firstOption = select.locator('option').first() + await expect(firstOption).toHaveText(/Random/i) + await expect(firstOption).toHaveAttribute('value', '') + }) + + test('populates dropdown with languages from the API', async ({ page }) => { + await setupLanguagesMock(page) + await page.goto('/') + + await openTranslationQuizTab(page) + + // All five API target languages should appear in the dropdown + const select = page.locator('#quiz-target-language') + for (const [code, name] of [['de', 'German'], ['nl', 'Dutch'], ['fr', 'French'], ['es', 'Spanish'], ['ja', 'Japanese']]) { + const option = select.locator(`option[value="${code}"]`) + await expect(option).toHaveText(new RegExp(name, 'i')) + } + }) + + test('falls back gracefully when languages API fails', async ({ page }) => { + await page.route('/languages', (route) => route.fulfill({ status: 500 })) + await page.goto('/') + + await openTranslationQuizTab(page) + + // Dropdown still visible with at least the random option + const select = page.locator('#quiz-target-language') + await expect(select).toBeVisible() + await expect(select.locator('option').first()).toHaveText(/Random/i) + }) +}) + +test.describe('Translation Quiz — round lifecycle', () => { + test.beforeEach(async ({ page }) => { + await setupLanguagesMock(page) + await setupQuizRoundMock(page) + }) + + test('starts a round and shows the source word and four choices', async ({ page }) => { + await page.goto('/') + await openTranslationQuizTab(page) + + await page.getByRole('button', { name: /Start Round/i }).click() + + await expect(page.getByText('house')).toBeVisible() + + const choices = page.locator('.quiz-choice') + await expect(choices).toHaveCount(4) + for (const choice of ['Haus', 'Auto', 'Baum', 'Buch']) { + await expect(page.getByRole('button', { name: choice })).toBeVisible() + } + }) + + test('shows the target language name in the quiz prompt', async ({ page }) => { + await page.goto('/') + await openTranslationQuizTab(page) + + await page.getByRole('button', { name: /Start Round/i }).click() + + // "de" → "German" is resolved from the language names in the languages API + await expect(page.locator('.quiz-word-label')).toContainText(/German/i) + }) + + test('correct choice highlights green and shows won status', async ({ page }) => { + await setupAnswerMock(page, mockQuizCorrectAnswer) + await page.goto('/') + await openTranslationQuizTab(page) + + await page.getByRole('button', { name: /Start Round/i }).click() + await page.getByRole('button', { name: 'Haus' }).click() + + await expect(page.getByRole('button', { name: 'Haus' })).toHaveClass(/quiz-choice-correct/) + await expect(page.locator('[role="status"]')).toContainText(/Correct/i) + }) + + test('wrong choice highlights red and shows the correct answer', async ({ page }) => { + await setupAnswerMock(page, mockQuizWrongAnswer) + await page.goto('/') + await openTranslationQuizTab(page) + + await page.getByRole('button', { name: /Start Round/i }).click() + await page.getByRole('button', { name: 'Auto' }).click() + + await expect(page.getByRole('button', { name: 'Auto' })).toHaveClass(/quiz-choice-wrong/) + await expect(page.getByRole('button', { name: 'Haus' })).toHaveClass(/quiz-choice-correct/) + await expect(page.locator('[role="status"]')).toContainText(/Haus/i) + }) + + test('choices are disabled after an answer is submitted', async ({ page }) => { + await setupAnswerMock(page, mockQuizCorrectAnswer) + await page.goto('/') + await openTranslationQuizTab(page) + + await page.getByRole('button', { name: /Start Round/i }).click() + await page.getByRole('button', { name: 'Haus' }).click() + + for (const choice of ['Haus', 'Auto', 'Baum', 'Buch']) { + await expect(page.getByRole('button', { name: choice })).toBeDisabled() + } + }) + + test('"New Round" button appears after completing a round', async ({ page }) => { + await setupAnswerMock(page, mockQuizCorrectAnswer) + await page.goto('/') + await openTranslationQuizTab(page) + + await page.getByRole('button', { name: /Start Round/i }).click() + await page.getByRole('button', { name: 'Haus' }).click() + + await expect(page.getByRole('button', { name: /New Round/i })).toBeVisible() + }) +}) + +test.describe('Translation Quiz — stats', () => { + test.beforeEach(async ({ page }) => { + await setupLanguagesMock(page) + await setupQuizRoundMock(page) + // Clear any persisted quiz stats from prior test runs + await page.addInitScript(() => { + window.localStorage.removeItem('translation-quiz-stats-v1') + }) + }) + + test('win streak increments on correct answer', async ({ page }) => { + await setupAnswerMock(page, mockQuizCorrectAnswer) + await page.goto('/') + await openTranslationQuizTab(page) + + await page.getByRole('button', { name: /Start Round/i }).click() + await page.getByRole('button', { name: 'Haus' }).click() + + await expect(page.getByText('1').first()).toBeVisible() + await expect(page.locator('.kpi', { hasText: /Win Streak/i })).toContainText('1') + }) + + test('win streak resets to 0 on wrong answer', async ({ page }) => { + await setupAnswerMock(page, mockQuizWrongAnswer) + await page.goto('/') + await openTranslationQuizTab(page) + + await page.getByRole('button', { name: /Start Round/i }).click() + await page.getByRole('button', { name: 'Auto' }).click() + + await expect(page.locator('.kpi', { hasText: /Win Streak/i })).toContainText('0') + }) + + test('rounds played counter increments on new round', async ({ page }) => { + await page.goto('/') + await openTranslationQuizTab(page) + + await expect(page.locator('.kpi', { hasText: /Rounds/i })).toContainText('0') + await page.getByRole('button', { name: /Start Round/i }).click() + await expect(page.locator('.kpi', { hasText: /Rounds/i })).toContainText('1') + }) +}) + +test.describe('Translation Quiz — timer expiry', () => { + test('reveals the correct answer via /expire when timer reaches 0', async ({ page }) => { + await setupLanguagesMock(page) + // Create the mock lazily so the expiry time is computed when the request arrives + await page.route('/game/translation-quiz/rounds', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ...mockQuizRound, + expiresAtUtc: new Date(Date.now() + 2_000).toISOString(), + roundSeconds: 2, + }), + }), + ) + await setupExpireMock(page, mockQuizExpired) + + await page.goto('/') + await openTranslationQuizTab(page) + await page.getByRole('button', { name: /Start Round/i }).click() + + // Allow a small tolerance for client-side timer rounding; reject if called well before expiry + await expect(page.locator('[role="status"]')).toContainText(/Time.s up/i, { timeout: 5_000 }) + await expect(page.getByRole('button', { name: 'Haus' })).toHaveClass(/quiz-choice-correct/, { timeout: 5_000 }) + }) +}) diff --git a/source/Lexicala.NET.Tests/Lexicala.NET.Tests.csproj b/source/Lexicala.NET.Tests/Lexicala.NET.Tests.csproj index ede2e89..ea23b1a 100644 --- a/source/Lexicala.NET.Tests/Lexicala.NET.Tests.csproj +++ b/source/Lexicala.NET.Tests/Lexicala.NET.Tests.csproj @@ -67,6 +67,7 @@ + diff --git a/source/Lexicala.NET.Tests/SenseSprintGameServiceTests.cs b/source/Lexicala.NET.Tests/SenseSprintGameServiceTests.cs new file mode 100644 index 0000000..f4986ad --- /dev/null +++ b/source/Lexicala.NET.Tests/SenseSprintGameServiceTests.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Lexicala.NET.Demo.Api.Game; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using Shouldly; + +namespace Lexicala.NET.Client.Tests +{ + [TestClass] + public class SenseSprintGameServiceTests + { + private IMemoryCache _cache; + private Mock _clientMock; + private Mock> _loggerMock; + private SenseSprintGameService _service; + + [TestInitialize] + public void Initialize() + { + _cache = new MemoryCache(new MemoryCacheOptions()); + _clientMock = new Mock(); + _loggerMock = new Mock>(); + _service = new SenseSprintGameService(_clientMock.Object, _cache, _loggerMock.Object); + } + + [TestCleanup] + public void Cleanup() + { + _cache.Dispose(); + } + + [TestMethod] + public async Task SubmitGuessAsync_NullGuess_ThrowsArgumentException() + { + await Should.ThrowAsync(() => + _service.SubmitGuessAsync(Guid.NewGuid(), null!)); + } + + [TestMethod] + public async Task SubmitGuessAsync_WhitespaceGuess_ThrowsArgumentException() + { + await Should.ThrowAsync(() => + _service.SubmitGuessAsync(Guid.NewGuid(), " ")); + } + + [TestMethod] + public async Task SubmitGuessAsync_RoundNotFound_ThrowsKeyNotFoundException() + { + var unknownId = Guid.NewGuid(); + + await Should.ThrowAsync(() => + _service.SubmitGuessAsync(unknownId, "house")); + } + + [TestMethod] + public async Task RevealNextClueAsync_RoundNotFound_ThrowsKeyNotFoundException() + { + var unknownId = Guid.NewGuid(); + + await Should.ThrowAsync(() => + _service.RevealNextClueAsync(unknownId)); + } + + [TestMethod] + public async Task GiveUpAsync_RoundNotFound_ThrowsKeyNotFoundException() + { + var unknownId = Guid.NewGuid(); + + await Should.ThrowAsync(() => + _service.GiveUpAsync(unknownId)); + } + } +} diff --git a/source/Lexicala.NET.Tests/TranslationQuizGameServiceTests.cs b/source/Lexicala.NET.Tests/TranslationQuizGameServiceTests.cs new file mode 100644 index 0000000..c11b78e --- /dev/null +++ b/source/Lexicala.NET.Tests/TranslationQuizGameServiceTests.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Lexicala.NET.Demo.Api.Game; +using Lexicala.NET.Response.Languages; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using Shouldly; + +namespace Lexicala.NET.Client.Tests +{ + [TestClass] + public class TranslationQuizGameServiceTests + { + private IMemoryCache _cache; + private Mock _clientMock; + private Mock> _loggerMock; + private TranslationQuizGameService _service; + + [TestInitialize] + public void Initialize() + { + _cache = new MemoryCache(new MemoryCacheOptions()); + _clientMock = new Mock(); + _loggerMock = new Mock>(); + _service = new TranslationQuizGameService(_clientMock.Object, _cache, _loggerMock.Object); + } + + [TestCleanup] + public void Cleanup() + { + _cache.Dispose(); + } + + [TestMethod] + public async Task CreateRoundAsync_NullLanguage_FetchesLanguagesFromApi() + { + // Arrange: language API returns a list; FlukySearch throws so round generation fails fast + _clientMock + .Setup(c => c.LanguagesAsync(It.IsAny())) + .ReturnsAsync(new LanguagesResponse + { + LanguageNames = new Dictionary { { "de", "German" }, { "nl", "Dutch" } }, + Resources = new Resources { Global = new Resource { SourceLanguages = ["de", "nl"] } } + }); + _clientMock + .Setup(c => c.FlukySearchAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("API not available in test")); + + // Act + Assert: null is not ArgumentException; execution reaches the mocked client + await Should.ThrowAsync(() => + _service.CreateRoundAsync(null)); + + _clientMock.Verify(c => c.LanguagesAsync(It.IsAny()), Times.Once); + } + + [TestMethod] + [DataRow("de")] + [DataRow("DE")] + [DataRow("nl")] + [DataRow("fr")] + [DataRow("es")] + [DataRow("ja")] + [DataRow("zh")] + [DataRow("xx")] + public async Task CreateRoundAsync_AnyLanguage_AcceptsWithoutArgumentException(string language) + { + // Arrange: make the client throw after validation so we can verify it reached the API call + _clientMock + .Setup(c => c.LanguagesAsync(It.IsAny())) + .ReturnsAsync(new LanguagesResponse + { + LanguageNames = new Dictionary(), + Resources = new Resources { Global = new Resource { SourceLanguages = ["de", "nl"] } } + }); + _clientMock + .Setup(c => c.FlukySearchAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("API not available in test")); + + // Act + Assert: any language passes validation; execution reaches the mocked API call + await Should.ThrowAsync(() => + _service.CreateRoundAsync(language)); + } + + [TestMethod] + public async Task GetTargetLanguagesAsync_CachesResult() + { + _clientMock + .Setup(c => c.LanguagesAsync(It.IsAny())) + .ReturnsAsync(new LanguagesResponse + { + LanguageNames = new Dictionary(), + Resources = new Resources { Global = new Resource { SourceLanguages = ["de", "nl", "fr"] } } + }); + + // Call twice + var first = await _service.GetTargetLanguagesAsync(CancellationToken.None); + var second = await _service.GetTargetLanguagesAsync(CancellationToken.None); + + first.ShouldBe(second); + _clientMock.Verify(c => c.LanguagesAsync(It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task GetTargetLanguagesAsync_ExcludesEnglish() + { + _clientMock + .Setup(c => c.LanguagesAsync(It.IsAny())) + .ReturnsAsync(new LanguagesResponse + { + LanguageNames = new Dictionary(), + Resources = new Resources { Global = new Resource { SourceLanguages = ["en", "de", "nl"] } } + }); + + var languages = await _service.GetTargetLanguagesAsync(CancellationToken.None); + + languages.ShouldNotContain("en"); + languages.ShouldContain("de"); + languages.ShouldContain("nl"); + } + + [TestMethod] + public async Task GetTargetLanguagesAsync_PrefersTargetLanguagesOverSourceLanguages() + { + _clientMock + .Setup(c => c.LanguagesAsync(It.IsAny())) + .ReturnsAsync(new LanguagesResponse + { + LanguageNames = new Dictionary(), + Resources = new Resources + { + Global = new Resource + { + SourceLanguages = ["en", "de", "nl"], + TargetLanguages = ["de", "fr"] + } + } + }); + + var languages = await _service.GetTargetLanguagesAsync(CancellationToken.None); + + languages.ShouldContain("de"); + languages.ShouldContain("fr"); + languages.ShouldNotContain("nl"); // only in source, not target + } + + [TestMethod] + public async Task GetTargetLanguagesAsync_FallsBackOnApiError() + { + _clientMock + .Setup(c => c.LanguagesAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("API error")); + + var languages = await _service.GetTargetLanguagesAsync(CancellationToken.None); + + languages.ShouldNotBeEmpty(); + } + + [TestMethod] + public async Task SubmitAnswerAsync_RoundNotFound_ThrowsKeyNotFoundException() + { + var unknownId = Guid.NewGuid(); + + await Should.ThrowAsync(() => + _service.SubmitAnswerAsync(unknownId, "someChoice")); + } + + [TestMethod] + public async Task SubmitAnswerAsync_NullChoice_ThrowsArgumentException() + { + await Should.ThrowAsync(() => + _service.SubmitAnswerAsync(Guid.NewGuid(), null!)); + } + + [TestMethod] + public async Task SubmitAnswerAsync_EmptyChoice_ThrowsArgumentException() + { + await Should.ThrowAsync(() => + _service.SubmitAnswerAsync(Guid.NewGuid(), string.Empty)); + } + + [TestMethod] + public async Task ExpireRoundAsync_RoundNotFound_ThrowsKeyNotFoundException() + { + var unknownId = Guid.NewGuid(); + + await Should.ThrowAsync(() => + _service.ExpireRoundAsync(unknownId)); + } + } +}