From c73209ed36cc47471b9475bc7bde703901d8aaae Mon Sep 17 00:00:00 2001 From: Brian Bicknell Date: Mon, 26 May 2025 22:35:26 -0400 Subject: [PATCH] More project structure changes. --- .../Sources/NatoManga/Api/INatoMangaClient.cs | 73 +++++++++++++++++++ .../{Search => Api}/NatoMangaSearchResult.cs | 2 +- .../Search/NatoMangaSearchProvider.cs | 59 +++------------ MangaReader.Tests/MangaReader.Tests.csproj | 8 +- .../NatoManga/NatoMangaWebSearchTests.cs | 51 ------------- .../NatoManga/Api/Manga-Search-Response.json} | 0 .../NatoManga/Api/NatoMangaClientTests.cs | 55 ++++++++++++++ .../NatoManga/Search/NatoMangaSearchTests.cs | 47 ++++++++++++ 8 files changed, 189 insertions(+), 106 deletions(-) create mode 100644 MangaReader.Core/Sources/NatoManga/Api/INatoMangaClient.cs rename MangaReader.Core/Sources/NatoManga/{Search => Api}/NatoMangaSearchResult.cs (85%) delete mode 100644 MangaReader.Tests/Search/NatoManga/NatoMangaWebSearchTests.cs rename MangaReader.Tests/{Search/NatoManga/SampleSearchResult.json => Sources/NatoManga/Api/Manga-Search-Response.json} (100%) create mode 100644 MangaReader.Tests/Sources/NatoManga/Api/NatoMangaClientTests.cs create mode 100644 MangaReader.Tests/Sources/NatoManga/Search/NatoMangaSearchTests.cs diff --git a/MangaReader.Core/Sources/NatoManga/Api/INatoMangaClient.cs b/MangaReader.Core/Sources/NatoManga/Api/INatoMangaClient.cs new file mode 100644 index 0000000..3b257f3 --- /dev/null +++ b/MangaReader.Core/Sources/NatoManga/Api/INatoMangaClient.cs @@ -0,0 +1,73 @@ +using MangaReader.Core.HttpService; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace MangaReader.Core.Sources.NatoManga.Api; + +public interface INatoMangaClient +{ + Task SearchAsync(string searchWord, CancellationToken cancellationToken); +} + +public partial class NatoMangaClient(IHttpService httpService) : INatoMangaClient +{ + [GeneratedRegex(@"[^a-z0-9]+")] + private static partial Regex NonAlphaNumericCharactersRegex(); + + [GeneratedRegex("_{2,}")] + private static partial Regex ExtendedUnderscoresRegex(); + + private static readonly JsonSerializerOptions _jsonSerializerOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + public async Task SearchAsync(string searchWord, CancellationToken cancellationToken) + { + string formattedSearchWord = GetFormattedSearchWord(searchWord); + string url = $"https://www.natomanga.com/home/search/json?searchword={formattedSearchWord}"; + + string response = await httpService.GetStringAsync(url, cancellationToken); + + return JsonSerializer.Deserialize(response, _jsonSerializerOptions) ?? []; + } + + protected string GetSearchUrl(string keyword) + { + string formattedSeachWord = GetFormattedSearchWord(keyword); + + return $"https://www.natomanga.com/home/search/json?searchword={formattedSeachWord}"; + } + + private static string GetFormattedSearchWord(string input) + { + if (string.IsNullOrWhiteSpace(input)) + return string.Empty; + + // Convert to lowercase and normalize to decompose accents + string normalized = input.ToLowerInvariant() + .Normalize(NormalizationForm.FormD); + + // Remove diacritics + var sb = new StringBuilder(); + foreach (var c in normalized) + { + var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c); + if (unicodeCategory != UnicodeCategory.NonSpacingMark) + sb.Append(c); + } + + // Replace non-alphanumeric characters with underscores + string cleaned = NonAlphaNumericCharactersRegex().Replace(sb.ToString(), "_"); + + // Trim and collapse underscores + cleaned = ExtendedUnderscoresRegex().Replace(cleaned, "_").Trim('_'); + + return cleaned; + } +} \ No newline at end of file diff --git a/MangaReader.Core/Sources/NatoManga/Search/NatoMangaSearchResult.cs b/MangaReader.Core/Sources/NatoManga/Api/NatoMangaSearchResult.cs similarity index 85% rename from MangaReader.Core/Sources/NatoManga/Search/NatoMangaSearchResult.cs rename to MangaReader.Core/Sources/NatoManga/Api/NatoMangaSearchResult.cs index 08fc000..a5303be 100644 --- a/MangaReader.Core/Sources/NatoManga/Search/NatoMangaSearchResult.cs +++ b/MangaReader.Core/Sources/NatoManga/Api/NatoMangaSearchResult.cs @@ -1,4 +1,4 @@ -namespace MangaReader.Core.Sources.NatoManga.Search; +namespace MangaReader.Core.Sources.NatoManga.Api; public record NatoMangaSearchResult { diff --git a/MangaReader.Core/Sources/NatoManga/Search/NatoMangaSearchProvider.cs b/MangaReader.Core/Sources/NatoManga/Search/NatoMangaSearchProvider.cs index c4b8bf2..86b20bf 100644 --- a/MangaReader.Core/Sources/NatoManga/Search/NatoMangaSearchProvider.cs +++ b/MangaReader.Core/Sources/NatoManga/Search/NatoMangaSearchProvider.cs @@ -1,65 +1,30 @@ -using MangaReader.Core.HttpService; -using MangaReader.Core.Search; -using System.Globalization; -using System.Text; -using System.Text.RegularExpressions; +using MangaReader.Core.Search; +using MangaReader.Core.Sources.NatoManga.Api; namespace MangaReader.Core.Sources.NatoManga.Search; -public partial class NatoMangaSearchProvider(IHttpService httpService) : MangaSearchProviderBase(httpService), IMangaSourceComponent +public partial class NatoMangaSearchProvider(INatoMangaClient natoMangaClient) : IMangaSearchProvider, IMangaSourceComponent { public string SourceId => "NatoManga"; - protected override string GetSearchUrl(string keyword) + public async Task SearchAsync(string keyword, CancellationToken cancellationToken) { - string formattedSeachWord = GetFormattedSearchWord(keyword); + NatoMangaSearchResult[] searchResults = await natoMangaClient.SearchAsync(keyword, cancellationToken); - return $"https://www.natomanga.com/home/search/json?searchword={formattedSeachWord}"; - } + List mangaSearchResults = []; - private static string GetFormattedSearchWord(string input) - { - if (string.IsNullOrWhiteSpace(input)) - return string.Empty; - - // Convert to lowercase and normalize to decompose accents - string normalized = input.ToLowerInvariant() - .Normalize(NormalizationForm.FormD); - - // Remove diacritics - var sb = new StringBuilder(); - foreach (var c in normalized) + foreach (NatoMangaSearchResult searchResult in searchResults) { - var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c); - if (unicodeCategory != UnicodeCategory.NonSpacingMark) - sb.Append(c); - } - - // Replace non-alphanumeric characters with underscores - string cleaned = NonAlphaNumericCharactersRegex().Replace(sb.ToString(), "_"); - - // Trim and collapse underscores - cleaned = ExtendedUnderscoresRegex().Replace(cleaned, "_").Trim('_'); - - return cleaned; - } - - protected override MangaSearchResult[] GetSearchResult(NatoMangaSearchResult[] searchResult) - { - IEnumerable mangaSearchResults = searchResult.Select(searchResult => - new MangaSearchResult() + MangaSearchResult mangaSearchResult = new() { Title = searchResult.Name, Thumbnail = searchResult.Thumb, Url = searchResult.Url - }); + }; + + mangaSearchResults.Add(mangaSearchResult); + } return [.. mangaSearchResults]; } - - [GeneratedRegex(@"[^a-z0-9]+")] - private static partial Regex NonAlphaNumericCharactersRegex(); - - [GeneratedRegex("_{2,}")] - private static partial Regex ExtendedUnderscoresRegex(); } \ No newline at end of file diff --git a/MangaReader.Tests/MangaReader.Tests.csproj b/MangaReader.Tests/MangaReader.Tests.csproj index 9416704..c36a84a 100644 --- a/MangaReader.Tests/MangaReader.Tests.csproj +++ b/MangaReader.Tests/MangaReader.Tests.csproj @@ -10,10 +10,8 @@ - - @@ -28,7 +26,7 @@ - + @@ -56,8 +54,4 @@ - - - - diff --git a/MangaReader.Tests/Search/NatoManga/NatoMangaWebSearchTests.cs b/MangaReader.Tests/Search/NatoManga/NatoMangaWebSearchTests.cs deleted file mode 100644 index ea8ae3c..0000000 --- a/MangaReader.Tests/Search/NatoManga/NatoMangaWebSearchTests.cs +++ /dev/null @@ -1,51 +0,0 @@ -using MangaReader.Core.HttpService; -using MangaReader.Core.Search; -using MangaReader.Core.Sources.NatoManga.Search; -using MangaReader.Tests.Utilities; -using NSubstitute; -using Shouldly; - -namespace MangaReader.Tests.Search.NatoManga; - -public class NatoMangaWebSearchTests -{ - [Fact] - public void Get_Search_Url() - { - // Arrange - IHttpService httpService = Substitute.For(); - NatoMangaSearchProviderTestWrapper searchProvider = new(httpService); - - // Act - string url = searchProvider.Test_GetSearchUrl("Gal can't be kind"); - - // Assert - url.ShouldBe("https://www.natomanga.com/home/search/json?searchword=gal_can_t_be_kind"); - } - - [Fact] - public async Task Get_Search_Result() - { - string resourceName = "MangaReader.Tests.Search.NatoManga.SampleSearchResult.json"; - string searchResultJson = await ResourceHelper.ReadJsonResourceAsync(resourceName); - - IHttpService httpService = Substitute.For(); - - httpService.GetStringAsync(Arg.Any(), CancellationToken.None) - .Returns(Task.FromResult(searchResultJson)); - - NatoMangaSearchProvider searchProvider = new(httpService); - MangaSearchResult[] searchResult = await searchProvider.SearchAsync("Gal Can't Be Kind", CancellationToken.None); - - searchResult.Length.ShouldBe(2); - searchResult[0].Title.ShouldBe("Gal Can't Be Kind to Otaku!"); - searchResult[1].Title.ShouldBe("Gal Can’t Be Kind to Otaku!?"); - searchResult[0].Url.ShouldBe("https://www.natomanga.com/manga/gal-can-t-be-kind-to-otaku"); - searchResult[1].Url.ShouldBe("https://www.natomanga.com/manga/gal-cant-be-kind-to-otaku"); - } -} - -internal class NatoMangaSearchProviderTestWrapper(IHttpService httpService) : NatoMangaSearchProvider(httpService) -{ - internal string Test_GetSearchUrl(string keyword) => GetSearchUrl(keyword); -} \ No newline at end of file diff --git a/MangaReader.Tests/Search/NatoManga/SampleSearchResult.json b/MangaReader.Tests/Sources/NatoManga/Api/Manga-Search-Response.json similarity index 100% rename from MangaReader.Tests/Search/NatoManga/SampleSearchResult.json rename to MangaReader.Tests/Sources/NatoManga/Api/Manga-Search-Response.json diff --git a/MangaReader.Tests/Sources/NatoManga/Api/NatoMangaClientTests.cs b/MangaReader.Tests/Sources/NatoManga/Api/NatoMangaClientTests.cs new file mode 100644 index 0000000..dc716e0 --- /dev/null +++ b/MangaReader.Tests/Sources/NatoManga/Api/NatoMangaClientTests.cs @@ -0,0 +1,55 @@ +using MangaReader.Core.HttpService; +using MangaReader.Core.Sources.NatoManga.Api; +using MangaReader.Tests.Utilities; +using NSubstitute; +using Shouldly; + +namespace MangaReader.Tests.Sources.NatoManga.Api; + +public class NatoMangaClientTests +{ + class TestNatoMangaClient(IHttpService httpService) : NatoMangaClient(httpService) + { + internal string Test_GetSearchUrl(string keyword) => GetSearchUrl(keyword); + } + + [Fact] + public void Get_Search_Url() + { + IHttpService httpService = Substitute.For(); + TestNatoMangaClient searchProvider = new(httpService); + + string url = searchProvider.Test_GetSearchUrl("Gal can't be kind"); + + url.ShouldBe("https://www.natomanga.com/home/search/json?searchword=gal_can_t_be_kind"); + } + + [Fact] + public async Task Search_Manga() + { + string searchResultJson = await ReadJsonResourceAsync("Manga-Search-Response.json"); + + IHttpService httpService = Substitute.For(); + + httpService.GetStringAsync(Arg.Any(), CancellationToken.None) + .Returns(Task.FromResult(searchResultJson)); + + NatoMangaClient natoMangaClient = new(httpService); + NatoMangaSearchResult[] searchResults = await natoMangaClient.SearchAsync("Gal Can't Be Kind", CancellationToken.None); + + searchResults.Length.ShouldBe(2); + + searchResults[0].Name.ShouldBe("Gal Can't Be Kind to Otaku!"); + searchResults[0].Url.ShouldBe("https://www.natomanga.com/manga/gal-can-t-be-kind-to-otaku"); + searchResults[0].Thumb.ShouldBe("https://img-r1.2xstorage.com/thumb/gal-can-t-be-kind-to-otaku.webp"); + + searchResults[1].Name.ShouldBe("Gal Can’t Be Kind to Otaku!?"); + searchResults[1].Url.ShouldBe("https://www.natomanga.com/manga/gal-cant-be-kind-to-otaku"); + searchResults[1].Thumb.ShouldBe("https://img-r1.2xstorage.com/thumb/gal-cant-be-kind-to-otaku.webp"); + } + + private static async Task ReadJsonResourceAsync(string resourceName) + { + return await ResourceHelper.ReadJsonResourceAsync($"MangaReader.Tests.Sources.NatoManga.Api.{resourceName}"); + } +} \ No newline at end of file diff --git a/MangaReader.Tests/Sources/NatoManga/Search/NatoMangaSearchTests.cs b/MangaReader.Tests/Sources/NatoManga/Search/NatoMangaSearchTests.cs new file mode 100644 index 0000000..f43a7af --- /dev/null +++ b/MangaReader.Tests/Sources/NatoManga/Search/NatoMangaSearchTests.cs @@ -0,0 +1,47 @@ +using MangaReader.Core.Search; +using MangaReader.Core.Sources.NatoManga.Api; +using MangaReader.Core.Sources.NatoManga.Search; +using NSubstitute; +using Shouldly; + +namespace MangaReader.Tests.Sources.NatoManga.Search; + +public class NatoMangaSearchTests +{ + [Fact] + public async Task Get_Search_Result() + { + NatoMangaSearchResult[] searchResults = + [ + new() + { + Name = "Gal Can't Be Kind to Otaku!", + Url = "https://www.natomanga.com/manga/gal-can-t-be-kind-to-otaku", + Thumb = "https://img-r1.2xstorage.com/thumb/gal-can-t-be-kind-to-otaku.webp" + }, + new() + { + Name = "Gal Can’t Be Kind to Otaku!?", + Url = "https://www.natomanga.com/manga/gal-cant-be-kind-to-otaku", + Thumb = "https://img-r1.2xstorage.com/thumb/gal-cant-be-kind-to-otaku.webp" + } + ]; + + INatoMangaClient natoMangaClient = Substitute.For(); + + natoMangaClient.SearchAsync(Arg.Any(), CancellationToken.None) + .Returns(Task.FromResult(searchResults)); + + NatoMangaSearchProvider searchProvider = new(natoMangaClient); + MangaSearchResult[] searchResult = await searchProvider.SearchAsync("Gal Can't Be Kind", CancellationToken.None); + + searchResult.Length.ShouldBe(2); + searchResult[0].Title.ShouldBe("Gal Can't Be Kind to Otaku!"); + searchResult[0].Url.ShouldBe("https://www.natomanga.com/manga/gal-can-t-be-kind-to-otaku"); + searchResult[0].Thumbnail.ShouldBe("https://img-r1.2xstorage.com/thumb/gal-can-t-be-kind-to-otaku.webp"); + + searchResult[1].Title.ShouldBe("Gal Can’t Be Kind to Otaku!?"); + searchResult[1].Url.ShouldBe("https://www.natomanga.com/manga/gal-cant-be-kind-to-otaku"); + searchResult[1].Thumbnail.ShouldBe("https://img-r1.2xstorage.com/thumb/gal-cant-be-kind-to-otaku.webp"); + } +} \ No newline at end of file