From b95a71e3e34bc91a94148928602bb9a00335242e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20H=C3=B8jlund?= Date: Fri, 27 Mar 2026 20:13:39 +0100 Subject: [PATCH] my attemt at a SOLID solution for flashcards --- Flashcards.Hillgrove/.csharpierrc | 3 + Flashcards.Hillgrove/.gitignore | 482 ++++++++++++++++++ .../Flashcards.Hillgrove.Tests.csproj | 18 + .../GlobalUsings.cs | 1 + .../Flashcards/DeleteFlashcardActionTests.cs | 139 +++++ .../StartStudySessionActionTests.cs | 39 ++ .../ViewStudySessionHistoryActionTests.cs | 95 ++++ .../Menu/CompositeMenuTests.cs | 122 +++++ .../Menu/MenuComposerTests.cs | 256 ++++++++++ .../Menu/MenuItemsTests.cs | 27 + .../Services/FlashcardServiceTests.cs | 90 ++++ .../Services/StackServiceTests.cs | 58 +++ .../Services/StudySessionServiceTests.cs | 138 +++++ Flashcards.Hillgrove/Flashcards.Hillgrove.sln | 65 +++ .../Data/FlashcardRepository.cs | 45 ++ .../Data/IFlashcardRepository.cs | 11 + .../Data/IReportRepository.cs | 10 + .../Data/IStackRepository.cs | 11 + .../Data/IStudySessionRepository.cs | 10 + .../Data/ReportRepository.cs | 90 ++++ .../Data/StackRepository.cs | 36 ++ .../Data/StudySessionRepository.cs | 44 ++ .../Flashcards.Hillgrove/Dtos/FlashCardDto.cs | 9 + .../Flashcards.Hillgrove.csproj | 31 ++ .../Helpers/NavigationResult.cs | 9 + .../Actions/Flashcards/AddFlashCardAction.cs | 52 ++ .../Flashcards/DeleteFlashcardAction.cs | 85 +++ .../Flashcards/ShowFlashcardsAction.cs | 25 + .../ViewAverageScorePerMonthPerStackAction.cs | 65 +++ .../ViewSessionsPerMonthPerStackAction.cs | 65 +++ .../Menu/Actions/Stacks/DeleteStackAction.cs | 41 ++ .../Menu/Actions/Stacks/IStackAction.cs | 9 + .../StudySessions/StartStudySessionAction.cs | 21 + .../ViewStudySessionHistoryAction.cs | 31 ++ .../Menu/CompositeMenu.cs | 42 ++ .../Menu/Composition/ExitSectionProvider.cs | 11 + .../Composition/IMainMenuSectionProvider.cs | 8 + .../ManageFlashcardsSectionProvider.cs | 57 +++ .../ManageStacksSectionProvider.cs | 65 +++ .../Composition/ReportsSectionProvider.cs | 43 ++ .../StudySessionsSectionProvider.cs | 53 ++ .../Flashcards.Hillgrove/Menu/IAppUi.cs | 20 + .../Flashcards.Hillgrove/Menu/IMenuItem.cs | 10 + .../Flashcards.Hillgrove/Menu/IMenuPrompt.cs | 8 + .../Menu/Items/BackMenuItem.cs | 11 + .../Menu/Items/ExitMenuItem.cs | 11 + .../AddFlashcardToExistingStackMenuItem.cs | 23 + ...iewAverageScorePerMonthPerStackMenuItem.cs | 23 + .../ViewSessionsPerMonthPerStackMenuItem.cs | 23 + .../Menu/Items/Stacks/CreateStackMenuItem.cs | 74 +++ .../Items/Stacks/ShowAllStacksMenuItem.cs | 49 ++ .../StartStudySessionMenuItem.cs | 23 + .../ViewStudySessionHistoryMenuItem.cs | 23 + .../Flashcards.Hillgrove/Menu/MenuComposer.cs | 28 + .../Flashcards.Hillgrove/Menu/SpectreAppUi.cs | 143 ++++++ .../Menu/SpectreMenuPrompt.cs | 33 ++ .../Flashcards.Hillgrove/Models/Flashcard.cs | 10 + .../Flashcards.Hillgrove/Models/Stack.cs | 8 + .../Models/StackReportRow.cs | 58 +++ .../Models/StudySession.cs | 11 + .../Flashcards.Hillgrove/Program.cs | 47 ++ .../Services/FlashcardService.cs | 32 ++ .../Services/IFlashcardService.cs | 9 + .../Services/IReportService.cs | 10 + .../Services/IStackService.cs | 9 + .../Services/IStudySessionService.cs | 10 + .../Services/ReportService.cs | 27 + .../Services/StackService.cs | 28 + .../Services/StudySessionService.cs | 79 +++ .../Flashcards.Hillgrove/appsettings.json | 5 + Flashcards.Hillgrove/sql/create_tables.sql | 22 + Flashcards.Hillgrove/sql/seed_report_data.sql | 104 ++++ 72 files changed, 3483 insertions(+) create mode 100644 Flashcards.Hillgrove/.csharpierrc create mode 100644 Flashcards.Hillgrove/.gitignore create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Flashcards.Hillgrove.Tests.csproj create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/GlobalUsings.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/Actions/Flashcards/DeleteFlashcardActionTests.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/Actions/StudySessions/StartStudySessionActionTests.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/Actions/StudySessions/ViewStudySessionHistoryActionTests.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/CompositeMenuTests.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/MenuComposerTests.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/MenuItemsTests.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Services/FlashcardServiceTests.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Services/StackServiceTests.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Services/StudySessionServiceTests.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove.sln create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Data/FlashcardRepository.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Data/IFlashcardRepository.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Data/IReportRepository.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Data/IStackRepository.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Data/IStudySessionRepository.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Data/ReportRepository.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Data/StackRepository.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Data/StudySessionRepository.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Dtos/FlashCardDto.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Flashcards.Hillgrove.csproj create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Helpers/NavigationResult.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Flashcards/AddFlashCardAction.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Flashcards/DeleteFlashcardAction.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Flashcards/ShowFlashcardsAction.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Reports/ViewAverageScorePerMonthPerStackAction.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Reports/ViewSessionsPerMonthPerStackAction.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Stacks/DeleteStackAction.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Stacks/IStackAction.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/StudySessions/StartStudySessionAction.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/StudySessions/ViewStudySessionHistoryAction.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/CompositeMenu.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/ExitSectionProvider.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/IMainMenuSectionProvider.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/ManageFlashcardsSectionProvider.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/ManageStacksSectionProvider.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/ReportsSectionProvider.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/StudySessionsSectionProvider.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/IAppUi.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/IMenuItem.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/IMenuPrompt.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/BackMenuItem.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/ExitMenuItem.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Flashcards/AddFlashcardToExistingStackMenuItem.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Reports/ViewAverageScorePerMonthPerStackMenuItem.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Reports/ViewSessionsPerMonthPerStackMenuItem.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Stacks/CreateStackMenuItem.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Stacks/ShowAllStacksMenuItem.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/StudySessions/StartStudySessionMenuItem.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/StudySessions/ViewStudySessionHistoryMenuItem.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/MenuComposer.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/SpectreAppUi.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/SpectreMenuPrompt.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Models/Flashcard.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Models/Stack.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Models/StackReportRow.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Models/StudySession.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Program.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Services/FlashcardService.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Services/IFlashcardService.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Services/IReportService.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Services/IStackService.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Services/IStudySessionService.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Services/ReportService.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Services/StackService.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/Services/StudySessionService.cs create mode 100644 Flashcards.Hillgrove/Flashcards.Hillgrove/appsettings.json create mode 100644 Flashcards.Hillgrove/sql/create_tables.sql create mode 100644 Flashcards.Hillgrove/sql/seed_report_data.sql diff --git a/Flashcards.Hillgrove/.csharpierrc b/Flashcards.Hillgrove/.csharpierrc new file mode 100644 index 00000000..234ca19a --- /dev/null +++ b/Flashcards.Hillgrove/.csharpierrc @@ -0,0 +1,3 @@ +{ + "printWidth": 100 +} \ No newline at end of file diff --git a/Flashcards.Hillgrove/.gitignore b/Flashcards.Hillgrove/.gitignore new file mode 100644 index 00000000..0808c4ad --- /dev/null +++ b/Flashcards.Hillgrove/.gitignore @@ -0,0 +1,482 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea/ + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Flashcards.Hillgrove.Tests.csproj b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Flashcards.Hillgrove.Tests.csproj new file mode 100644 index 00000000..93a5c746 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Flashcards.Hillgrove.Tests.csproj @@ -0,0 +1,18 @@ + + + net8.0 + false + enable + enable + + + + + + + + + + + + diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/GlobalUsings.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/GlobalUsings.cs new file mode 100644 index 00000000..c802f448 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/Actions/Flashcards/DeleteFlashcardActionTests.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/Actions/Flashcards/DeleteFlashcardActionTests.cs new file mode 100644 index 00000000..d58b8e2f --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/Actions/Flashcards/DeleteFlashcardActionTests.cs @@ -0,0 +1,139 @@ +using Flashcards.Hillgrove.Data; +using Flashcards.Hillgrove.Dtos; +using Flashcards.Hillgrove.Menu; +using Flashcards.Hillgrove.Menu.Actions.Flashcards; +using Flashcards.Hillgrove.Models; + +namespace Flashcards.Hillgrove.Tests.Menu.Actions.Flashcards; + +public class DeleteFlashcardActionTests +{ + [Fact] + public async Task ExecuteAsync_DeletesCardByMappedDisplayIndex_WhenSelectionIsValid() + { + var repository = new FakeFlashcardRepository( + [ + new Flashcard { Id = 11, StackId = 3, Question = "Q1", Answer = "A1" }, + new Flashcard { Id = 25, StackId = 3, Question = "Q2", Answer = "A2" }, + ] + ); + var ui = new FakeAppUi(promptInputs: ["2"], confirmResults: [true]); + var action = new DeleteFlashcardAction(repository, ui); + + await action.ExecuteAsync(new Stack { Id = 3, Name = "Math" }); + + Assert.Equal([25], repository.DeletedIds); + Assert.Equal(2, ui.ShownTables.Count); + Assert.Equal([1, 2], ui.ShownTables[0].Select(c => c.DisplayIndex).ToArray()); + Assert.Equal([1], ui.ShownTables[1].Select(c => c.DisplayIndex).ToArray()); + } + + [Fact] + public async Task ExecuteAsync_DoesNotDelete_WhenSelectedDisplayIndexIsInvalid() + { + var repository = new FakeFlashcardRepository( + [new Flashcard { Id = 11, StackId = 3, Question = "Q1", Answer = "A1" }] + ); + var ui = new FakeAppUi(promptInputs: ["9", ""], confirmResults: []); + var action = new DeleteFlashcardAction(repository, ui); + + await action.ExecuteAsync(new Stack { Id = 3, Name = "Math" }); + + Assert.Empty(repository.DeletedIds); + Assert.Contains(ui.ErrorMessages, message => message.Contains("Invalid flashcard number.")); + } + + [Fact] + public async Task ExecuteAsync_ShowsWarning_WhenStackHasNoCards() + { + var repository = new FakeFlashcardRepository([]); + var ui = new FakeAppUi(promptInputs: [], confirmResults: []); + var action = new DeleteFlashcardAction(repository, ui); + + await action.ExecuteAsync(new Stack { Id = 3, Name = "Math" }); + + Assert.Empty(repository.DeletedIds); + Assert.Contains(ui.WarningMessages, message => message.Contains("There are no flashcards")); + Assert.Equal(1, ui.WaitForKeyCalls); + } + + private sealed class FakeFlashcardRepository(IEnumerable cards) : IFlashcardRepository + { + private readonly List _cards = cards.ToList(); + + public List DeletedIds { get; } = []; + + public Task> GetByStackIdAsync(int stackId) + { + return Task.FromResult(_cards.Where(card => card.StackId == stackId).AsEnumerable()); + } + + public Task AddAsync(Flashcard card) + { + _cards.Add(card); + return Task.CompletedTask; + } + + public Task DeleteAsync(int id) + { + DeletedIds.Add(id); + _cards.RemoveAll(card => card.Id == id); + return Task.CompletedTask; + } + } + + private sealed class FakeAppUi(IEnumerable promptInputs, IEnumerable confirmResults) + : IAppUi + { + private readonly Queue _promptInputs = new(promptInputs); + private readonly Queue _confirmResults = new(confirmResults); + + public List WarningMessages { get; } = []; + public List ErrorMessages { get; } = []; + public List> ShownTables { get; } = []; + public int WaitForKeyCalls { get; private set; } + + public void Clear() { } + + public void WriteSuccess(string message) { } + + public void WriteWarning(string message) + { + WarningMessages.Add(message); + } + + public void WriteError(string message) + { + ErrorMessages.Add(message); + } + + public void WaitForKey(string message = "[grey]Press any key to continue...[/]") + { + WaitForKeyCalls++; + } + + public string PromptText(string prompt, bool allowEmpty = false) + { + return _promptInputs.Count > 0 ? _promptInputs.Dequeue() : string.Empty; + } + + public bool Confirm(string prompt) + { + return _confirmResults.Count > 0 && _confirmResults.Dequeue(); + } + + public Stack PromptStackSelection(string title, IReadOnlyList stacks) + { + return stacks.First(); + } + + public void ShowFlashcardsTable(string title, IEnumerable cards) + { + ShownTables.Add(cards.ToList()); + } + + public void ShowStudySessionsTable(string title, IEnumerable sessions) { } + + public void ShowStackReportTable(string title, IEnumerable rows) { } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/Actions/StudySessions/StartStudySessionActionTests.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/Actions/StudySessions/StartStudySessionActionTests.cs new file mode 100644 index 00000000..5d8570c7 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/Actions/StudySessions/StartStudySessionActionTests.cs @@ -0,0 +1,39 @@ +using Flashcards.Hillgrove.Menu.Actions.StudySessions; +using Flashcards.Hillgrove.Models; +using Flashcards.Hillgrove.Services; + +namespace Flashcards.Hillgrove.Tests.Menu.Actions.StudySessions; + +public class StartStudySessionActionTests +{ + [Fact] + public async Task ExecuteAsync_DelegatesToStudySessionService_WithSelectedStack() + { + var service = new FakeStudySessionService(); + var action = new StartStudySessionAction(service); + var stack = new Stack { Id = 12, Name = "Math" }; + + await action.ExecuteAsync(stack); + + Assert.Same(stack, service.RunStack); + Assert.Equal(1, service.RunCalls); + } + + private sealed class FakeStudySessionService : IStudySessionService + { + public int RunCalls { get; private set; } + public Stack? RunStack { get; private set; } + + public Task RunAsync(Stack stack) + { + RunCalls++; + RunStack = stack; + return Task.CompletedTask; + } + + public Task> GetHistoryAsync() + { + return Task.FromResult>([]); + } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/Actions/StudySessions/ViewStudySessionHistoryActionTests.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/Actions/StudySessions/ViewStudySessionHistoryActionTests.cs new file mode 100644 index 00000000..994b42f7 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/Actions/StudySessions/ViewStudySessionHistoryActionTests.cs @@ -0,0 +1,95 @@ +using Flashcards.Hillgrove.Dtos; +using Flashcards.Hillgrove.Menu; +using Flashcards.Hillgrove.Menu.Actions.StudySessions; +using Flashcards.Hillgrove.Models; +using Flashcards.Hillgrove.Services; + +namespace Flashcards.Hillgrove.Tests.Menu.Actions.StudySessions; + +public class ViewStudySessionHistoryActionTests +{ + [Fact] + public async Task ExecuteAsync_ShowsHistoryTable_WhenSessionsExist() + { + var sessions = new[] + { + new StudySession { Id = 1, StackId = 3, Date = DateTime.UtcNow, Score = 2 }, + new StudySession { Id = 2, StackId = 4, Date = DateTime.UtcNow.AddDays(-1), Score = 4 }, + }; + var service = new FakeStudySessionService(sessions); + var ui = new FakeAppUi(); + var action = new ViewStudySessionHistoryAction(service, ui); + + await action.ExecuteAsync(); + + Assert.True(ui.HistoryShown); + Assert.Equal(2, ui.ShownSessions.Count); + Assert.Equal(1, ui.WaitForKeyCalls); + Assert.Empty(ui.WarningMessages); + } + + [Fact] + public async Task ExecuteAsync_ShowsWarning_WhenNoSessionsExist() + { + var service = new FakeStudySessionService([]); + var ui = new FakeAppUi(); + var action = new ViewStudySessionHistoryAction(service, ui); + + await action.ExecuteAsync(); + + Assert.False(ui.HistoryShown); + Assert.Contains(ui.WarningMessages, message => message.Contains("There are no study sessions")); + Assert.Equal(1, ui.WaitForKeyCalls); + } + + private sealed class FakeStudySessionService(IEnumerable sessions) : IStudySessionService + { + public Task RunAsync(Stack stack) => Task.CompletedTask; + + public Task> GetHistoryAsync() + { + return Task.FromResult>(sessions.ToList()); + } + } + + private sealed class FakeAppUi : IAppUi + { + public bool HistoryShown { get; private set; } + public List ShownSessions { get; } = []; + public List WarningMessages { get; } = []; + public int WaitForKeyCalls { get; private set; } + + public void Clear() { } + + public void WriteSuccess(string message) { } + + public void WriteWarning(string message) + { + WarningMessages.Add(message); + } + + public void WriteError(string message) { } + + public void WaitForKey(string message = "[grey]Press any key to continue...[/]") + { + WaitForKeyCalls++; + } + + public string PromptText(string prompt, bool allowEmpty = false) => string.Empty; + + public bool Confirm(string prompt) => false; + + public Stack PromptStackSelection(string title, IReadOnlyList stacks) => + stacks.FirstOrDefault() ?? new Stack { Name = string.Empty }; + + public void ShowFlashcardsTable(string title, IEnumerable cards) { } + + public void ShowStudySessionsTable(string title, IEnumerable sessions) + { + HistoryShown = true; + ShownSessions.AddRange(sessions); + } + + public void ShowStackReportTable(string title, IEnumerable rows) { } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/CompositeMenuTests.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/CompositeMenuTests.cs new file mode 100644 index 00000000..8e1d0ff6 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/CompositeMenuTests.cs @@ -0,0 +1,122 @@ +using Flashcards.Hillgrove.Helpers; +using Flashcards.Hillgrove.Menu; + +namespace Flashcards.Hillgrove.Tests.Menu; + +public class CompositeMenuTests +{ + [Fact] + public async Task ExecuteAsync_ReturnsContinue_WhenSelectedItemReturnsBack() + { + var prompt = new FakeMenuPrompt([new FakeMenuItem("Back", NavigationResult.Back)]); + + var sut = new CompositeMenu( + "Any Menu", + [new FakeMenuItem("Ignored", NavigationResult.Continue)], + prompt + ); + + var result = await sut.ExecuteAsync(); + + Assert.Equal(NavigationResult.Continue, result); + Assert.Equal(1, prompt.CallCount); + } + + [Fact] + public async Task ExecuteAsync_ReturnsExit_WhenSelectedItemReturnsExit() + { + var prompt = new FakeMenuPrompt([new FakeMenuItem("Exit", NavigationResult.Exit)]); + + var sut = new CompositeMenu( + "Any Menu", + [new FakeMenuItem("Ignored", NavigationResult.Continue)], + prompt + ); + + var result = await sut.ExecuteAsync(); + + Assert.Equal(NavigationResult.Exit, result); + Assert.Equal(1, prompt.CallCount); + } + + [Fact] + public async Task ExecuteAsync_KeepsLoopingUntilExit_WhenSelectedItemsReturnContinueThenExit() + { + var prompt = new FakeMenuPrompt([ + new FakeMenuItem("Continue", NavigationResult.Continue), + new FakeMenuItem("Exit", NavigationResult.Exit), + ]); + + var sut = new CompositeMenu( + "Any Menu", + [new FakeMenuItem("Ignored", NavigationResult.Continue)], + prompt + ); + + var result = await sut.ExecuteAsync(); + + Assert.Equal(NavigationResult.Exit, result); + Assert.Equal(2, prompt.CallCount); + } + + [Fact] + public async Task ExecuteAsync_ReturnsContinue_WhenPromptReturnsNullChoice() + { + var prompt = new FakeMenuPrompt([null]); + + var sut = new CompositeMenu( + "Any Menu", + [new FakeMenuItem("Ignored", NavigationResult.Continue)], + prompt + ); + + var result = await sut.ExecuteAsync(); + + Assert.Equal(NavigationResult.Continue, result); + Assert.Equal(1, prompt.CallCount); + } + + private sealed class FakeMenuPrompt : IMenuPrompt + { + private readonly Queue _choices; + + public int CallCount { get; private set; } + + public FakeMenuPrompt(IEnumerable choices) + { + _choices = new Queue(choices); + } + + public void Clear() + { + // do nothing + } + + public Task PromptAsync(string title, IReadOnlyList items) + { + CallCount++; + + if (_choices.Count == 0) + { + throw new InvalidOperationException("No more queued choices in FakeMenuPrompt."); + } + + return Task.FromResult(_choices.Dequeue()); + } + } + + private sealed class FakeMenuItem : IMenuItem + { + private readonly NavigationResult _result; + + public string Label { get; } + + public FakeMenuItem(string label, NavigationResult result) + { + Label = label; + _result = result; + } + + public Task ExecuteAsync() => Task.FromResult(_result); + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/MenuComposerTests.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/MenuComposerTests.cs new file mode 100644 index 00000000..a3bf4954 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/MenuComposerTests.cs @@ -0,0 +1,256 @@ +using Flashcards.Hillgrove.Data; +using Flashcards.Hillgrove.Dtos; +using Flashcards.Hillgrove.Helpers; +using Flashcards.Hillgrove.Menu; +using Flashcards.Hillgrove.Menu.Composition; +using Flashcards.Hillgrove.Models; +using Flashcards.Hillgrove.Services; + +namespace Flashcards.Hillgrove.Tests.Menu; + +public class MenuComposerTests +{ + [Fact] + public void Build_ComposesMenuFromProvidersInOrder_WhenProvidersAreRegistered() + { + var composer = new MenuComposer( + [ + new FakeMainMenuSectionProvider("Second", 2), + new FakeMainMenuSectionProvider("First", 1), + ], + new FakeMenuPrompt() + ); + + var menu = composer.Build(); + + var root = Assert.IsType(menu); + var labels = root.Items.Select(i => i.Label).ToArray(); + + Assert.Equal(["First", "Second"], labels); + } + + [Fact] + public void Build_IncludesNewProviderContribution_WithoutChangingComposer() + { + var composer = new MenuComposer( + [ + new FakeMainMenuSectionProvider("Manage Stacks", 1), + new FakeMainMenuSectionProvider("Custom Reports", 2), + ], + new FakeMenuPrompt() + ); + + var menu = composer.Build(); + + var root = Assert.IsType(menu); + Assert.Contains(root.Items, i => i.Label == "Custom Reports"); + } + + [Fact] + public void Build_ReturnsMainMenuWithExpectedTopLevelLabels_WhenComposingDefaultProviders() + { + var menu = BuildMenuFromDefaultProviders(); + + var root = Assert.IsType(menu); + var labels = root.Items.Select(i => i.Label).ToArray(); + + Assert.Equal(["Manage Stacks", "Manage Flashcards", "Study Sessions", "Reports", "Exit"], labels); + } + + [Fact] + public void Build_ReturnsManageStacksMenuWithExpectedItemsInOrder_WhenComposingDefaultProviders() + { + var menu = BuildMenuFromDefaultProviders(); + + var root = Assert.IsType(menu); + var manageStacks = Assert.IsType( + root.Items.Single(i => i.Label == "Manage Stacks") + ); + var labels = manageStacks.Items.Select(i => i.Label).ToArray(); + + Assert.Equal(["Show All Stacks", "Create a new stack", "Delete a stack", "Back"], labels); + } + + [Fact] + public void Build_ReturnsManageFlashcardsMenuWithExpectedItemsInOrder_WhenComposingDefaultProviders() + { + var menu = BuildMenuFromDefaultProviders(); + + var root = Assert.IsType(menu); + var manageFlashcards = Assert.IsType( + root.Items.Single(i => i.Label == "Manage Flashcards") + ); + var labels = manageFlashcards.Items.Select(i => i.Label).ToArray(); + + Assert.Equal( + [ + "Show All Stacks", + "Add Flashcard to Existing Stack", + "Delete Flashcard from Stack", + "Back", + ], + labels + ); + } + + [Fact] + public void Build_ReturnsStudySessionsMenuWithExpectedItemsInOrder_WhenComposingDefaultProviders() + { + var menu = BuildMenuFromDefaultProviders(); + + var root = Assert.IsType(menu); + var studySessions = Assert.IsType( + root.Items.Single(i => i.Label == "Study Sessions") + ); + var labels = studySessions.Items.Select(i => i.Label).ToArray(); + + Assert.Equal(["Start Study Session", "View Study Session History", "Back"], labels); + } + + [Fact] + public void Build_ReturnsReportsMenuWithExpectedItemsInOrder_WhenComposingDefaultProviders() + { + var menu = BuildMenuFromDefaultProviders(); + + var root = Assert.IsType(menu); + var reports = Assert.IsType(root.Items.Single(i => i.Label == "Reports")); + var labels = reports.Items.Select(i => i.Label).ToArray(); + + Assert.Equal( + [ + "Sessions per Month per Stack", + "Average Score per Month per Stack", + "Back", + ], + labels + ); + } + + private static IMenuItem BuildMenuFromDefaultProviders() + { + var stackRepository = new FakeStackRepository(); + var flashcardRepository = new FakeFlashcardRepository(); + var stackService = new StackService(stackRepository); + var flashcardService = new FlashcardService(flashcardRepository); + var studySessionService = new FakeStudySessionService(); + var reportService = new FakeReportService(); + var ui = new FakeAppUi(); + var menuPrompt = new FakeMenuPrompt(); + + var providers = new IMainMenuSectionProvider[] + { + new ManageStacksSectionProvider( + flashcardRepository, + stackRepository, + stackService, + flashcardService, + ui, + menuPrompt + ), + new ManageFlashcardsSectionProvider( + stackRepository, + flashcardRepository, + flashcardService, + ui, + menuPrompt + ), + new StudySessionsSectionProvider(stackRepository, studySessionService, ui, menuPrompt), + new ReportsSectionProvider(reportService, ui, menuPrompt), + new ExitSectionProvider(), + }; + + var composer = new MenuComposer(providers, menuPrompt); + return composer.Build(); + } + + private sealed class FakeStudySessionService : IStudySessionService + { + public Task RunAsync(Stack stack) => Task.CompletedTask; + + public Task> GetHistoryAsync() + { + return Task.FromResult>([]); + } + } + + private sealed class FakeReportService : IReportService + { + public Task> GetSessionsPerMonthPerStackAsync(int year) + { + return Task.FromResult>([]); + } + + public Task> GetAverageScorePerMonthPerStackAsync(int year) + { + return Task.FromResult>([]); + } + } + + private sealed class FakeMainMenuSectionProvider(string label, int order) + : IMainMenuSectionProvider + { + public int Order => order; + + public IMenuItem Create() => new FakeMenuItem(label); + } + + private sealed class FakeMenuItem(string label) : IMenuItem + { + public string Label => label; + + public Task ExecuteAsync() => Task.FromResult(NavigationResult.Continue); + } + + private sealed class FakeAppUi : IAppUi + { + public void Clear() { } + + public void WriteSuccess(string message) { } + + public void WriteWarning(string message) { } + + public void WriteError(string message) { } + + public void WaitForKey(string message = "[grey]Press any key to continue...[/]") { } + + public string PromptText(string prompt, bool allowEmpty = false) => string.Empty; + + public bool Confirm(string prompt) => false; + + public Stack PromptStackSelection(string title, IReadOnlyList stacks) => + stacks.FirstOrDefault() ?? new Stack { Name = string.Empty }; + + public void ShowFlashcardsTable(string title, IEnumerable cards) { } + + public void ShowStudySessionsTable(string title, IEnumerable sessions) { } + + public void ShowStackReportTable(string title, IEnumerable rows) { } + } + + private sealed class FakeMenuPrompt : IMenuPrompt + { + public void Clear() { } + + public Task PromptAsync(string title, IReadOnlyList items) => + Task.FromResult(null); + } + + private sealed class FakeStackRepository : IStackRepository + { + public Task> GetAllAsync() => Task.FromResult(Enumerable.Empty()); + + public Task AddAsync(Stack stack) => Task.FromResult(stack); + + public Task DeleteAsync(int id) => Task.CompletedTask; + } + + private sealed class FakeFlashcardRepository : IFlashcardRepository + { + public Task> GetByStackIdAsync(int stackId) => + Task.FromResult(Enumerable.Empty()); + + public Task AddAsync(Flashcard card) => Task.CompletedTask; + + public Task DeleteAsync(int id) => Task.CompletedTask; + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/MenuItemsTests.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/MenuItemsTests.cs new file mode 100644 index 00000000..5055bcd5 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Menu/MenuItemsTests.cs @@ -0,0 +1,27 @@ +using Flashcards.Hillgrove.Helpers; +using Flashcards.Hillgrove.Menu.Items; + +namespace Flashcards.Hillgrove.Tests.Menu; + +public class MenuItemsTests +{ + [Fact] + public async Task ExecuteAsync_ReturnsBack_WhenMenuItemIsBack() + { + var item = new BackMenuItem(); + + var result = await item.ExecuteAsync(); + + Assert.Equal(NavigationResult.Back, result); + } + + [Fact] + public async Task ExecuteAsync_ReturnsExit_WhenMenuItemIsExit() + { + var item = new ExitMenuItem(); + + var result = await item.ExecuteAsync(); + + Assert.Equal(NavigationResult.Exit, result); + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Services/FlashcardServiceTests.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Services/FlashcardServiceTests.cs new file mode 100644 index 00000000..d9898d9f --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Services/FlashcardServiceTests.cs @@ -0,0 +1,90 @@ +using Flashcards.Hillgrove.Data; +using Flashcards.Hillgrove.Models; +using Flashcards.Hillgrove.Services; + +namespace Flashcards.Hillgrove.Tests.Services; + +public class FlashcardServiceTests +{ + [Fact] + public async Task GetByStackIdAsync_ReturnsMappedDtos_WhenRepositoryContainsCards() + { + var repository = new FakeFlashcardRepository + { + CardsByStackId = new Dictionary> + { + [5] = + [ + new Flashcard { Id = 1, StackId = 5, Question = "Q1", Answer = "A1" }, + new Flashcard { Id = 2, StackId = 5, Question = "Q2", Answer = "A2" }, + ], + }, + }; + + var service = new FlashcardService(repository); + + var result = (await service.GetByStackIdAsync(5)).ToList(); + + Assert.Equal(5, repository.LastRequestedStackId); + Assert.Equal(2, result.Count); + Assert.Equal(1, result[0].DisplayIndex); + Assert.Equal("Q1", result[0].Question); + Assert.Equal("A1", result[0].Answer); + Assert.Equal(2, result[1].DisplayIndex); + Assert.Equal("Q2", result[1].Question); + Assert.Equal("A2", result[1].Answer); + } + + [Fact] + public async Task GetByStackIdAsync_ReturnsContiguousDisplayIndexes_WhenSourceIdsHaveGaps() + { + var repository = new FakeFlashcardRepository + { + CardsByStackId = new Dictionary> + { + [10] = + [ + new Flashcard { Id = 1, StackId = 10, Question = "Q1", Answer = "A1" }, + new Flashcard { Id = 3, StackId = 10, Question = "Q3", Answer = "A3" }, + ], + }, + }; + + var service = new FlashcardService(repository); + + var result = (await service.GetByStackIdAsync(10)).ToList(); + + Assert.Equal(2, result.Count); + Assert.Equal(1, result[0].DisplayIndex); + Assert.Equal(2, result[1].DisplayIndex); + } + + [Fact] + public async Task GetByStackIdAsync_ReturnsEmptyCollection_WhenRepositoryHasNoCards() + { + var repository = new FakeFlashcardRepository(); + var service = new FlashcardService(repository); + + var result = await service.GetByStackIdAsync(999); + + Assert.Equal(999, repository.LastRequestedStackId); + Assert.Empty(result); + } + + private sealed class FakeFlashcardRepository : IFlashcardRepository + { + public Dictionary> CardsByStackId { get; set; } = []; + public int? LastRequestedStackId { get; private set; } + + public Task> GetByStackIdAsync(int stackId) + { + LastRequestedStackId = stackId; + CardsByStackId.TryGetValue(stackId, out var cards); + return Task.FromResult(cards ?? Enumerable.Empty()); + } + + public Task AddAsync(Flashcard card) => Task.CompletedTask; + + public Task DeleteAsync(int id) => Task.CompletedTask; + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Services/StackServiceTests.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Services/StackServiceTests.cs new file mode 100644 index 00000000..486d2572 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Services/StackServiceTests.cs @@ -0,0 +1,58 @@ +using Flashcards.Hillgrove.Data; +using Flashcards.Hillgrove.Models; +using Flashcards.Hillgrove.Services; + +namespace Flashcards.Hillgrove.Tests.Services; + +public class StackServiceTests +{ + [Fact] + public async Task AddAsync_ReturnsNull_WhenStackNameAlreadyExists() + { + var existingStack = new Stack { Id = 1, Name = "Geography" }; + var repository = new FakeStackRepository { AllStacks = [existingStack] }; + + var service = new StackService(repository); + + var result = await service.AddAsync(new Stack { Name = "Geography" }); + + Assert.Null(result); + Assert.False(repository.AddCalled); + } + + [Fact] + public async Task AddAsync_ReturnsAddedStack_WhenStackNameIsUnique() + { + var repository = new FakeStackRepository + { + AllStacks = [new Stack { Id = 1, Name = "Math" }], + StackToReturnFromAdd = new Stack { Id = 2, Name = "Science" }, + }; + + var service = new StackService(repository); + + var result = await service.AddAsync(new Stack { Name = "Science" }); + + Assert.NotNull(result); + Assert.True(repository.AddCalled); + Assert.Equal("Science", result!.Name); + Assert.Equal(2, result.Id); + } + + private sealed class FakeStackRepository : IStackRepository + { + public IEnumerable AllStacks { get; set; } = []; + public Stack StackToReturnFromAdd { get; set; } = new Stack { Id = 1, Name = "Default" }; + public bool AddCalled { get; private set; } + + public Task> GetAllAsync() => Task.FromResult(AllStacks); + + public Task AddAsync(Stack stack) + { + AddCalled = true; + return Task.FromResult(StackToReturnFromAdd); + } + + public Task DeleteAsync(int id) => Task.CompletedTask; + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Services/StudySessionServiceTests.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Services/StudySessionServiceTests.cs new file mode 100644 index 00000000..7b4ef90c --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove.Tests/Services/StudySessionServiceTests.cs @@ -0,0 +1,138 @@ +using Flashcards.Hillgrove.Data; +using Flashcards.Hillgrove.Dtos; +using Flashcards.Hillgrove.Menu; +using Flashcards.Hillgrove.Models; +using Flashcards.Hillgrove.Services; + +namespace Flashcards.Hillgrove.Tests.Services; + +public class StudySessionServiceTests +{ + [Fact] + public async Task RunAsync_SavesOneStudySessionWithCalculatedScore_WhenFlashcardsExist() + { + var flashcardRepository = new FakeFlashcardRepository( + [ + new Flashcard { Id = 1, StackId = 8, Question = "Q1", Answer = "A1" }, + new Flashcard { Id = 2, StackId = 8, Question = "Q2", Answer = "A2" }, + ] + ); + var studySessionRepository = new FakeStudySessionRepository(); + var ui = new FakeAppUi(promptInputs: ["a1", "wrong"]); + var service = new StudySessionService(flashcardRepository, studySessionRepository, ui); + var beforeRun = DateTime.UtcNow; + + await service.RunAsync(new Stack { Id = 8, Name = "Science" }); + + Assert.Single(studySessionRepository.AddedSessions); + var saved = studySessionRepository.AddedSessions[0]; + Assert.Equal(8, saved.StackId); + Assert.Equal(1, saved.Score); + Assert.True(saved.Date >= beforeRun); + Assert.True(saved.Date <= DateTime.UtcNow.AddSeconds(1)); + } + + [Fact] + public async Task RunAsync_DoesNotSaveStudySession_WhenNoFlashcardsExist() + { + var flashcardRepository = new FakeFlashcardRepository([]); + var studySessionRepository = new FakeStudySessionRepository(); + var ui = new FakeAppUi(promptInputs: []); + var service = new StudySessionService(flashcardRepository, studySessionRepository, ui); + + await service.RunAsync(new Stack { Id = 8, Name = "Science" }); + + Assert.Empty(studySessionRepository.AddedSessions); + Assert.Contains(ui.WarningMessages, message => message.Contains("There are no flashcards")); + } + + [Fact] + public async Task GetHistoryAsync_ReturnsSessionsFromRepository() + { + var expectedSessions = new[] + { + new StudySession { Id = 1, StackId = 8, Date = DateTime.UtcNow.AddDays(-1), Score = 3 }, + new StudySession { Id = 2, StackId = 9, Date = DateTime.UtcNow, Score = 5 }, + }; + var flashcardRepository = new FakeFlashcardRepository([]); + var studySessionRepository = new FakeStudySessionRepository(historySessions: expectedSessions); + var ui = new FakeAppUi(promptInputs: []); + var service = new StudySessionService(flashcardRepository, studySessionRepository, ui); + + var history = await service.GetHistoryAsync(); + + Assert.Equal(2, history.Count); + Assert.Equal(expectedSessions.Select(s => s.Id), history.Select(s => s.Id)); + } + + private sealed class FakeFlashcardRepository(IEnumerable cards) : IFlashcardRepository + { + private readonly List _cards = cards.ToList(); + + public Task> GetByStackIdAsync(int stackId) + { + return Task.FromResult(_cards.Where(c => c.StackId == stackId).AsEnumerable()); + } + + public Task AddAsync(Flashcard card) => Task.CompletedTask; + + public Task DeleteAsync(int id) => Task.CompletedTask; + } + + private sealed class FakeStudySessionRepository(IEnumerable? historySessions = null) + : IStudySessionRepository + { + private readonly List _historySessions = historySessions?.ToList() ?? []; + + public List AddedSessions { get; } = []; + + public Task AddAsync(StudySession studySession) + { + AddedSessions.Add(studySession); + return Task.CompletedTask; + } + + public Task> GetAllAsync() + { + return Task.FromResult(_historySessions.AsEnumerable()); + } + } + + private sealed class FakeAppUi(IEnumerable promptInputs) : IAppUi + { + private readonly Queue _promptInputs = new(promptInputs); + + public List WarningMessages { get; } = []; + + public void Clear() { } + + public void WriteSuccess(string message) { } + + public void WriteWarning(string message) + { + WarningMessages.Add(message); + } + + public void WriteError(string message) { } + + public void WaitForKey(string message = "[grey]Press any key to continue...[/]") { } + + public string PromptText(string prompt, bool allowEmpty = false) + { + return _promptInputs.Count > 0 ? _promptInputs.Dequeue() : string.Empty; + } + + public bool Confirm(string prompt) => false; + + public Stack PromptStackSelection(string title, IReadOnlyList stacks) + { + return stacks.FirstOrDefault() ?? new Stack { Name = string.Empty }; + } + + public void ShowFlashcardsTable(string title, IEnumerable cards) { } + + public void ShowStudySessionsTable(string title, IEnumerable sessions) { } + + public void ShowStackReportTable(string title, IEnumerable rows) { } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove.sln b/Flashcards.Hillgrove/Flashcards.Hillgrove.sln new file mode 100644 index 00000000..bcd3867e --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove.sln @@ -0,0 +1,65 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36930.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flashcards.Hillgrove", "Flashcards.Hillgrove\Flashcards.Hillgrove.csproj", "{E08B2448-5E73-4A0D-8C46-E8DC04E6E7C4}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}" + ProjectSection(SolutionItems) = preProject + .csharpierrc = .csharpierrc + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "sql", "sql", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" + ProjectSection(SolutionItems) = preProject + sql\create_tables.sql = sql\create_tables.sql + sql\seed_report_data.sql = sql\seed_report_data.sql + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flashcards.Hillgrove.Tests", "Flashcards.Hillgrove.Tests\Flashcards.Hillgrove.Tests.csproj", "{CA996EEE-71B5-437E-80F0-41CBAF45BA07}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {E08B2448-5E73-4A0D-8C46-E8DC04E6E7C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E08B2448-5E73-4A0D-8C46-E8DC04E6E7C4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E08B2448-5E73-4A0D-8C46-E8DC04E6E7C4}.Debug|x64.ActiveCfg = Debug|Any CPU + {E08B2448-5E73-4A0D-8C46-E8DC04E6E7C4}.Debug|x64.Build.0 = Debug|Any CPU + {E08B2448-5E73-4A0D-8C46-E8DC04E6E7C4}.Debug|x86.ActiveCfg = Debug|Any CPU + {E08B2448-5E73-4A0D-8C46-E8DC04E6E7C4}.Debug|x86.Build.0 = Debug|Any CPU + {E08B2448-5E73-4A0D-8C46-E8DC04E6E7C4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E08B2448-5E73-4A0D-8C46-E8DC04E6E7C4}.Release|Any CPU.Build.0 = Release|Any CPU + {E08B2448-5E73-4A0D-8C46-E8DC04E6E7C4}.Release|x64.ActiveCfg = Release|Any CPU + {E08B2448-5E73-4A0D-8C46-E8DC04E6E7C4}.Release|x64.Build.0 = Release|Any CPU + {E08B2448-5E73-4A0D-8C46-E8DC04E6E7C4}.Release|x86.ActiveCfg = Release|Any CPU + {E08B2448-5E73-4A0D-8C46-E8DC04E6E7C4}.Release|x86.Build.0 = Release|Any CPU + {CA996EEE-71B5-437E-80F0-41CBAF45BA07}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CA996EEE-71B5-437E-80F0-41CBAF45BA07}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CA996EEE-71B5-437E-80F0-41CBAF45BA07}.Debug|x64.ActiveCfg = Debug|Any CPU + {CA996EEE-71B5-437E-80F0-41CBAF45BA07}.Debug|x64.Build.0 = Debug|Any CPU + {CA996EEE-71B5-437E-80F0-41CBAF45BA07}.Debug|x86.ActiveCfg = Debug|Any CPU + {CA996EEE-71B5-437E-80F0-41CBAF45BA07}.Debug|x86.Build.0 = Debug|Any CPU + {CA996EEE-71B5-437E-80F0-41CBAF45BA07}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CA996EEE-71B5-437E-80F0-41CBAF45BA07}.Release|Any CPU.Build.0 = Release|Any CPU + {CA996EEE-71B5-437E-80F0-41CBAF45BA07}.Release|x64.ActiveCfg = Release|Any CPU + {CA996EEE-71B5-437E-80F0-41CBAF45BA07}.Release|x64.Build.0 = Release|Any CPU + {CA996EEE-71B5-437E-80F0-41CBAF45BA07}.Release|x86.ActiveCfg = Release|Any CPU + {CA996EEE-71B5-437E-80F0-41CBAF45BA07}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {8EC462FD-D22E-90A8-E5CE-7E832BA40C5D} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {407D08FC-66A8-4634-9601-175DEFF67F99} + EndGlobalSection +EndGlobal diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/FlashcardRepository.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/FlashcardRepository.cs new file mode 100644 index 00000000..74b77569 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/FlashcardRepository.cs @@ -0,0 +1,45 @@ +using System.Data; +using Dapper; +using Flashcards.Hillgrove.Models; + +namespace Flashcards.Hillgrove.Data +{ + internal class FlashcardRepository : IFlashcardRepository + { + private readonly IDbConnection _connection; + + public FlashcardRepository(IDbConnection connection) + { + _connection = connection; + } + + public async Task> GetByStackIdAsync(int stackId) + { + var sql = + "SELECT Id, Question, Answer FROM FlashCard WHERE StackId = @StackId ORDER BY Id"; + return await _connection.QueryAsync(sql, new { StackId = stackId }); + } + + public async Task AddAsync(Flashcard card) + { + var sql = + @"INSERT INTO FlashCard (StackId, Question, Answer) + VALUES (@StackId, @Question, @Answer)"; + await _connection.ExecuteAsync( + sql, + new + { + card.StackId, + card.Question, + card.Answer, + } + ); + } + + public async Task DeleteAsync(int id) + { + var sql = "DELETE FROM FlashCard WHERE Id = @Id"; + await _connection.ExecuteAsync(sql, new { Id = id }); + } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/IFlashcardRepository.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/IFlashcardRepository.cs new file mode 100644 index 00000000..a70a0476 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/IFlashcardRepository.cs @@ -0,0 +1,11 @@ +using Flashcards.Hillgrove.Models; + +namespace Flashcards.Hillgrove.Data +{ + internal interface IFlashcardRepository + { + Task> GetByStackIdAsync(int stackId); + Task AddAsync(Flashcard card); + Task DeleteAsync(int id); + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/IReportRepository.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/IReportRepository.cs new file mode 100644 index 00000000..12004f21 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/IReportRepository.cs @@ -0,0 +1,10 @@ +using Flashcards.Hillgrove.Models; + +namespace Flashcards.Hillgrove.Data +{ + internal interface IReportRepository + { + Task> GetSessionsPerMonthPerStackAsync(int year); + Task> GetAverageScorePerMonthPerStackAsync(int year); + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/IStackRepository.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/IStackRepository.cs new file mode 100644 index 00000000..0a8fbdbf --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/IStackRepository.cs @@ -0,0 +1,11 @@ +using Flashcards.Hillgrove.Models; + +namespace Flashcards.Hillgrove.Data +{ + internal interface IStackRepository + { + Task> GetAllAsync(); + Task AddAsync(Stack stack); + Task DeleteAsync(int id); + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/IStudySessionRepository.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/IStudySessionRepository.cs new file mode 100644 index 00000000..b211b8e0 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/IStudySessionRepository.cs @@ -0,0 +1,10 @@ +using Flashcards.Hillgrove.Models; + +namespace Flashcards.Hillgrove.Data +{ + internal interface IStudySessionRepository + { + Task AddAsync(StudySession studySession); + Task> GetAllAsync(); + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/ReportRepository.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/ReportRepository.cs new file mode 100644 index 00000000..badbe5d8 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/ReportRepository.cs @@ -0,0 +1,90 @@ +using System.Data; +using Dapper; +using Flashcards.Hillgrove.Models; + +namespace Flashcards.Hillgrove.Data +{ + internal class ReportRepository : IReportRepository + { + private readonly IDbConnection _connection; + + public ReportRepository(IDbConnection connection) + { + _connection = connection; + } + + public async Task> GetSessionsPerMonthPerStackAsync(int year) + { + var sql = + @" + SELECT + StackName, + ISNULL(CAST([1] AS INT), 0) AS January, + ISNULL(CAST([2] AS INT), 0) AS February, + ISNULL(CAST([3] AS INT), 0) AS March, + ISNULL(CAST([4] AS INT), 0) AS April, + ISNULL(CAST([5] AS INT), 0) AS May, + ISNULL(CAST([6] AS INT), 0) AS June, + ISNULL(CAST([7] AS INT), 0) AS July, + ISNULL(CAST([8] AS INT), 0) AS August, + ISNULL(CAST([9] AS INT), 0) AS September, + ISNULL(CAST([10] AS INT), 0) AS October, + ISNULL(CAST([11] AS INT), 0) AS November, + ISNULL(CAST([12] AS INT), 0) AS December + FROM ( + SELECT + s.Name AS StackName, + MONTH(ss.Date) AS Month, + COUNT(ss.Id) AS SessionCount + FROM Stack s + LEFT JOIN StudySession ss ON s.Id = ss.StackId AND YEAR(ss.Date) = @Year + GROUP BY s.Name, MONTH(ss.Date) + ) SourceTable + PIVOT ( + SUM(SessionCount) + FOR [Month] IN ([1], [2], [3], [4], [5], [6], [7], [8], [9], [10], [11], [12]) + ) PivotTable + ORDER BY StackName"; + + return await _connection.QueryAsync(sql, new { Year = year }); + } + + public async Task> GetAverageScorePerMonthPerStackAsync( + int year + ) + { + var sql = + @" + SELECT + StackName, + ISNULL(ROUND([1], 2), 0) AS January, + ISNULL(ROUND([2], 2), 0) AS February, + ISNULL(ROUND([3], 2), 0) AS March, + ISNULL(ROUND([4], 2), 0) AS April, + ISNULL(ROUND([5], 2), 0) AS May, + ISNULL(ROUND([6], 2), 0) AS June, + ISNULL(ROUND([7], 2), 0) AS July, + ISNULL(ROUND([8], 2), 0) AS August, + ISNULL(ROUND([9], 2), 0) AS September, + ISNULL(ROUND([10], 2), 0) AS October, + ISNULL(ROUND([11], 2), 0) AS November, + ISNULL(ROUND([12], 2), 0) AS December + FROM ( + SELECT + s.Name AS StackName, + MONTH(ss.Date) AS Month, + AVG(CAST(ss.Score AS FLOAT)) AS AverageScore + FROM Stack s + LEFT JOIN StudySession ss ON s.Id = ss.StackId AND YEAR(ss.Date) = @Year + GROUP BY s.Name, MONTH(ss.Date) + ) SourceTable + PIVOT ( + AVG(AverageScore) + FOR [Month] IN ([1], [2], [3], [4], [5], [6], [7], [8], [9], [10], [11], [12]) + ) PivotTable + ORDER BY StackName"; + + return await _connection.QueryAsync(sql, new { Year = year }); + } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/StackRepository.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/StackRepository.cs new file mode 100644 index 00000000..611db2e7 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/StackRepository.cs @@ -0,0 +1,36 @@ +using System.Data; +using Dapper; +using Flashcards.Hillgrove.Models; + +namespace Flashcards.Hillgrove.Data +{ + internal class StackRepository : IStackRepository + { + private readonly IDbConnection _connection; + + public StackRepository(IDbConnection connection) + { + _connection = connection; + } + + public async Task> GetAllAsync() + { + var sql = "SELECT Id, Name FROM Stack"; + return await _connection.QueryAsync(sql); + } + + public async Task AddAsync(Stack stack) + { + var sql = + @"INSERT INTO Stack (Name) VALUES (@Name); + SELECT Id, Name FROM Stack WHERE Id = SCOPE_IDENTITY();"; + return await _connection.QuerySingleAsync(sql, new { stack.Name }); + } + + public async Task DeleteAsync(int id) + { + var sql = "DELETE FROM Stack WHERE Id = @Id"; + await _connection.ExecuteAsync(sql, new { Id = id }); + } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/StudySessionRepository.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/StudySessionRepository.cs new file mode 100644 index 00000000..3844d998 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Data/StudySessionRepository.cs @@ -0,0 +1,44 @@ +using System.Data; +using Dapper; +using Flashcards.Hillgrove.Models; + +namespace Flashcards.Hillgrove.Data +{ + internal class StudySessionRepository : IStudySessionRepository + { + private readonly IDbConnection _connection; + + public StudySessionRepository(IDbConnection connection) + { + _connection = connection; + } + + public async Task AddAsync(StudySession studySession) + { + var sql = + @"INSERT INTO StudySession (StackId, Date, Score) + VALUES (@StackId, @Date, @Score)"; + + await _connection.ExecuteAsync( + sql, + new + { + studySession.StackId, + studySession.Date, + studySession.Score, + } + ); + } + + public async Task> GetAllAsync() + { + var sql = + @"SELECT ss.Id, ss.StackId, s.Name AS StackName, ss.Date, ss.Score + FROM StudySession ss + INNER JOIN Stack s ON s.Id = ss.StackId + ORDER BY ss.Date DESC, ss.Id DESC"; + + return await _connection.QueryAsync(sql); + } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Dtos/FlashCardDto.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Dtos/FlashCardDto.cs new file mode 100644 index 00000000..304e568b --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Dtos/FlashCardDto.cs @@ -0,0 +1,9 @@ +namespace Flashcards.Hillgrove.Dtos +{ + internal class FlashCardDto + { + public int DisplayIndex { get; set; } + public required string Question { get; set; } + public required string Answer { get; set; } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Flashcards.Hillgrove.csproj b/Flashcards.Hillgrove/Flashcards.Hillgrove/Flashcards.Hillgrove.csproj new file mode 100644 index 00000000..b968e7a0 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Flashcards.Hillgrove.csproj @@ -0,0 +1,31 @@ + + + Exe + net8.0 + enable + enable + + + + + <_Parameter1>Flashcards.Hillgrove.Tests + + + + + + + + + + + + + + + + + PreserveNewest + + + diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Helpers/NavigationResult.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Helpers/NavigationResult.cs new file mode 100644 index 00000000..82f97817 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Helpers/NavigationResult.cs @@ -0,0 +1,9 @@ +namespace Flashcards.Hillgrove.Helpers +{ + internal enum NavigationResult + { + Continue, + Back, + Exit, + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Flashcards/AddFlashCardAction.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Flashcards/AddFlashCardAction.cs new file mode 100644 index 00000000..e9b0b16c --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Flashcards/AddFlashCardAction.cs @@ -0,0 +1,52 @@ +using Flashcards.Hillgrove.Data; +using Flashcards.Hillgrove.Menu.Actions.Stacks; +using Flashcards.Hillgrove.Models; + +namespace Flashcards.Hillgrove.Menu.Actions.Flashcards +{ + internal class AddFlashCardAction : IStackAction + { + private readonly IFlashcardRepository _repository; + private readonly IStackAction _onCompleted; + private readonly IAppUi _ui; + + public AddFlashCardAction(IFlashcardRepository repository, IStackAction onCreated, IAppUi ui) + { + _repository = repository; + _onCompleted = onCreated; + _ui = ui; + } + + public async Task ExecuteAsync(Stack stack) + { + while (true) + { + _ui.Clear(); + _ui.WriteSuccess($"Add flashcards to '{stack.Name}'\n"); + + var question = _ui.PromptText("Enter question:").Trim(); + var answer = _ui.PromptText("Enter answer:").Trim(); + + if (!_ui.Confirm("\nSave this card?")) + continue; + + var card = new Flashcard + { + Question = question, + Answer = answer, + StackId = stack.Id, + }; + + await _repository.AddAsync(card); + + _ui.WriteSuccess("\nFlashcard added!"); + + if (!_ui.Confirm("\n\nAdd another card to this stack?")) + break; + } + + _ui.Clear(); + await _onCompleted.ExecuteAsync(stack); + } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Flashcards/DeleteFlashcardAction.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Flashcards/DeleteFlashcardAction.cs new file mode 100644 index 00000000..6b68593f --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Flashcards/DeleteFlashcardAction.cs @@ -0,0 +1,85 @@ +using Flashcards.Hillgrove.Data; +using Flashcards.Hillgrove.Dtos; +using Flashcards.Hillgrove.Menu.Actions.Stacks; +using Flashcards.Hillgrove.Models; + +namespace Flashcards.Hillgrove.Menu.Actions.Flashcards +{ + internal class DeleteFlashcardAction : IStackAction + { + private readonly IFlashcardRepository _repository; + private readonly IAppUi _ui; + + public DeleteFlashcardAction(IFlashcardRepository repository, IAppUi ui) + { + _repository = repository; + _ui = ui; + } + + public async Task ExecuteAsync(Stack stack) + { + while (true) + { + _ui.Clear(); + + var cards = (await _repository.GetByStackIdAsync(stack.Id)).ToList(); + + if (cards.Count == 0) + { + _ui.WriteWarning($"There are no flashcards in '{stack.Name}'."); + _ui.WaitForKey(); + return; + } + + _ui.WriteSuccess($"Delete flashcard from '{stack.Name}'\n"); + _ui.ShowFlashcardsTable($"{stack.Name} stack", ToDisplayCards(cards)); + + var input = _ui + .PromptText("\nEnter flashcard # to delete (leave empty to cancel):", true) + .Trim(); + + if (string.IsNullOrEmpty(input)) + { + return; + } + + if (!int.TryParse(input, out var selectedIndex) || selectedIndex < 1 || selectedIndex > cards.Count) + { + _ui.WriteError("Invalid flashcard number."); + _ui.WaitForKey(); + continue; + } + + var cardToDelete = cards[selectedIndex - 1]; + + if (!_ui.Confirm($"Delete flashcard #{selectedIndex}?")) + { + return; + } + + await _repository.DeleteAsync(cardToDelete.Id); + + _ui.Clear(); + _ui.WriteSuccess($"Deleted flashcard #{selectedIndex}.\n"); + + var updatedCards = (await _repository.GetByStackIdAsync(stack.Id)).ToList(); + _ui.ShowFlashcardsTable($"{stack.Name} stack", ToDisplayCards(updatedCards)); + _ui.WaitForKey(); + return; + } + } + + private static IEnumerable ToDisplayCards(IReadOnlyList cards) + { + return cards.Select( + (card, index) => + new FlashCardDto + { + DisplayIndex = index + 1, + Question = card.Question, + Answer = card.Answer, + } + ); + } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Flashcards/ShowFlashcardsAction.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Flashcards/ShowFlashcardsAction.cs new file mode 100644 index 00000000..3e3ac5aa --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Flashcards/ShowFlashcardsAction.cs @@ -0,0 +1,25 @@ +using Flashcards.Hillgrove.Menu.Actions.Stacks; +using Flashcards.Hillgrove.Models; +using Flashcards.Hillgrove.Services; + +namespace Flashcards.Hillgrove.Menu.Actions.Flashcards +{ + internal class ShowFlashcardsAction : IStackAction + { + private readonly IFlashcardService _service; + private readonly IAppUi _ui; + + public ShowFlashcardsAction(IFlashcardService service, IAppUi ui) + { + _service = service; + _ui = ui; + } + + public async Task ExecuteAsync(Stack stack) + { + var cards = await _service.GetByStackIdAsync(stack.Id); + _ui.ShowFlashcardsTable($"{stack.Name} stack", cards); + _ui.WaitForKey(); + } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Reports/ViewAverageScorePerMonthPerStackAction.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Reports/ViewAverageScorePerMonthPerStackAction.cs new file mode 100644 index 00000000..323fc020 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Reports/ViewAverageScorePerMonthPerStackAction.cs @@ -0,0 +1,65 @@ +using Flashcards.Hillgrove.Services; + +namespace Flashcards.Hillgrove.Menu.Actions.Reports +{ + internal class ViewAverageScorePerMonthPerStackAction + { + private readonly IReportService _reportService; + private readonly IAppUi _ui; + + public ViewAverageScorePerMonthPerStackAction(IReportService reportService, IAppUi ui) + { + _reportService = reportService; + _ui = ui; + } + + public async Task ExecuteAsync() + { + _ui.Clear(); + _ui.WriteSuccess("Average Score per Month per Stack\n"); + + var year = PromptYear(); + + if (year is null) + { + return; + } + + var reportRows = await _reportService.GetAverageScorePerMonthPerStackAsync(year.Value); + + if (reportRows.Count == 0) + { + _ui.WriteWarning($"No report data found for {year}."); + _ui.WaitForKey(); + return; + } + + _ui.ShowStackReportTable($"Average Score per Month per Stack ({year})", reportRows); + _ui.WaitForKey(); + } + + private int? PromptYear() + { + while (true) + { + var input = _ui.PromptText( + "Enter year [grey](leave blank to cancel)[/]", + allowEmpty: true + ) + .Trim(); + + if (string.IsNullOrWhiteSpace(input)) + { + return null; + } + + if (int.TryParse(input, out var year) && year is >= 1 and <= 9999) + { + return year; + } + + _ui.WriteWarning("Please enter a valid year."); + } + } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Reports/ViewSessionsPerMonthPerStackAction.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Reports/ViewSessionsPerMonthPerStackAction.cs new file mode 100644 index 00000000..61915315 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Reports/ViewSessionsPerMonthPerStackAction.cs @@ -0,0 +1,65 @@ +using Flashcards.Hillgrove.Services; + +namespace Flashcards.Hillgrove.Menu.Actions.Reports +{ + internal class ViewSessionsPerMonthPerStackAction + { + private readonly IReportService _reportService; + private readonly IAppUi _ui; + + public ViewSessionsPerMonthPerStackAction(IReportService reportService, IAppUi ui) + { + _reportService = reportService; + _ui = ui; + } + + public async Task ExecuteAsync() + { + _ui.Clear(); + _ui.WriteSuccess("Sessions per Month per Stack\n"); + + var year = PromptYear(); + + if (year is null) + { + return; + } + + var reportRows = await _reportService.GetSessionsPerMonthPerStackAsync(year.Value); + + if (reportRows.Count == 0) + { + _ui.WriteWarning($"No report data found for {year}."); + _ui.WaitForKey(); + return; + } + + _ui.ShowStackReportTable($"Sessions per Month per Stack ({year})", reportRows); + _ui.WaitForKey(); + } + + private int? PromptYear() + { + while (true) + { + var input = _ui.PromptText( + "Enter year [grey](leave blank to cancel)[/]", + allowEmpty: true + ) + .Trim(); + + if (string.IsNullOrWhiteSpace(input)) + { + return null; + } + + if (int.TryParse(input, out var year) && year is >= 1 and <= 9999) + { + return year; + } + + _ui.WriteWarning("Please enter a valid year."); + } + } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Stacks/DeleteStackAction.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Stacks/DeleteStackAction.cs new file mode 100644 index 00000000..1a765c2b --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Stacks/DeleteStackAction.cs @@ -0,0 +1,41 @@ +using Flashcards.Hillgrove.Data; +using Flashcards.Hillgrove.Models; + +namespace Flashcards.Hillgrove.Menu.Actions.Stacks +{ + internal class DeleteStackAction : IStackAction + { + private readonly IStackRepository _repository; + private readonly IAppUi _ui; + + public DeleteStackAction(IStackRepository repository, IAppUi ui) + { + _repository = repository; + _ui = ui; + } + + public async Task ExecuteAsync(Stack stack) + { + _ui.WriteSuccess($"Deleting '{stack.Name}'\n"); + + if (!_ui.Confirm($"Delete stack '{stack.Name}'?")) + { + return; + } + + if ( + !_ui.Confirm( + $"[red]This will permanently delete '{stack.Name}' and related records. Continue?[/]" + ) + ) + { + return; + } + + await _repository.DeleteAsync(stack.Id); + + _ui.WriteSuccess($"Deleted stack: '{stack.Name}'"); + _ui.WaitForKey(); + } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Stacks/IStackAction.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Stacks/IStackAction.cs new file mode 100644 index 00000000..c1291372 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/Stacks/IStackAction.cs @@ -0,0 +1,9 @@ +using Flashcards.Hillgrove.Models; + +namespace Flashcards.Hillgrove.Menu.Actions.Stacks +{ + internal interface IStackAction + { + Task ExecuteAsync(Stack stack); + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/StudySessions/StartStudySessionAction.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/StudySessions/StartStudySessionAction.cs new file mode 100644 index 00000000..c35ffb7f --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/StudySessions/StartStudySessionAction.cs @@ -0,0 +1,21 @@ +using Flashcards.Hillgrove.Menu.Actions.Stacks; +using Flashcards.Hillgrove.Models; +using Flashcards.Hillgrove.Services; + +namespace Flashcards.Hillgrove.Menu.Actions.StudySessions +{ + internal class StartStudySessionAction : IStackAction + { + private readonly IStudySessionService _studySessionService; + + public StartStudySessionAction(IStudySessionService studySessionService) + { + _studySessionService = studySessionService; + } + + public Task ExecuteAsync(Stack stack) + { + return _studySessionService.RunAsync(stack); + } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/StudySessions/ViewStudySessionHistoryAction.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/StudySessions/ViewStudySessionHistoryAction.cs new file mode 100644 index 00000000..41924844 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Actions/StudySessions/ViewStudySessionHistoryAction.cs @@ -0,0 +1,31 @@ +using Flashcards.Hillgrove.Services; + +namespace Flashcards.Hillgrove.Menu.Actions.StudySessions +{ + internal class ViewStudySessionHistoryAction + { + private readonly IStudySessionService _studySessionService; + private readonly IAppUi _ui; + + public ViewStudySessionHistoryAction(IStudySessionService studySessionService, IAppUi ui) + { + _studySessionService = studySessionService; + _ui = ui; + } + + public async Task ExecuteAsync() + { + var sessions = await _studySessionService.GetHistoryAsync(); + + if (sessions.Count == 0) + { + _ui.WriteWarning("There are no study sessions available."); + _ui.WaitForKey(); + return; + } + + _ui.ShowStudySessionsTable("Study Session History", sessions); + _ui.WaitForKey(); + } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/CompositeMenu.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/CompositeMenu.cs new file mode 100644 index 00000000..a55b8ae9 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/CompositeMenu.cs @@ -0,0 +1,42 @@ +using Flashcards.Hillgrove.Helpers; + +namespace Flashcards.Hillgrove.Menu +{ + internal class CompositeMenu : IMenuItem + { + private readonly IMenuPrompt _prompt; + internal IReadOnlyList Items { get; } + + public string Label { get; } + + public CompositeMenu(string label, IEnumerable items, IMenuPrompt prompt) + { + _prompt = prompt; + Items = items.ToList(); + Label = label; + } + + public async Task ExecuteAsync() + { + while (true) + { + _prompt.Clear(); + + var choice = await _prompt.PromptAsync(Label, Items); + + if (choice is null) + { + return NavigationResult.Continue; + } + + var result = await choice.ExecuteAsync(); + + if (result == NavigationResult.Back) + return NavigationResult.Continue; + + if (result == NavigationResult.Exit) + return NavigationResult.Exit; + } + } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/ExitSectionProvider.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/ExitSectionProvider.cs new file mode 100644 index 00000000..9ca04172 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/ExitSectionProvider.cs @@ -0,0 +1,11 @@ +using Flashcards.Hillgrove.Menu.Items; + +namespace Flashcards.Hillgrove.Menu.Composition +{ + internal class ExitSectionProvider : IMainMenuSectionProvider + { + public int Order => 5; + + public IMenuItem Create() => new ExitMenuItem(); + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/IMainMenuSectionProvider.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/IMainMenuSectionProvider.cs new file mode 100644 index 00000000..c22c87d0 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/IMainMenuSectionProvider.cs @@ -0,0 +1,8 @@ +namespace Flashcards.Hillgrove.Menu.Composition +{ + internal interface IMainMenuSectionProvider + { + int Order { get; } + IMenuItem Create(); + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/ManageFlashcardsSectionProvider.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/ManageFlashcardsSectionProvider.cs new file mode 100644 index 00000000..7275a60b --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/ManageFlashcardsSectionProvider.cs @@ -0,0 +1,57 @@ +using Flashcards.Hillgrove.Data; +using Flashcards.Hillgrove.Menu.Actions.Flashcards; +using Flashcards.Hillgrove.Menu.Items; +using Flashcards.Hillgrove.Menu.Items.Flashcards; +using Flashcards.Hillgrove.Menu.Items.Stacks; +using Flashcards.Hillgrove.Services; + +namespace Flashcards.Hillgrove.Menu.Composition +{ + internal class ManageFlashcardsSectionProvider : IMainMenuSectionProvider + { + private readonly IStackRepository _stackRepository; + private readonly IFlashcardRepository _flashcardRepository; + private readonly IFlashcardService _flashcardService; + private readonly IAppUi _ui; + private readonly IMenuPrompt _menuPrompt; + + public int Order => 2; + + public ManageFlashcardsSectionProvider( + IStackRepository stackRepository, + IFlashcardRepository flashcardRepository, + IFlashcardService flashcardService, + IAppUi ui, + IMenuPrompt menuPrompt + ) + { + _stackRepository = stackRepository; + _flashcardRepository = flashcardRepository; + _flashcardService = flashcardService; + _ui = ui; + _menuPrompt = menuPrompt; + } + + public IMenuItem Create() + { + var showFlashcardsAction = new ShowFlashcardsAction(_flashcardService, _ui); + var deleteFlashcardAction = new DeleteFlashcardAction(_flashcardRepository, _ui); + + var showStacks = new ShowAllStacksMenuItem(_stackRepository, showFlashcardsAction, _ui); + var addFlashcard = new AddFlashcardToExistingStackMenuItem(_ui); + var deleteFlashcard = new ShowAllStacksMenuItem( + _stackRepository, + deleteFlashcardAction, + _ui, + "Delete Flashcard from Stack", + "[green]Choose a stack[/]" + ); + + return new CompositeMenu( + "Manage Flashcards", + [showStacks, addFlashcard, deleteFlashcard, new BackMenuItem()], + _menuPrompt + ); + } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/ManageStacksSectionProvider.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/ManageStacksSectionProvider.cs new file mode 100644 index 00000000..cb5cf79f --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/ManageStacksSectionProvider.cs @@ -0,0 +1,65 @@ +using Flashcards.Hillgrove.Data; +using Flashcards.Hillgrove.Menu.Actions.Flashcards; +using Flashcards.Hillgrove.Menu.Actions.Stacks; +using Flashcards.Hillgrove.Menu.Items; +using Flashcards.Hillgrove.Menu.Items.Stacks; +using Flashcards.Hillgrove.Services; + +namespace Flashcards.Hillgrove.Menu.Composition +{ + internal class ManageStacksSectionProvider : IMainMenuSectionProvider + { + private readonly IFlashcardRepository _flashcardRepository; + private readonly IStackRepository _stackRepository; + private readonly IStackService _stackService; + private readonly IFlashcardService _flashcardService; + private readonly IAppUi _ui; + private readonly IMenuPrompt _menuPrompt; + + public int Order => 1; + + public ManageStacksSectionProvider( + IFlashcardRepository flashcardRepository, + IStackRepository stackRepository, + IStackService stackService, + IFlashcardService flashcardService, + IAppUi ui, + IMenuPrompt menuPrompt + ) + { + _flashcardRepository = flashcardRepository; + _stackRepository = stackRepository; + _stackService = stackService; + _flashcardService = flashcardService; + _ui = ui; + _menuPrompt = menuPrompt; + } + + public IMenuItem Create() + { + var showFlashcardsAction = new ShowFlashcardsAction(_flashcardService, _ui); + var addFlashcardAction = new AddFlashCardAction( + _flashcardRepository, + showFlashcardsAction, + _ui + ); + var deleteStackAction = new DeleteStackAction(_stackRepository, _ui); + + var showStacks = new ShowAllStacksMenuItem(_stackRepository, showFlashcardsAction, _ui); + var createStack = new CreateStackMenuItem(_stackService, addFlashcardAction, _ui); + var deleteStack = new ShowAllStacksMenuItem( + _stackRepository, + deleteStackAction, + _ui, + "Delete a stack", + "[green]Choose a stack to delete[/]" + ); + + return new CompositeMenu( + "Manage Stacks", + [showStacks, createStack, deleteStack, new BackMenuItem()], + _menuPrompt + ); + } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/ReportsSectionProvider.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/ReportsSectionProvider.cs new file mode 100644 index 00000000..bf14a7bd --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/ReportsSectionProvider.cs @@ -0,0 +1,43 @@ +using Flashcards.Hillgrove.Menu.Actions.Reports; +using Flashcards.Hillgrove.Menu.Items; +using Flashcards.Hillgrove.Menu.Items.Reports; +using Flashcards.Hillgrove.Services; + +namespace Flashcards.Hillgrove.Menu.Composition +{ + internal class ReportsSectionProvider : IMainMenuSectionProvider + { + private readonly IReportService _reportService; + private readonly IAppUi _ui; + private readonly IMenuPrompt _menuPrompt; + + public int Order => 4; + + public ReportsSectionProvider(IReportService reportService, IAppUi ui, IMenuPrompt menuPrompt) + { + _reportService = reportService; + _ui = ui; + _menuPrompt = menuPrompt; + } + + public IMenuItem Create() + { + var viewSessionsAction = new ViewSessionsPerMonthPerStackAction(_reportService, _ui); + var viewAverageScoreAction = new ViewAverageScorePerMonthPerStackAction( + _reportService, + _ui + ); + + var sessionsReport = new ViewSessionsPerMonthPerStackMenuItem(viewSessionsAction); + var averageScoreReport = new ViewAverageScorePerMonthPerStackMenuItem( + viewAverageScoreAction + ); + + return new CompositeMenu( + "Reports", + [sessionsReport, averageScoreReport, new BackMenuItem()], + _menuPrompt + ); + } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/StudySessionsSectionProvider.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/StudySessionsSectionProvider.cs new file mode 100644 index 00000000..891c3385 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Composition/StudySessionsSectionProvider.cs @@ -0,0 +1,53 @@ +using Flashcards.Hillgrove.Data; +using Flashcards.Hillgrove.Menu.Actions.StudySessions; +using Flashcards.Hillgrove.Menu.Items; +using Flashcards.Hillgrove.Menu.Items.Stacks; +using Flashcards.Hillgrove.Menu.Items.StudySessions; +using Flashcards.Hillgrove.Services; + +namespace Flashcards.Hillgrove.Menu.Composition +{ + internal class StudySessionsSectionProvider : IMainMenuSectionProvider + { + private readonly IStackRepository _stackRepository; + private readonly IStudySessionService _studySessionService; + private readonly IAppUi _ui; + private readonly IMenuPrompt _menuPrompt; + + public int Order => 3; + + public StudySessionsSectionProvider( + IStackRepository stackRepository, + IStudySessionService studySessionService, + IAppUi ui, + IMenuPrompt menuPrompt + ) + { + _stackRepository = stackRepository; + _studySessionService = studySessionService; + _ui = ui; + _menuPrompt = menuPrompt; + } + + public IMenuItem Create() + { + var startStudySessionAction = new StartStudySessionAction(_studySessionService); + var viewStudySessionHistoryAction = new ViewStudySessionHistoryAction(_studySessionService, _ui); + + var startStudySession = new ShowAllStacksMenuItem( + _stackRepository, + startStudySessionAction, + _ui, + "Start Study Session", + "[green]Choose a stack to study[/]" + ); + var viewStudySessionHistory = new ViewStudySessionHistoryMenuItem(viewStudySessionHistoryAction); + + return new CompositeMenu( + "Study Sessions", + [startStudySession, viewStudySessionHistory, new BackMenuItem()], + _menuPrompt + ); + } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/IAppUi.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/IAppUi.cs new file mode 100644 index 00000000..5006eda1 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/IAppUi.cs @@ -0,0 +1,20 @@ +using Flashcards.Hillgrove.Dtos; +using Flashcards.Hillgrove.Models; + +namespace Flashcards.Hillgrove.Menu +{ + internal interface IAppUi + { + void Clear(); + void WriteSuccess(string message); + void WriteWarning(string message); + void WriteError(string message); + void WaitForKey(string message = "[grey]Press any key to continue...[/]"); + string PromptText(string prompt, bool allowEmpty = false); + bool Confirm(string prompt); + Stack PromptStackSelection(string title, IReadOnlyList stacks); + void ShowFlashcardsTable(string title, IEnumerable cards); + void ShowStudySessionsTable(string title, IEnumerable sessions); + void ShowStackReportTable(string title, IEnumerable rows); + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/IMenuItem.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/IMenuItem.cs new file mode 100644 index 00000000..2d816565 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/IMenuItem.cs @@ -0,0 +1,10 @@ +using Flashcards.Hillgrove.Helpers; + +namespace Flashcards.Hillgrove.Menu +{ + internal interface IMenuItem + { + string Label { get; } + Task ExecuteAsync(); + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/IMenuPrompt.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/IMenuPrompt.cs new file mode 100644 index 00000000..d0efef2b --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/IMenuPrompt.cs @@ -0,0 +1,8 @@ +namespace Flashcards.Hillgrove.Menu +{ + internal interface IMenuPrompt + { + void Clear(); + Task PromptAsync(string title, IReadOnlyList items); + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/BackMenuItem.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/BackMenuItem.cs new file mode 100644 index 00000000..513880d6 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/BackMenuItem.cs @@ -0,0 +1,11 @@ +using Flashcards.Hillgrove.Helpers; + +namespace Flashcards.Hillgrove.Menu.Items +{ + internal class BackMenuItem : IMenuItem + { + public string Label => "Back"; + + public Task ExecuteAsync() => Task.FromResult(NavigationResult.Back); + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/ExitMenuItem.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/ExitMenuItem.cs new file mode 100644 index 00000000..52189055 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/ExitMenuItem.cs @@ -0,0 +1,11 @@ +using Flashcards.Hillgrove.Helpers; + +namespace Flashcards.Hillgrove.Menu.Items +{ + internal class ExitMenuItem : IMenuItem + { + public string Label => "Exit"; + + public Task ExecuteAsync() => Task.FromResult(NavigationResult.Exit); + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Flashcards/AddFlashcardToExistingStackMenuItem.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Flashcards/AddFlashcardToExistingStackMenuItem.cs new file mode 100644 index 00000000..c4bd3c65 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Flashcards/AddFlashcardToExistingStackMenuItem.cs @@ -0,0 +1,23 @@ +using Flashcards.Hillgrove.Helpers; + +namespace Flashcards.Hillgrove.Menu.Items.Flashcards +{ + internal class AddFlashcardToExistingStackMenuItem : IMenuItem + { + private readonly IAppUi _ui; + + public string Label => "Add Flashcard to Existing Stack"; + + public AddFlashcardToExistingStackMenuItem(IAppUi ui) + { + _ui = ui; + } + + public Task ExecuteAsync() + { + _ui.WriteWarning("Add Flashcard to Existing Stack is not implemented yet."); + _ui.WaitForKey(); + return Task.FromResult(NavigationResult.Continue); + } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Reports/ViewAverageScorePerMonthPerStackMenuItem.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Reports/ViewAverageScorePerMonthPerStackMenuItem.cs new file mode 100644 index 00000000..501a6d3f --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Reports/ViewAverageScorePerMonthPerStackMenuItem.cs @@ -0,0 +1,23 @@ +using Flashcards.Hillgrove.Helpers; +using Flashcards.Hillgrove.Menu.Actions.Reports; + +namespace Flashcards.Hillgrove.Menu.Items.Reports +{ + internal class ViewAverageScorePerMonthPerStackMenuItem : IMenuItem + { + private readonly ViewAverageScorePerMonthPerStackAction _action; + + public string Label => "Average Score per Month per Stack"; + + public ViewAverageScorePerMonthPerStackMenuItem(ViewAverageScorePerMonthPerStackAction action) + { + _action = action; + } + + public async Task ExecuteAsync() + { + await _action.ExecuteAsync(); + return NavigationResult.Continue; + } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Reports/ViewSessionsPerMonthPerStackMenuItem.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Reports/ViewSessionsPerMonthPerStackMenuItem.cs new file mode 100644 index 00000000..91b2925f --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Reports/ViewSessionsPerMonthPerStackMenuItem.cs @@ -0,0 +1,23 @@ +using Flashcards.Hillgrove.Helpers; +using Flashcards.Hillgrove.Menu.Actions.Reports; + +namespace Flashcards.Hillgrove.Menu.Items.Reports +{ + internal class ViewSessionsPerMonthPerStackMenuItem : IMenuItem + { + private readonly ViewSessionsPerMonthPerStackAction _action; + + public string Label => "Sessions per Month per Stack"; + + public ViewSessionsPerMonthPerStackMenuItem(ViewSessionsPerMonthPerStackAction action) + { + _action = action; + } + + public async Task ExecuteAsync() + { + await _action.ExecuteAsync(); + return NavigationResult.Continue; + } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Stacks/CreateStackMenuItem.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Stacks/CreateStackMenuItem.cs new file mode 100644 index 00000000..dfc8b898 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Stacks/CreateStackMenuItem.cs @@ -0,0 +1,74 @@ +using Flashcards.Hillgrove.Helpers; +using Flashcards.Hillgrove.Menu.Actions.Stacks; +using Flashcards.Hillgrove.Models; +using Flashcards.Hillgrove.Services; + +namespace Flashcards.Hillgrove.Menu.Items.Stacks +{ + internal class CreateStackMenuItem : IMenuItem + { + private readonly IStackService _service; + private readonly IStackAction _onCreated; + private readonly IAppUi _ui; + + public string Label => "Create a new stack"; + + public CreateStackMenuItem(IStackService service, IStackAction onCreated, IAppUi ui) + { + _service = service; + _onCreated = onCreated; + _ui = ui; + } + + public async Task ExecuteAsync() + { + string input; + + while (true) + { + _ui.Clear(); + _ui.WriteSuccess("Create a new stack\n"); + + input = _ui.PromptText( + "What should the stack be called? [grey](leave blank to cancel)[/]", + allowEmpty: true + ); + + if (string.IsNullOrWhiteSpace(input)) + { + return NavigationResult.Continue; + } + + if (!_ui.Confirm($"Use this name: '{input}'? ")) + { + _ui.Clear(); + continue; + } + + Stack newStack = new Stack { Name = input }; + Stack? stackAdded = await _service.AddAsync(newStack); + + if (stackAdded == null) + { + _ui.WriteError($"\nName already exists: '{input}'"); + + if (_ui.Confirm("\nTry again with another name?")) + { + continue; + } + } + else + { + _ui.WriteSuccess($"\nStack added: '{input}'"); + + if (_ui.Confirm("\nAdd flashcards to the new stack now?")) + { + await _onCreated.ExecuteAsync(stackAdded); + } + } + + return NavigationResult.Continue; + } + } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Stacks/ShowAllStacksMenuItem.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Stacks/ShowAllStacksMenuItem.cs new file mode 100644 index 00000000..7e9a7e7c --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/Stacks/ShowAllStacksMenuItem.cs @@ -0,0 +1,49 @@ +using Flashcards.Hillgrove.Data; +using Flashcards.Hillgrove.Helpers; +using Flashcards.Hillgrove.Menu.Actions.Stacks; + +namespace Flashcards.Hillgrove.Menu.Items.Stacks +{ + internal class ShowAllStacksMenuItem : IMenuItem + { + private readonly IStackRepository _repository; + private readonly IStackAction _onSelect; + private readonly IAppUi _ui; + private readonly string _label; + private readonly string _promptTitle; + + public string Label => _label; + + public ShowAllStacksMenuItem( + IStackRepository repository, + IStackAction onSelect, + IAppUi ui, + string label = "Show All Stacks", + string promptTitle = "[green]Choose a stack[/]" + ) + { + _repository = repository; + _onSelect = onSelect; + _ui = ui; + _label = label; + _promptTitle = promptTitle; + } + + public async Task ExecuteAsync() + { + var stacks = (await _repository.GetAllAsync()).ToList(); + + if (stacks.Count == 0) + { + _ui.WriteWarning("There are no stacks available."); + _ui.WaitForKey(); + return NavigationResult.Continue; + } + + var selected = _ui.PromptStackSelection(_promptTitle, stacks); + await _onSelect.ExecuteAsync(selected); + + return NavigationResult.Continue; + } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/StudySessions/StartStudySessionMenuItem.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/StudySessions/StartStudySessionMenuItem.cs new file mode 100644 index 00000000..bbb1f09e --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/StudySessions/StartStudySessionMenuItem.cs @@ -0,0 +1,23 @@ +using Flashcards.Hillgrove.Helpers; + +namespace Flashcards.Hillgrove.Menu.Items.StudySessions +{ + internal class StartStudySessionMenuItem : IMenuItem + { + private readonly IAppUi _ui; + + public string Label => "Start Study Session"; + + public StartStudySessionMenuItem(IAppUi ui) + { + _ui = ui; + } + + public Task ExecuteAsync() + { + _ui.WriteWarning("Start Study Session is not implemented yet."); + _ui.WaitForKey(); + return Task.FromResult(NavigationResult.Continue); + } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/StudySessions/ViewStudySessionHistoryMenuItem.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/StudySessions/ViewStudySessionHistoryMenuItem.cs new file mode 100644 index 00000000..93bec920 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/Items/StudySessions/ViewStudySessionHistoryMenuItem.cs @@ -0,0 +1,23 @@ +using Flashcards.Hillgrove.Helpers; +using Flashcards.Hillgrove.Menu.Actions.StudySessions; + +namespace Flashcards.Hillgrove.Menu.Items.StudySessions +{ + internal class ViewStudySessionHistoryMenuItem : IMenuItem + { + private readonly ViewStudySessionHistoryAction _viewStudySessionHistoryAction; + + public string Label => "View Study Session History"; + + public ViewStudySessionHistoryMenuItem(ViewStudySessionHistoryAction viewStudySessionHistoryAction) + { + _viewStudySessionHistoryAction = viewStudySessionHistoryAction; + } + + public async Task ExecuteAsync() + { + await _viewStudySessionHistoryAction.ExecuteAsync(); + return NavigationResult.Continue; + } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/MenuComposer.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/MenuComposer.cs new file mode 100644 index 00000000..84f08564 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/MenuComposer.cs @@ -0,0 +1,28 @@ +using Flashcards.Hillgrove.Menu.Composition; + +namespace Flashcards.Hillgrove.Menu +{ + internal class MenuComposer + { + private readonly IEnumerable _sectionProviders; + private readonly IMenuPrompt _menuPrompt; + + public MenuComposer( + IEnumerable sectionProviders, + IMenuPrompt menuPrompt + ) + { + _sectionProviders = sectionProviders; + _menuPrompt = menuPrompt; + } + + public IMenuItem Build() + { + var sections = _sectionProviders + .OrderBy(provider => provider.Order) + .Select(provider => provider.Create()); + + return new CompositeMenu("Main Menu", sections, _menuPrompt); + } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/SpectreAppUi.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/SpectreAppUi.cs new file mode 100644 index 00000000..d5597528 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/SpectreAppUi.cs @@ -0,0 +1,143 @@ +using Flashcards.Hillgrove.Dtos; +using Flashcards.Hillgrove.Models; +using Spectre.Console; + +namespace Flashcards.Hillgrove.Menu +{ + internal class SpectreAppUi : IAppUi + { + public void Clear() => AnsiConsole.Clear(); + + public void WriteSuccess(string message) => AnsiConsole.MarkupLine($"[green]{message}[/]"); + + public void WriteWarning(string message) => AnsiConsole.MarkupLine($"[yellow]{message}[/]"); + + public void WriteError(string message) => AnsiConsole.MarkupLine($"[red]{message}[/]"); + + public void WaitForKey(string message = "[grey]Press any key to continue...[/]") + { + AnsiConsole.Markup($"\n{message}"); + Console.ReadKey(true); + } + + public string PromptText(string prompt, bool allowEmpty = false) + { + var textPrompt = new TextPrompt(prompt); + + if (allowEmpty) + { + textPrompt.AllowEmpty(); + } + + return AnsiConsole.Prompt(textPrompt); + } + + public bool Confirm(string prompt) => AnsiConsole.Confirm(prompt); + + public Stack PromptStackSelection(string title, IReadOnlyList stacks) + { + return AnsiConsole.Prompt( + new SelectionPrompt() + .Title(title) + .UseConverter(stack => stack.Name) + .AddChoices(stacks) + ); + } + + public void ShowFlashcardsTable(string title, IEnumerable cards) + { + var table = new Table(); + table.AddColumn("#"); + table.AddColumn("Question"); + table.AddColumn("Answer"); + + foreach (var card in cards) + { + table.AddRow( + card.DisplayIndex.ToString(), + card.Question ?? string.Empty, + card.Answer ?? string.Empty + ); + } + + WriteSuccess(title); + AnsiConsole.Write(table); + } + + public void ShowStudySessionsTable(string title, IEnumerable sessions) + { + var table = new Table(); + table.AddColumn("Date (UTC)"); + table.AddColumn("Stack"); + table.AddColumn("Score"); + + foreach (var session in sessions) + { + table.AddRow( + session.Date.ToString("u"), + session.StackName ?? session.StackId.ToString(), + session.Score.ToString() + ); + } + + WriteSuccess(title); + AnsiConsole.Write(table); + } + + public void ShowStackReportTable(string title, IEnumerable rows) + { + var table = new Table(); + table.AddColumn("Stack"); + table.AddColumn("Jan"); + table.AddColumn("Feb"); + table.AddColumn("Mar"); + table.AddColumn("Apr"); + table.AddColumn("May"); + table.AddColumn("Jun"); + table.AddColumn("Jul"); + table.AddColumn("Aug"); + table.AddColumn("Sep"); + table.AddColumn("Oct"); + table.AddColumn("Nov"); + table.AddColumn("Dec"); + table.AddColumn("Total"); + table.AddColumn("Average"); + + foreach (var row in rows) + { + table.AddRow( + row.StackName, + FormatReportValue(row.January), + FormatReportValue(row.February), + FormatReportValue(row.March), + FormatReportValue(row.April), + FormatReportValue(row.May), + FormatReportValue(row.June), + FormatReportValue(row.July), + FormatReportValue(row.August), + FormatReportValue(row.September), + FormatReportValue(row.October), + FormatReportValue(row.November), + FormatReportValue(row.December), + FormatReportValue(row.Total), + FormatReportValue(row.Average) + ); + } + + WriteSuccess(title); + AnsiConsole.Write(table); + } + + private static string FormatReportValue(double value) + { + var roundedValue = Math.Round(value, 2); + + if (Math.Abs(roundedValue % 1) < 0.000001) + { + return roundedValue.ToString("0"); + } + + return roundedValue.ToString("0.##"); + } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/SpectreMenuPrompt.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/SpectreMenuPrompt.cs new file mode 100644 index 00000000..dd283f83 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Menu/SpectreMenuPrompt.cs @@ -0,0 +1,33 @@ +using Spectre.Console; + +namespace Flashcards.Hillgrove.Menu +{ + internal class SpectreMenuPrompt : IMenuPrompt + { + public void Clear() => AnsiConsole.Clear(); + + public Task PromptAsync(string label, IReadOnlyList items) + { + ArgumentNullException.ThrowIfNull(label); + ArgumentNullException.ThrowIfNull(items); + + if (items.Count == 0) + { + AnsiConsole.MarkupLine($"[green]{label}[/]\n"); + AnsiConsole.MarkupLine("[yellow]No options available yet.[/]"); + AnsiConsole.Markup("\n[grey]Press any key to go back...[/]"); + Console.ReadKey(true); + return Task.FromResult(null); + } + + var choice = AnsiConsole.Prompt( + new SelectionPrompt() + .Title($"[green]{label}[/]") + .AddChoices(items) + .UseConverter(item => item.Label) + ); + + return Task.FromResult(choice); + } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Models/Flashcard.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Models/Flashcard.cs new file mode 100644 index 00000000..f46b5ce9 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Models/Flashcard.cs @@ -0,0 +1,10 @@ +namespace Flashcards.Hillgrove.Models +{ + public class Flashcard + { + public int Id { get; set; } + public int StackId { get; set; } + public required string Question { get; set; } + public required string Answer { get; set; } + } +} \ No newline at end of file diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Models/Stack.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Models/Stack.cs new file mode 100644 index 00000000..0408974b --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Models/Stack.cs @@ -0,0 +1,8 @@ +namespace Flashcards.Hillgrove.Models +{ + public class Stack + { + public int Id { get; set; } + public required string Name { get; set; } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Models/StackReportRow.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Models/StackReportRow.cs new file mode 100644 index 00000000..92ab9f00 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Models/StackReportRow.cs @@ -0,0 +1,58 @@ +namespace Flashcards.Hillgrove.Models +{ + internal class StackReportRow + { + public string StackName { get; set; } = string.Empty; + public double January { get; set; } + public double February { get; set; } + public double March { get; set; } + public double April { get; set; } + public double May { get; set; } + public double June { get; set; } + public double July { get; set; } + public double August { get; set; } + public double September { get; set; } + public double October { get; set; } + public double November { get; set; } + public double December { get; set; } + + public double Total => + January + + February + + March + + April + + May + + June + + July + + August + + September + + October + + November + + December; + + public double Average + { + get + { + var monthValues = new[] + { + January, + February, + March, + April, + May, + June, + July, + August, + September, + October, + November, + December, + }; + + var activeMonthValues = monthValues.Where(value => value > 0).ToArray(); + return activeMonthValues.Length == 0 ? 0 : activeMonthValues.Average(); + } + } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Models/StudySession.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Models/StudySession.cs new file mode 100644 index 00000000..0e43d810 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Models/StudySession.cs @@ -0,0 +1,11 @@ +namespace Flashcards.Hillgrove.Models +{ + public class StudySession + { + public int Id { get; set; } + public int StackId { get; set; } + public string? StackName { get; set; } + public DateTime Date { get; set; } + public int Score { get; set; } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Program.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Program.cs new file mode 100644 index 00000000..463996a7 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Program.cs @@ -0,0 +1,47 @@ +using System.Data; +using Flashcards.Hillgrove.Data; +using Flashcards.Hillgrove.Menu; +using Flashcards.Hillgrove.Menu.Composition; +using Flashcards.Hillgrove.Services; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +var config = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: false) + .Build(); + +string connectionString = + config.GetConnectionString("DefaultConnection") + ?? throw new InvalidOperationException("Connection string is missing."); + +var services = new ServiceCollection(); +services.AddSingleton(_ => new SqlConnection(connectionString)); + +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(); + +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(); + +services.AddSingleton(); +services.AddSingleton(); + +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(); + +services.AddSingleton(); + +using var provider = services.BuildServiceProvider(); + +var menuComposer = provider.GetRequiredService(); +var menu = menuComposer.Build(); +await menu.ExecuteAsync(); diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/FlashcardService.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/FlashcardService.cs new file mode 100644 index 00000000..6b079c96 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/FlashcardService.cs @@ -0,0 +1,32 @@ +using Flashcards.Hillgrove.Data; +using Flashcards.Hillgrove.Dtos; + +namespace Flashcards.Hillgrove.Services +{ + internal class FlashcardService : IFlashcardService + { + private readonly IFlashcardRepository _repository; + + public FlashcardService(IFlashcardRepository repository) + { + _repository = repository; + } + + public async Task> GetByStackIdAsync(int stackId) + { + var foundCards = await _repository.GetByStackIdAsync(stackId); + + var cards = foundCards.Select( + (fc, index) => + new FlashCardDto + { + DisplayIndex = index + 1, + Question = fc.Question, + Answer = fc.Answer, + } + ); + + return cards; + } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/IFlashcardService.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/IFlashcardService.cs new file mode 100644 index 00000000..583d8d1d --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/IFlashcardService.cs @@ -0,0 +1,9 @@ +using Flashcards.Hillgrove.Dtos; + +namespace Flashcards.Hillgrove.Services +{ + internal interface IFlashcardService + { + Task> GetByStackIdAsync(int stackId); + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/IReportService.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/IReportService.cs new file mode 100644 index 00000000..a4af86de --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/IReportService.cs @@ -0,0 +1,10 @@ +using Flashcards.Hillgrove.Models; + +namespace Flashcards.Hillgrove.Services +{ + internal interface IReportService + { + Task> GetSessionsPerMonthPerStackAsync(int year); + Task> GetAverageScorePerMonthPerStackAsync(int year); + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/IStackService.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/IStackService.cs new file mode 100644 index 00000000..d3212f87 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/IStackService.cs @@ -0,0 +1,9 @@ +using Flashcards.Hillgrove.Models; + +namespace Flashcards.Hillgrove.Services +{ + internal interface IStackService + { + Task AddAsync(Stack newStack); + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/IStudySessionService.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/IStudySessionService.cs new file mode 100644 index 00000000..b5361a8f --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/IStudySessionService.cs @@ -0,0 +1,10 @@ +using Flashcards.Hillgrove.Models; + +namespace Flashcards.Hillgrove.Services +{ + internal interface IStudySessionService + { + Task RunAsync(Stack stack); + Task> GetHistoryAsync(); + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/ReportService.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/ReportService.cs new file mode 100644 index 00000000..a27bcd2f --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/ReportService.cs @@ -0,0 +1,27 @@ +using Flashcards.Hillgrove.Data; +using Flashcards.Hillgrove.Models; + +namespace Flashcards.Hillgrove.Services +{ + internal class ReportService : IReportService + { + private readonly IReportRepository _reportRepository; + + public ReportService(IReportRepository reportRepository) + { + _reportRepository = reportRepository; + } + + public async Task> GetSessionsPerMonthPerStackAsync(int year) + { + var reportRows = await _reportRepository.GetSessionsPerMonthPerStackAsync(year); + return reportRows.ToList(); + } + + public async Task> GetAverageScorePerMonthPerStackAsync(int year) + { + var reportRows = await _reportRepository.GetAverageScorePerMonthPerStackAsync(year); + return reportRows.ToList(); + } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/StackService.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/StackService.cs new file mode 100644 index 00000000..0ec0bcc2 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/StackService.cs @@ -0,0 +1,28 @@ +using Flashcards.Hillgrove.Data; +using Flashcards.Hillgrove.Models; + +namespace Flashcards.Hillgrove.Services +{ + internal class StackService : IStackService + { + private readonly IStackRepository _repository; + + public StackService(IStackRepository repository) + { + _repository = repository; + } + + public async Task AddAsync(Stack newStack) + { + IEnumerable existingStacks = await _repository.GetAllAsync(); + bool stackNameExist = existingStacks.Any(s => s.Name == newStack.Name); + + if (stackNameExist) + { + return null; + } + + return await _repository.AddAsync(newStack); + } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/StudySessionService.cs b/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/StudySessionService.cs new file mode 100644 index 00000000..3e05c172 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/Services/StudySessionService.cs @@ -0,0 +1,79 @@ +using Flashcards.Hillgrove.Data; +using Flashcards.Hillgrove.Menu; +using Flashcards.Hillgrove.Models; + +namespace Flashcards.Hillgrove.Services +{ + internal class StudySessionService : IStudySessionService + { + private readonly IFlashcardRepository _flashcardRepository; + private readonly IStudySessionRepository _studySessionRepository; + private readonly IAppUi _ui; + + public StudySessionService( + IFlashcardRepository flashcardRepository, + IStudySessionRepository studySessionRepository, + IAppUi ui + ) + { + _flashcardRepository = flashcardRepository; + _studySessionRepository = studySessionRepository; + _ui = ui; + } + + public async Task RunAsync(Stack stack) + { + var flashcards = (await _flashcardRepository.GetByStackIdAsync(stack.Id)).ToList(); + + if (flashcards.Count == 0) + { + _ui.WriteWarning($"There are no flashcards in '{stack.Name}'."); + _ui.WaitForKey(); + return; + } + + _ui.Clear(); + _ui.WriteSuccess($"Study session for '{stack.Name}'\n"); + + var score = 0; + + for (var index = 0; index < flashcards.Count; index++) + { + var card = flashcards[index]; + _ui.WriteSuccess($"Question {index + 1} of {flashcards.Count}"); + _ui.WriteSuccess(card.Question ?? string.Empty); + + var userAnswer = _ui.PromptText("Your answer:").Trim(); + var correctAnswer = (card.Answer ?? string.Empty).Trim(); + + if (string.Equals(userAnswer, correctAnswer, StringComparison.OrdinalIgnoreCase)) + { + score++; + _ui.WriteSuccess("Correct!\n"); + } + else + { + _ui.WriteWarning($"Incorrect. Correct answer: {card.Answer}\n"); + } + } + + await _studySessionRepository.AddAsync( + new StudySession + { + StackId = stack.Id, + Date = DateTime.UtcNow, + Score = score, + } + ); + + _ui.WriteSuccess($"Session complete. Score: {score}/{flashcards.Count}"); + _ui.WaitForKey(); + } + + public async Task> GetHistoryAsync() + { + var sessions = await _studySessionRepository.GetAllAsync(); + return sessions.ToList(); + } + } +} diff --git a/Flashcards.Hillgrove/Flashcards.Hillgrove/appsettings.json b/Flashcards.Hillgrove/Flashcards.Hillgrove/appsettings.json new file mode 100644 index 00000000..c489c6c3 --- /dev/null +++ b/Flashcards.Hillgrove/Flashcards.Hillgrove/appsettings.json @@ -0,0 +1,5 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=FlashcardsDB;Integrated Security=True" + } +} diff --git a/Flashcards.Hillgrove/sql/create_tables.sql b/Flashcards.Hillgrove/sql/create_tables.sql new file mode 100644 index 00000000..547c0424 --- /dev/null +++ b/Flashcards.Hillgrove/sql/create_tables.sql @@ -0,0 +1,22 @@ +CREATE TABLE Stack ( + Id INT NOT NULL IDENTITY(1,1) PRIMARY KEY, + Name NVARCHAR(255) NOT NULL UNIQUE +); + +CREATE TABLE Flashcard ( + Id INT NOT NULL IDENTITY(1,1) PRIMARY KEY, + StackId INT NOT NULL, + Question NVARCHAR(MAX) NOT NULL, + Answer NVARCHAR(MAX) NOT NULL, + + CONSTRAINT FK_FlashCard_Stack FOREIGN KEY (StackId) REFERENCES Stack(Id) ON DELETE CASCADE +); + +CREATE TABLE StudySession ( + Id INT NOT NULL IDENTITY(1,1) PRIMARY KEY, + StackId INT NOT NULL, + Date DATETIME2 NOT NULL, + Score INT NOT NULL, + + CONSTRAINT FK_StudySession_Stack FOREIGN KEY (StackId) REFERENCES Stack(Id) ON DELETE CASCADE +); \ No newline at end of file diff --git a/Flashcards.Hillgrove/sql/seed_report_data.sql b/Flashcards.Hillgrove/sql/seed_report_data.sql new file mode 100644 index 00000000..ae99470c --- /dev/null +++ b/Flashcards.Hillgrove/sql/seed_report_data.sql @@ -0,0 +1,104 @@ +BEGIN TRAN; + +SET NOCOUNT ON; + +DECLARE @BaseYear INT = YEAR(SYSUTCDATETIME()); + +-- 1) Seed stacks (Stack: Id, Name) +INSERT INTO Stack (Name) +SELECT src.Name +FROM (VALUES (N'C#'), (N'SQL'), (N'JavaScript')) AS src(Name) +WHERE NOT EXISTS ( + SELECT 1 + FROM Stack s + WHERE s.Name = src.Name +); + +-- 2) Seed flashcards (Flashcard: Id, StackId, Question, Answer) +INSERT INTO Flashcard (StackId, Question, Answer) +SELECT s.Id, src.Question, src.Answer +FROM ( + VALUES + (N'C#', N'What keyword defines a class?', N'class'), + (N'C#', N'What does var do?', N'Implicit local typing'), + (N'C#', N'How do you declare an async method return type?', N'Task'), + (N'SQL', N'What clause filters rows?', N'WHERE'), + (N'SQL', N'What clause groups rows?', N'GROUP BY'), + (N'SQL', N'How do you sort result rows?', N'ORDER BY'), + (N'JavaScript', N'How do you declare a constant?', N'const'), + (N'JavaScript', N'How do you create an arrow function?', N'=>'), + (N'JavaScript', N'What method parses JSON text?', N'JSON.parse') +) AS src(StackName, Question, Answer) +INNER JOIN Stack s ON s.Name = src.StackName +WHERE NOT EXISTS ( + SELECT 1 + FROM Flashcard f + WHERE f.StackId = s.Id + AND f.Question = src.Question + AND f.Answer = src.Answer +); + +-- 3) Seed study sessions for report output (StudySession: Id, StackId, Date, Score) +WITH Numbers AS ( + SELECT 1 AS N + UNION ALL SELECT 2 + UNION ALL SELECT 3 + UNION ALL SELECT 4 + UNION ALL SELECT 5 +), +Seed AS ( + SELECT * + FROM ( + VALUES + (N'C#', 0, 1, 3, 3), + (N'C#', 0, 2, 2, 2), + (N'C#', 0, 3, 4, 3), + (N'C#', 0, 5, 3, 3), + (N'C#', 0, 7, 2, 2), + (N'C#', 0, 10, 4, 3), + (N'SQL', 0, 1, 2, 2), + (N'SQL', 0, 4, 3, 2), + (N'SQL', 0, 6, 4, 3), + (N'SQL', 0, 9, 3, 2), + (N'SQL', 0, 12, 2, 2), + (N'JavaScript', 0, 2, 3, 1), + (N'JavaScript', 0, 3, 2, 2), + (N'JavaScript', 0, 6, 3, 2), + (N'JavaScript', 0, 8, 4, 3), + (N'JavaScript', 0, 11, 2, 2), + (N'C#', -1, 1, 2, 2), + (N'C#', -1, 6, 3, 3), + (N'C#', -1, 12, 2, 2), + (N'SQL', -1, 2, 2, 2), + (N'SQL', -1, 7, 3, 2), + (N'SQL', -1, 10, 2, 2), + (N'JavaScript',-1, 3, 2, 1), + (N'JavaScript',-1, 8, 3, 2), + (N'JavaScript',-1, 11, 2, 2) + ) AS t(StackName, YearOffset, MonthNumber, SessionCount, BaseScore) +), +Expanded AS ( + SELECT + s.Id AS StackId, + DATEFROMPARTS(@BaseYear + seed.YearOffset, seed.MonthNumber, 2 + (num.N * 4)) AS SessionDate, + CASE + WHEN seed.BaseScore + (((num.N + seed.MonthNumber) % 3) - 1) < 0 THEN 0 + WHEN seed.BaseScore + (((num.N + seed.MonthNumber) % 3) - 1) > 3 THEN 3 + ELSE seed.BaseScore + (((num.N + seed.MonthNumber) % 3) - 1) + END AS SessionScore + FROM Seed seed + INNER JOIN Stack s ON s.Name = seed.StackName + INNER JOIN Numbers num ON num.N <= seed.SessionCount +) +INSERT INTO StudySession (StackId, Date, Score) +SELECT exp.StackId, exp.SessionDate, exp.SessionScore +FROM Expanded exp +WHERE NOT EXISTS ( + SELECT 1 + FROM StudySession ss + WHERE ss.StackId = exp.StackId + AND ss.Date = exp.SessionDate + AND ss.Score = exp.SessionScore +); + +ROLLBACK; \ No newline at end of file