diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 323290891..93b5f3ea9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -264,11 +264,6 @@ jobs:
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- - name: Install Linux Dependencies
- run: |
- sudo apt-get update
- sudo apt-get install -y libgtk-3-dev libx11-dev
-
- name: Cache NuGet Packages
uses: actions/cache@v3
with:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 89588dd19..8d424e2c1 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -234,9 +234,6 @@ jobs:
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- - name: Install Linux Dependencies
- run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libx11-dev
-
- name: Extract Build Info
id: buildinfo
run: |
diff --git a/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs b/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs
index e7a543023..5ee0581e1 100644
--- a/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs
+++ b/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs
@@ -10,6 +10,21 @@ public static class AppUpdateConstants
///
public const int MaxHttpRetries = 3;
+ ///
+ /// Index for the Update tab in update notification views.
+ ///
+ public const int UpdateTabIndex = 0;
+
+ ///
+ /// Index for the Browse Builds tab in update notification views.
+ ///
+ public const int BrowseBuildsTabIndex = 1;
+
+ ///
+ /// Maximum valid tab index in update notification views.
+ ///
+ public const int MaxTabIndex = 1;
+
///
/// Velopack directory name.
///
@@ -153,6 +168,101 @@ public static class AppUpdateConstants
"3. Launch the installed version (will be in %LOCALAPPDATA%\\GenHub)\n\n" +
"Update available: v{1}";
+ ///
+ /// Update available notification title for release channel.
+ ///
+ public const string UpdateAvailableNotificationTitle = "Update Available";
+
+ ///
+ /// Update available notification title for branch subscriptions.
+ ///
+ public const string BranchUpdateAvailableNotificationTitle = "Branch Update Available";
+
+ ///
+ /// Update available notification title for PR subscriptions.
+ ///
+ public const string PrUpdateAvailableNotificationTitle = "PR Update Available";
+
+ ///
+ /// Update action button text.
+ ///
+ public const string UpdateAction = "Update";
+
+ ///
+ /// Title for the update in progress notification.
+ ///
+ public const string UpdatingAppNotificationTitle = "Updating GenHub";
+
+ ///
+ /// Starting update progress message.
+ ///
+ public const string UpdateStartingMessage = "Starting update...";
+
+ ///
+ /// Title for update failed notification.
+ ///
+ public const string UpdateFailedNotificationTitle = "Update Failed";
+
+ ///
+ /// Update failed notification body format string ({0}: error message).
+ ///
+ public const string UpdateFailedNotificationFormat = "Failed to install update: {0}";
+
+ ///
+ /// View updates action button text.
+ ///
+ public const string ViewUpdatesAction = "View Updates";
+
+ ///
+ /// Release update notification body format string ({0}: version).
+ ///
+ public const string ReleaseUpdateNotificationFormat = "A new version ({0}) is available.";
+
+ ///
+ /// Branch update notification body format string ({0}: version, {1}: branch name).
+ ///
+ public const string BranchUpdateNotificationFormat = "A new build ({0}) is available on branch '{1}'.";
+
+ ///
+ /// PR update notification body format string ({0}: version, {1}: PR number).
+ ///
+ public const string PrUpdateNotificationFormat = "A new build ({0}) is available for PR #{1}.";
+
+ ///
+ /// Sort option: sort by last updated date descending.
+ ///
+ public const string SortOptionLastUpdated = "Last Updated";
+
+ ///
+ /// Sort option: sort by pull request number descending.
+ ///
+ public const string SortOptionPrNumberDesc = "PR Number (Highest)";
+
+ ///
+ /// Sort option: sort by pull request number ascending.
+ ///
+ public const string SortOptionPrNumberAsc = "PR Number (Lowest)";
+
+ ///
+ /// Default interval in minutes for periodic update checks (30 minutes).
+ ///
+ public const int DefaultPeriodicUpdateCheckIntervalMinutes = 30;
+
+ ///
+ /// Minimum interval in minutes for periodic update checks (5 minutes).
+ ///
+ public const int MinPeriodicUpdateCheckIntervalMinutes = 5;
+
+ ///
+ /// Maximum interval in minutes for periodic update checks (10080 minutes / 7 days).
+ ///
+ public const int MaxPeriodicUpdateCheckIntervalMinutes = 10080;
+
+ ///
+ /// Increment step in minutes for periodic update check interval setting (5 minutes).
+ ///
+ public const int PeriodicUpdateCheckIntervalIncrementMinutes = 5;
+
///
/// Delay before exit after applying update (5 seconds).
///
diff --git a/GenHub/GenHub.Core/Constants/CommandLineConstants.cs b/GenHub/GenHub.Core/Constants/CommandLineConstants.cs
index 4b0821443..30cd69c4f 100644
--- a/GenHub/GenHub.Core/Constants/CommandLineConstants.cs
+++ b/GenHub/GenHub.Core/Constants/CommandLineConstants.cs
@@ -1,8 +1,13 @@
namespace GenHub.Core.Constants;
///
-/// Constants for command line arguments and URI schemes.
+/// Constants for command line arguments and the genhub:// URI scheme.
///
+///
+/// Subscription links use genhub://subscribe?url=<absolute-url>.
+/// Today url is a hosted GenHub catalog.json. Publisher Studio will also share
+/// Provider Definition URLs via the same scheme; GenHub will detect payload type at fetch time.
+///
public static class CommandLineConstants
{
///
@@ -16,22 +21,27 @@ public static class CommandLineConstants
public const string LaunchProfileInlinePrefix = "--launch-profile=";
///
- /// URI scheme used for protocol handling.
+ /// Scheme name for custom protocol registration.
///
- public const string UriScheme = "genhub://";
+ public const string SchemeName = "genhub";
///
- /// Command for subscribing to a catalog via URI.
+ /// Custom URI scheme registered so OS/browser links can open GenHub.
+ ///
+ public const string UriScheme = SchemeName + "://";
+
+ ///
+ /// URI path segment for content subscription (genhub://subscribe?url=...).
///
public const string SubscribeCommand = "subscribe";
///
- /// Full prefix for subscription URI.
+ /// Full prefix for subscription URIs (genhub://subscribe).
///
public const string SubscribeUriPrefix = UriScheme + SubscribeCommand;
///
- /// Query parameter name for the catalog URL in a subscription URI.
+ /// Query parameter carrying the absolute URL of a catalog (or future provider definition).
///
public const string SubscribeUrlParam = "?url=";
}
diff --git a/GenHub/GenHub.Core/Constants/ContentConstants.cs b/GenHub/GenHub.Core/Constants/ContentConstants.cs
index 0bc3c907c..96d6a6db0 100644
--- a/GenHub/GenHub.Core/Constants/ContentConstants.cs
+++ b/GenHub/GenHub.Core/Constants/ContentConstants.cs
@@ -94,4 +94,31 @@ public static class ContentConstants
/// Maximum allowed size for the content catalog in bytes (10 MB).
///
public const long MaxCatalogSizeBytes = 10 * ConversionConstants.BytesPerMegabyte;
+
+ ///
+ /// Shared resolver/display metadata key for map player counts.
+ /// Builtin and catalog publishers should set this so download cards can render a consistent badge.
+ ///
+ public const string PlayerCountMetadataKey = "playerCount";
+
+ ///
+ /// Shared resolver/display metadata key for content categories (AOA, Compstomp, ModDB category, etc.).
+ ///
+ public const string CategoryMetadataKey = "category";
+
+ ///
+ /// Display metadata key for a comma-separated list of included/required content names
+ /// (e.g. catalog ContentBundle dependencies resolved to friendly titles).
+ ///
+ public const string IncludesSummaryMetadataKey = "includesSummary";
+
+ ///
+ /// Number of recent releases and addons to eagerly preload extended details for.
+ ///
+ public const int PreloadRecentItemsLimit = 5;
+
+ ///
+ /// Maximum concurrent background requests when preloading recent item details.
+ ///
+ public const int PreloadConcurrencyLimit = 3;
}
\ No newline at end of file
diff --git a/GenHub/GenHub.Core/Constants/DirectoryNames.cs b/GenHub/GenHub.Core/Constants/DirectoryNames.cs
index 47097cc18..19b341d80 100644
--- a/GenHub/GenHub.Core/Constants/DirectoryNames.cs
+++ b/GenHub/GenHub.Core/Constants/DirectoryNames.cs
@@ -59,4 +59,14 @@ public static class DirectoryNames
/// Directory for storing tool workspaces.
///
public const string ToolWorkspaces = "ToolWorkspaces";
+
+ ///
+ /// Directory for persistent Playwright browser profiles (cookies/storage for bot-protected sites).
+ ///
+ public const string BrowserProfiles = "BrowserProfiles";
+
+ ///
+ /// Directory for the app-owned Playwright Chromium runtime (not the system Chrome/Edge install).
+ ///
+ public const string BrowserRuntime = "BrowserRuntime";
}
diff --git a/GenHub/GenHub.Core/Constants/IpcCommands.cs b/GenHub/GenHub.Core/Constants/IpcCommands.cs
index 1a66630f8..4096fd317 100644
--- a/GenHub/GenHub.Core/Constants/IpcCommands.cs
+++ b/GenHub/GenHub.Core/Constants/IpcCommands.cs
@@ -11,7 +11,8 @@ public static class IpcCommands
public const string LaunchProfilePrefix = "launch-profile:";
///
- /// Command prefix used to subscribe to a catalog via IPC.
+ /// Command prefix used to forward a subscribe URL to the primary instance
+ /// (subscribe:<absolute-url>). Same payload as genhub://subscribe?url=....
///
public const string SubscribePrefix = "subscribe:";
}
diff --git a/GenHub/GenHub.Core/Constants/ModDBConstants.cs b/GenHub/GenHub.Core/Constants/ModDBConstants.cs
index 2deed935c..f8b9c8d3d 100644
--- a/GenHub/GenHub.Core/Constants/ModDBConstants.cs
+++ b/GenHub/GenHub.Core/Constants/ModDBConstants.cs
@@ -72,6 +72,12 @@ public static class ModDBConstants
/// ModDB website URL.
public const string PublisherWebsite = BaseUrl;
+ ///
+ /// On-disk Playwright browser profile name used to persist the Cloudflare clearance cookie so
+ /// the user only solves the bot challenge once per session (and across restarts until expiry).
+ ///
+ public const string BrowserProfileName = "moddb";
+
/// Short description for publisher card display.
public const string ShortDescription = "Community mods, maps, and content from ModDB";
@@ -184,6 +190,29 @@ public static class ModDBConstants
/// Value for filter parameter when enabled.
public const string FilterEnabledValue = "t";
+ // ===== Sort Values =====
+
+ /// Sort: Date descending (newest first).
+ public const string SortDateDesc = "date-desc";
+
+ /// Sort: Date ascending (oldest first).
+ public const string SortDateAsc = "date-asc";
+
+ /// Sort: Visits / Popularity descending.
+ public const string SortVisitDesc = "visit-desc";
+
+ /// Sort: Rating descending.
+ public const string SortRatingDesc = "rating-desc";
+
+ /// Sort: Name ascending (A-Z).
+ public const string SortNameAsc = "name-asc";
+
+ /// Sort: Name descending (Z-A).
+ public const string SortNameDesc = "name-desc";
+
+ /// Default sort value for ModDB searches and listings (newest first).
+ public const string DefaultSort = SortDateDesc;
+
// ===== Category Values =====
// Downloads Section - Releases
@@ -356,6 +385,34 @@ public static class ModDBConstants
/// Metadata key for original category.
public const string OriginalCategoryMetadataKey = "moddbCategory";
+ /// Metadata key for identifying if content is a mod.
+ public const string IsModMetadataKey = "IsMod";
+
+ /// Metadata key for parent mod URL.
+ public const string ParentModUrlMetadataKey = "ParentModUrl";
+
+ // ===== Playwright / Scraping Constants =====
+
+ /// Default timeout for page navigation (ms).
+ public const int DefaultGotoTimeout = 30000;
+
+ ///
+ /// Default timeout for waiting for a selector (ms). ModDB sits behind Cloudflare; the headed
+ /// browser persistent profile usually receives the clearance cookie after verification, but 15 s gives a safe margin
+ /// for manual challenge solves before the scraper parses whatever it has.
+ ///
+ public const int DefaultSelectorTimeout = 15000;
+
+ ///
+ /// How long (ms) the listing scrape waits for the user to solve a Cloudflare challenge in the
+ /// visible browser before giving up. Long enough for a manual "I am not a robot" click; the
+ /// page stays open after the deadline so the user can finish and retry.
+ ///
+ public const int VerificationWaitTimeoutMs = 120000;
+
+ /// Selector for content items in listing pages (Fallback).
+ public const string DefaultListItemSelector = "div.row.rowcontent, div.table tr";
+
// ===== Error Messages =====
/// Error message for invalid URL.
diff --git a/GenHub/GenHub.Core/Constants/ModDBParserConstants.cs b/GenHub/GenHub.Core/Constants/ModDBParserConstants.cs
index 55ab7fa3c..4f61403a9 100644
--- a/GenHub/GenHub.Core/Constants/ModDBParserConstants.cs
+++ b/GenHub/GenHub.Core/Constants/ModDBParserConstants.cs
@@ -17,6 +17,9 @@ public static class ModDBParserConstants
/// Selector for developer/publisher links.
public const string DeveloperSelector = "a[href*='/members/'], a[href*='/company/']";
+ /// Selector for the profile/mod info box that carries the real developer name.
+ public const string DeveloperProfileSelector = "#modsinfo a[href*='/members/'], #modsinfo a[href*='/company/'], .sidecolumn a[href*='/members/'], .sidecolumn a[href*='/company/']";
+
/// Selector for release date.
public const string ReleaseDateSelector = "time[datetime], .date, .released";
@@ -59,7 +62,7 @@ public static class ModDBParserConstants
public const string FileMetadataValueSelector = "td:last-child";
/// Selector for the main download button on file pages.
- public const string MainDownloadButtonSelector = "a.download, a.downloadarea, .downloadbutton a, a[href*='/downloads/start/']";
+ public const string MainDownloadButtonSelector = "a.download, a.downloadarea, .downloadbutton a, a[href*='/downloads/start/'], a[href*='/addons/start/']";
/// Selector for download size on the button.
public const string DownloadSizeSelector = ".download .size, .downloadbutton .size";
@@ -87,10 +90,13 @@ public static class ModDBParserConstants
// ===== Description/Summary Selectors =====
/// Selector for full description content.
- public const string FullDescriptionSelector = "#articlebrowse, .summary .content, .description .content, .modtext";
+ public const string FullDescriptionSelector = "#downloaddescription, #downloadsummary, #articlebrowse .articlebody, .articlebody, #modsummary, .modtext, #profile .description, #description, #articlebrowse, .summary .content, .description .content";
+
+ /// Selector for the file-page body copy (not the breadcrumb .summary trail).
+ public const string FileDescriptionSelector = "#downloaddescription, #downloadsummary";
- /// Selector for truncated summary.
- public const string SummarySelector = ".summary p, .description p";
+ /// Selector for summary or description container.
+ public const string SummarySelector = ".description, .rubric, p[itemprop='description']";
// ===== Legacy File Selectors =====
@@ -98,28 +104,31 @@ public static class ModDBParserConstants
public const string FilesTableSelector = "table.filelist, .table.files, #files";
/// Selector for individual file rows.
- public const string FileRowSelector = "tr.file, .row.file, .file";
+ public const string FileRowSelector = "tr.file, .row.file, .file, .row.rowcontent";
/// Selector for file name.
- public const string FileNameSelector = "h5, h4, .name, .title";
+ public const string FileNameSelector = "h4 a, h5 a, h3 a, .heading a, .title a, a.title, .name a, h5, h4, .name, .title";
/// Selector for file version.
public const string FileVersionSelector = ".version, .ver";
/// Selector for file size.
- public const string FileSizeSelector = ".size, .filesize";
+ public const string FileSizeSelector = ".size, .filesize, .filesizes, span.size";
+
+ /// Selector for file subheading or metadata row.
+ public const string FileSubheadingSelector = ".subheading, span.subheading, .meta, .details, .info, p.summary, .summary";
/// Selector for file upload date.
- public const string FileDateSelector = "time[datetime], .date, .uploaded";
+ public const string FileDateSelector = "time[datetime], .date, .uploaded, time";
/// Selector for file category.
- public const string FileCategorySelector = ".category, .type";
+ public const string FileCategorySelector = ".category, .type, span.category";
/// Selector for file uploader.
- public const string FileUploaderSelector = ".uploader, .author, a[href*='/members/']";
+ public const string FileUploaderSelector = ".uploader, .author, a[href*='/members/'], a[href*='/company/']";
/// Selector for file download link (robust).
- public const string FileDownloadSelector = "a.button.download, a[href*='/downloads/start/'], .download a";
+ public const string FileDownloadSelector = "a.button, a.buttonlarge, a.download, a.btn, a[href*='/downloads/start/'], a[href*='/addons/start/'], a[href*='/downloads/'], a[href*='/addons/'], .download a, .actions a";
/// Selector for file MD5 hash.
public const string FileMd5Selector = ".md5, .hash";
@@ -130,22 +139,41 @@ public static class ModDBParserConstants
// ===== Videos Section Selectors =====
/// Selector for embedded video iframes.
- public const string VideoSelector = "iframe[src*='youtube'], iframe[src*='vimeo'], iframe[src*='youtu.be']";
+ public const string VideoSelector = "iframe[src*='youtube'], iframe[src*='youtube-nocookie'], iframe[src*='youtu.be'], iframe[src*='vimeo'], iframe[src*='dailymotion'], iframe[src*='moddb.com/media/iframe'], iframe[src*='moddb.com/media/embed'], iframe[src*='moddb.com/videos/iframe'], iframe[src*='moddb.com/videos/embed']";
+
+ /// Selector for video gallery containers and items.
+ public const string VideoGallerySelector = "#videobox, #videosbrowse, #mediabrowse, .mediarow, .mediabox";
+
+ /// Selector for video links.
+ public const string VideoLinkSelector = "a[href*='/videos/'], a[href*='youtube.com/watch'], a[href*='youtu.be/'], a[href*='vimeo.com/']";
/// Selector for video thumbnails.
- public const string VideoThumbnailSelector = ".thumbnail img, .preview img";
+ public const string VideoThumbnailSelector = ".thumbnail img, .preview img, img";
/// Selector for video titles.
- public const string VideoTitleSelector = ".title, h3, h4";
+ public const string VideoTitleSelector = ".title, h3, h4, h5, .caption";
+
+ /// Selector for recommendation and related content sections.
+ public const string RecommendationsSelector = "#recommendations, .recommendations, #related, .related, #similar, .similar, #fansalsoviewed, .fansalsoviewed, .youmayalso, [class*='recommend'], [id*='recommend'], [class*='similar'], [id*='similar']";
// ===== Images Section Selectors =====
/// Selector for image gallery container.
- public const string ImageGallerySelector = ".mediarow, .screenshot, .imagebox, .gallery";
+ public const string ImageGallerySelector = "#imagebox, #mediaimage, #imagebrowse, #mediabrowse, .mediarow";
+
+ ///
+ /// Selector for gallery images only. Deliberately excludes a blanket
+ /// img[src*='media.moddb.com'] match, which previously pulled game icons, member
+ /// avatars, and file-page chrome into the Media tab.
+ ///
+ public const string GalleryImageSelector = "#imagebox img, #mediaimage img, #imagebrowse img, #mediabrowse img, .mediarow img, .media .holder img, #downloadsummary img, #downloaddescription img, .preview img, a[href*='/mods/'][href*='/images/'] img";
/// Selector for individual images.
public const string ImageSelector = "img";
+ /// Sidebar/profile containers whose images are icons and avatars, not gallery media.
+ public const string ImageSidebarSelector = "#modsinfo, #downloadsprofilemenu, #profile, .sidecolumn, aside";
+
/// Selector for image thumbnails.
public const string ImageThumbnailSelector = ".thumbnail img, .thumb img";
@@ -197,20 +225,24 @@ public static class ModDBParserConstants
// ===== Comments Section Selectors =====
- /// Selector for comments container.
- public const string CommentsSelector = ".comment, .post, .comments";
+ /// Selector for comments container. Do not use #commentform — that is the composer.
+ public const string CommentsSelector = "#commentsbrowse";
- /// Selector for individual comment rows.
- public const string CommentRowSelector = ".comment, .post";
+ ///
+ /// Selector for posted comment rows. Requires the exact rowcomment class so the
+ /// composer rows (rowcommentguest, rowcommentsummary, rowcommentemail)
+ /// and #commentform are not treated as comments.
+ ///
+ public const string CommentRowSelector = ".row.rowcomment, .rowcomment";
/// Selector for comment authors.
- public const string CommentAuthorSelector = ".author, .username, a[href*='/members/']";
+ public const string CommentAuthorSelector = ".author, .username, .heading a, a[href*='/members/']";
- /// Selector for comment content.
- public const string CommentContentSelector = ".content, .body, .text";
+ /// Selector for comment content. Avoids bare p which matches login chrome and CSS blobs.
+ public const string CommentContentSelector = ":scope > .commentbody, .commentbody, p.comment";
/// Selector for comment dates.
- public const string CommentDateSelector = "time[datetime], .date";
+ public const string CommentDateSelector = "time[datetime], time, .date, .datetime, span.subheading";
/// Selector for comment karma/votes.
public const string CommentKarmaSelector = ".karma, .votes, .goodkarma, .badkarma";
@@ -245,4 +277,100 @@ public static class ModDBParserConstants
/// Pattern for games URLs.
public const string GamesUrlPattern = "/games/";
+
+ // ===== Mod Detail Page Selectors =====
+
+ /// Selector for the downloads section on mod pages.
+ public const string DownloadsSectionSelector = "#downloads, .downloads, .files";
+
+ /// Selector for the addons section on mod pages.
+ public const string AddonsSectionSelector = "#addons, .addons";
+
+ /// Selector for the tabs/navigation on mod pages.
+ public const string TabsSelector = ".tabs, .navigation, nav";
+
+ /// Selector for individual tab links.
+ public const string TabLinkSelector = "a[href*='/downloads'], a[href*='/addons']";
+
+ // ===== Metadata Keys (Internal/Normalized) =====
+
+ /// Metadata key for filename.
+ public const string MetadataFilename = "filename";
+
+ /// Alternative metadata key for filename.
+ public const string MetadataFileNameAlt = "file name";
+
+ /// Alternative metadata key for file.
+ public const string MetadataFileAlt = "file";
+
+ /// Metadata key for size.
+ public const string MetadataSize = "size";
+
+ /// Alternative metadata key for size.
+ public const string MetadataFileSizeAlt = "file size";
+
+ /// Metadata key for uploader.
+ public const string MetadataUploader = "uploader";
+
+ /// Alternative metadata key for uploaded by.
+ public const string MetadataUploadedBy = "uploaded by";
+
+ /// Alternative metadata key for author.
+ public const string MetadataAuthor = "author";
+
+ /// Metadata key for category.
+ public const string MetadataCategory = "category";
+
+ /// Alternative metadata key for file category.
+ public const string MetadataFileCategory = "file category";
+
+ /// Alternative metadata key for type.
+ public const string MetadataType = "type";
+
+ /// Metadata key for MD5 hash.
+ public const string MetadataMd5Hash = "md5 hash";
+
+ /// Metadata key for MD5 hash (alternative).
+ public const string MetadataMd5HashAlt = "md5hash";
+
+ /// Alternative metadata key for MD5 checksum.
+ public const string MetadataMd5Checksum = "md5 checksum";
+
+ /// Alternative metadata key for MD5.
+ public const string MetadataMd5 = "md5";
+
+ /// Alternative metadata key for hash.
+ public const string MetadataHash = "hash";
+
+ /// Alternative metadata key for checksum.
+ public const string MetadataChecksum = "checksum";
+
+ /// Metadata key for total downloads.
+ public const string MetadataTotalDownloads = "total downloads";
+
+ /// Alternative metadata key for download count.
+ public const string MetadataDownloadCount = "download count";
+
+ /// Metadata key for added date.
+ public const string MetadataAdded = "added";
+
+ /// Metadata key for updated date.
+ public const string MetadataUpdated = "updated";
+
+ // ===== Additional Selectors =====
+
+ /// Selector for fallback titles (h1, h2, etc).
+ public const string FallbackTitleSelector = "h2 a, h1 a, h2, h1";
+
+ /// Selector for file detail page title heading outside the global headerbox.
+ public const string FilePageTitleSelector = ".midcolumn h2, .columncenter h2, #downloadsfiles h2, #downloadsinfo h2, #downloads h2, .heading h2, .title h2, h2.title, .midcolumn h3, .heading h3";
+
+ /// Selector for file detail page preview images.
+ public const string FilePreviewImagesSelector = "#downloadmedia img, #downloadsmedia img, #preview img, #media img, .mediagallery img, .imagebox img, #imagebox img, #downloaddescription img, #downloadsummary img, a[href*='/images/'] img, .previewholder img, .media .holder img";
+
+ /// Selector for file description container elements.
+ public const string FileDescriptionContainerSelector = "#downloaddescription, #downloadsummary, #description, .description, .articlebody, #profiletotal";
+
+ /// Regex pattern for extracting parent mod path.
+ public const string ParentModPathRegex = @"(/mods/[^/]+)/(?:downloads|addons)/";
}
diff --git a/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs b/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs
index b15606e59..d5d3dffce 100644
--- a/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs
+++ b/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs
@@ -60,6 +60,21 @@ public static class SuperHackersConstants
///
public const string GeneralsGameCodeRepo = "GeneralsGameCode";
+ ///
+ /// GitHub owner for Generals game patch 2.
+ ///
+ public const string GeneralsGamePatch2Owner = "TheSuperHackers";
+
+ ///
+ /// GitHub repo for Generals game patch 2.
+ ///
+ public const string GeneralsGamePatch2Repo = "GeneralsGamePatch2";
+
+ ///
+ /// Display name for Generals game patch 2.
+ ///
+ public const string GeneralsGamePatch2DisplayName = "Community Patch 2";
+
// ===== Service Configuration =====
///
diff --git a/GenHub/GenHub.Core/Helpers/AppUpdateVersionHelper.cs b/GenHub/GenHub.Core/Helpers/AppUpdateVersionHelper.cs
new file mode 100644
index 000000000..b44ff9c53
--- /dev/null
+++ b/GenHub/GenHub.Core/Helpers/AppUpdateVersionHelper.cs
@@ -0,0 +1,101 @@
+using System;
+using System.Linq;
+using System.Text.RegularExpressions;
+
+namespace GenHub.Core.Helpers;
+
+///
+/// Helper class for application update version comparison and parsing.
+///
+public static partial class AppUpdateVersionHelper
+{
+ ///
+ /// Extracts the workflow run number from a version string (e.g., "0.0.641-pr241" -> 641).
+ /// Returns 0 for plain semantic versions without CI run markers.
+ ///
+ /// The version string to extract the run number from.
+ /// The extracted run number, or 0 if extraction fails or not a CI build.
+ public static int ExtractRunNumber(string? version)
+ {
+ if (string.IsNullOrWhiteSpace(version))
+ {
+ return 0;
+ }
+
+ var match = CiRunNumberRegex().Match(version);
+ if (match.Success && int.TryParse(match.Groups[1].Value, out var runNumber) && runNumber > 0)
+ {
+ return runNumber;
+ }
+
+ var ciMatch = CiMarkerRegex().Match(version);
+ if (ciMatch.Success && int.TryParse(ciMatch.Groups[1].Value, out var ciRunNumber) && ciRunNumber > 0)
+ {
+ return ciRunNumber;
+ }
+
+ return 0;
+ }
+
+ ///
+ /// Checks whether an available artifact version is newer than the currently installed version.
+ ///
+ /// The new artifact version string.
+ /// The current version string.
+ /// True if newVersion is newer than currentVersion; otherwise false.
+ public static bool IsArtifactVersionNewer(string? newVersion, string? currentVersion)
+ {
+ if (string.IsNullOrWhiteSpace(newVersion))
+ {
+ return false;
+ }
+
+ if (string.IsNullOrWhiteSpace(currentVersion))
+ {
+ return true;
+ }
+
+ var newVersionBase = newVersion.Split('+')[0].Trim();
+ var currentVersionBase = currentVersion.Split('+')[0].Trim();
+
+ var newRun = ExtractRunNumber(newVersionBase);
+ var currentRun = ExtractRunNumber(currentVersionBase);
+
+ if (newRun > 0 && currentRun > 0)
+ {
+ return newRun > currentRun;
+ }
+
+ if (newRun == 0 && currentRun > 0)
+ {
+ return false;
+ }
+
+ if (newRun > 0 && currentRun == 0)
+ {
+ return true;
+ }
+
+ var newClean = newVersionBase.Split('-')[0];
+ var currentClean = currentVersionBase.Split('-')[0];
+ if (Version.TryParse(newClean, out var newVer) && Version.TryParse(currentClean, out var currentVer))
+ {
+ return newVer > currentVer;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Regex for extracting workflow run number from a 0.0.X CI version string.
+ /// Matches patterns like "0.0.1282-pr265", "0.0.1282-main", "0.0.1282".
+ ///
+ [GeneratedRegex(@"^0\.0\.(\d+)(?:-[a-zA-Z0-9_.-]+)?$", RegexOptions.IgnoreCase)]
+ private static partial Regex CiRunNumberRegex();
+
+ ///
+ /// Regex for extracting workflow run number from a -ci.X marker.
+ ///
+ [GeneratedRegex(@"-ci\.(\d+)", RegexOptions.IgnoreCase)]
+ private static partial Regex CiMarkerRegex();
+}
diff --git a/GenHub/GenHub.Core/Helpers/CommandLineParser.cs b/GenHub/GenHub.Core/Helpers/CommandLineParser.cs
index f6b570af0..d5c595d36 100644
--- a/GenHub/GenHub.Core/Helpers/CommandLineParser.cs
+++ b/GenHub/GenHub.Core/Helpers/CommandLineParser.cs
@@ -15,9 +15,9 @@ public static class CommandLineParser
/// The extracted profile identifier if present; otherwise, null.
public static string? ExtractProfileId(string[] args)
{
- for (var i = 0; i < args.Length; i++)
+ for (int i = 0; i < args.Length; i++)
{
- var arg = args[i];
+ string arg = args[i];
if (arg.Equals(CommandLineConstants.LaunchProfileArg, StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
{
@@ -34,23 +34,48 @@ public static class CommandLineParser
}
///
- /// Extracts a subscription URL from command line arguments.
- /// Supports the URI scheme format: genhub://subscribe?url=<url>.
+ /// Extracts the absolute URL from a genhub://subscribe?url=... startup argument.
///
+ ///
+ /// The returned value is the url query value only (not the genhub:// wrapper).
+ /// Callers treat it as a GenHub catalog JSON URL today; later it may also be a Provider
+ /// Definition URL without changing this parser.
+ ///
/// The command line arguments.
- /// The extracted catalog URL if present; otherwise, null.
+ /// The decoded absolute URL if present; otherwise, null.
public static string? ExtractSubscriptionUrl(string[] args)
{
- foreach (var arg in args)
+ foreach (string arg in args)
{
if (arg.StartsWith(CommandLineConstants.SubscribeUriPrefix, StringComparison.OrdinalIgnoreCase))
{
- // Simple parsing for ?url=...
- var queryStart = arg.IndexOf(CommandLineConstants.SubscribeUrlParam, StringComparison.OrdinalIgnoreCase);
+ string remainder = arg[CommandLineConstants.SubscribeUriPrefix.Length..];
+ if (!remainder.StartsWith('?') && !remainder.StartsWith("/?", StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ int queryStart = arg.IndexOf(CommandLineConstants.SubscribeUrlParam, StringComparison.OrdinalIgnoreCase);
if (queryStart != -1)
{
- var url = arg[(queryStart + CommandLineConstants.SubscribeUrlParam.Length)..];
- return Uri.UnescapeDataString(url).Trim('"');
+ string url = arg[(queryStart + CommandLineConstants.SubscribeUrlParam.Length)..];
+ string unescaped = Uri.UnescapeDataString(url)
+ .Replace("\r", string.Empty)
+ .Replace("\n", string.Empty)
+ .Trim('"', '\'', ' ', '\t');
+
+ if (string.IsNullOrWhiteSpace(unescaped))
+ {
+ return null;
+ }
+
+ if (Uri.TryCreate(unescaped, UriKind.Absolute, out var uri) &&
+ (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
+ {
+ return unescaped;
+ }
+
+ return null;
}
}
}
diff --git a/GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs b/GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs
new file mode 100644
index 000000000..b19f11d94
--- /dev/null
+++ b/GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs
@@ -0,0 +1,145 @@
+using System;
+using System.Net;
+using System.Text.RegularExpressions;
+
+namespace GenHub.Core.Helpers;
+
+///
+/// Provides high-performance utilities for stripping HTML tags, decoding HTML entities,
+/// and normalizing text descriptions for display across the application.
+///
+public static partial class HtmlTextHelper
+{
+ ///
+ /// Converts an HTML snippet or formatted description into clean, normalized plain text:
+ /// - Replaces <br> and block element closures (</p>, </div>, etc.) with line breaks.
+ /// - Strips all remaining HTML tags.
+ /// - Decodes HTML entities (e.g., &, ", >, ).
+ /// - Normalizes whitespace and excessive blank lines.
+ /// - Uses the platform newline format.
+ ///
+ /// The raw HTML or formatted text string to normalize.
+ /// Normalized plain text, or empty string if input is null or whitespace.
+ public static string NormalizeHtml(string? html)
+ {
+ if (string.IsNullOrWhiteSpace(html))
+ {
+ return string.Empty;
+ }
+
+ // 0. Remove script and style elements along with their contents
+ var text = ScriptTagRegex().Replace(html, string.Empty);
+ text = StyleTagRegex().Replace(text, string.Empty);
+
+ // 1. Convert
tags to newline
+ text = BrTagRegex().Replace(text, "\n");
+
+ // 2. Convert paragraph closing tags to double newline for paragraph separation
+ text = ParagraphCloseTagRegex().Replace(text, "\n\n");
+
+ // 3. Convert other block-level closing tags and
tags to newline
+ text = BlockCloseTagRegex().Replace(text, "\n");
+
+ // 4. Strip all remaining HTML/XML tags
+ text = HtmlTagRegex().Replace(text, string.Empty);
+
+ // 5. Decode HTML entities ( , >, ", ', numeric entities, etc.)
+ text = WebUtility.HtmlDecode(text);
+
+ // 6. Normalize non-breaking spaces and line endings
+ text = text.Replace('\u00A0', ' ')
+ .Replace("\r\n", "\n")
+ .Replace('\r', '\n');
+
+ // 7. Clean trailing whitespace on lines and collapse excess blank lines
+ text = TrailingWhitespaceBeforeNewlineRegex().Replace(text, "\n");
+ text = ExcessBlankLinesRegex().Replace(text, "\n\n");
+
+ // 8. Trim and unify with environment newline
+ text = text.Trim();
+ text = text.Replace("\n", Environment.NewLine);
+
+ return text;
+ }
+
+ ///
+ /// Converts an HTML snippet or multi-line text into a single-line summary without HTML tags,
+ /// collapsing all whitespace runs into a single space, and optionally truncating with an ellipsis.
+ ///
+ /// The input HTML or text string.
+ /// Optional maximum character length including ellipsis.
+ /// A single-line plain text summary.
+ public static string CleanToSingleLine(string? htmlOrText, int? maxLength = null)
+ {
+ if (string.IsNullOrWhiteSpace(htmlOrText))
+ {
+ return string.Empty;
+ }
+
+ // Strip HTML if tags exist, decode entities, and normalize
+ var text = NormalizeHtml(htmlOrText);
+
+ // Collapse all newlines, tabs, and multiple spaces into a single space
+ text = MultiWhitespaceRegex().Replace(text, " ").Trim();
+
+ if (maxLength.HasValue && maxLength.Value > 0 && text.Length > maxLength.Value)
+ {
+ return TruncateWithEllipsis(text, maxLength.Value);
+ }
+
+ return text;
+ }
+
+ ///
+ /// Truncates a string to a specified maximum length and appends an ellipsis ("...") if truncated.
+ ///
+ /// The text to truncate.
+ /// The maximum allowed length (including the ellipsis).
+ /// The truncated text with an ellipsis if it exceeded maxLength, or the original text.
+ public static string TruncateWithEllipsis(string? text, int maxLength)
+ {
+ if (string.IsNullOrWhiteSpace(text) || maxLength <= 0)
+ {
+ return string.Empty;
+ }
+
+ if (text.Length <= maxLength)
+ {
+ return text;
+ }
+
+ if (maxLength <= 3)
+ {
+ return text[..maxLength];
+ }
+
+ return string.Concat(text.AsSpan(0, maxLength - 3), "...");
+ }
+
+ [GeneratedRegex(@"", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex ScriptTagRegex();
+
+ [GeneratedRegex(@"", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex StyleTagRegex();
+
+ [GeneratedRegex(@"
", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex BrTagRegex();
+
+ [GeneratedRegex(@"
", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex ParagraphCloseTagRegex();
+
+ [GeneratedRegex(@"?(?:div|li|h[1-6]|tr|section|article|blockquote|header|footer|hr)\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex BlockCloseTagRegex();
+
+ [GeneratedRegex(@"?[A-Za-z][^>]*>", RegexOptions.CultureInvariant)]
+ private static partial Regex HtmlTagRegex();
+
+ [GeneratedRegex(@"[ \t]+\n", RegexOptions.CultureInvariant)]
+ private static partial Regex TrailingWhitespaceBeforeNewlineRegex();
+
+ [GeneratedRegex(@"(?:\n){3,}", RegexOptions.CultureInvariant)]
+ private static partial Regex ExcessBlankLinesRegex();
+
+ [GeneratedRegex(@"\s+", RegexOptions.CultureInvariant)]
+ private static partial Regex MultiWhitespaceRegex();
+}
diff --git a/GenHub/GenHub.Core/Interfaces/Common/IConfigurationProviderService.cs b/GenHub/GenHub.Core/Interfaces/Common/IConfigurationProviderService.cs
index d97f2f8cf..93a3536b0 100644
--- a/GenHub/GenHub.Core/Interfaces/Common/IConfigurationProviderService.cs
+++ b/GenHub/GenHub.Core/Interfaces/Common/IConfigurationProviderService.cs
@@ -64,6 +64,18 @@ public interface IConfigurationProviderService
/// True if auto-check is enabled; otherwise, false.
bool GetAutoCheckForUpdatesOnStartup();
+ ///
+ /// Gets whether to automatically check for updates periodically.
+ ///
+ /// True if periodic auto-check is enabled; otherwise, false.
+ bool GetAutoCheckForUpdatesPeriodically();
+
+ ///
+ /// Gets the interval in minutes for periodic update checks.
+ ///
+ /// The update check interval in minutes.
+ int GetPeriodicUpdateCheckIntervalMinutes();
+
///
/// Gets whether detailed logging is enabled.
///
diff --git a/GenHub/GenHub.Core/Interfaces/Content/ILocalContentService.cs b/GenHub/GenHub.Core/Interfaces/Content/ILocalContentService.cs
index 84a4d3773..9b26f26b2 100644
--- a/GenHub/GenHub.Core/Interfaces/Content/ILocalContentService.cs
+++ b/GenHub/GenHub.Core/Interfaces/Content/ILocalContentService.cs
@@ -20,6 +20,7 @@ public interface ILocalContentService
/// Optional original source path of the content.
/// Optional progress reporter for tracking manifest creation.
/// Cancellation token.
+ /// Optional relative path of the main executable entry point.
/// A result containing the created manifest or errors.
Task> CreateLocalContentManifestAsync(
string directoryPath,
@@ -28,7 +29,8 @@ Task> CreateLocalContentManifestAsync(
GameType targetGame,
string? sourcePath = null,
IProgress? progress = null,
- CancellationToken cancellationToken = default);
+ CancellationToken cancellationToken = default,
+ string? entryPoint = null);
///
/// Adds local content by creating and storing a manifest.
@@ -66,6 +68,7 @@ Task> AddLocalContentAsync(
/// Optional original source path of the content.
/// Optional progress reporter.
/// Cancellation token.
+ /// Optional relative path of the main executable entry point.
/// A result containing the updated manifest.
Task> UpdateLocalContentManifestAsync(
string existingManifestId,
@@ -75,7 +78,8 @@ Task> UpdateLocalContentManifestAsync(
GameType targetGame,
string? sourcePath = null,
IProgress? progress = null,
- CancellationToken cancellationToken = default);
+ CancellationToken cancellationToken = default,
+ string? entryPoint = null);
///
/// Gets the allowed content types for local content creation.
diff --git a/GenHub/GenHub.Core/Interfaces/Notifications/INotificationService.cs b/GenHub/GenHub.Core/Interfaces/Notifications/INotificationService.cs
index 34a88df40..55800dd3e 100644
--- a/GenHub/GenHub.Core/Interfaces/Notifications/INotificationService.cs
+++ b/GenHub/GenHub.Core/Interfaces/Notifications/INotificationService.cs
@@ -28,6 +28,11 @@ public interface INotificationService
///
IObservable NotificationHistory { get; }
+ ///
+ /// Gets the observable stream of notification update requests.
+ ///
+ IObservable<(Guid Id, string? Title, string Message)> UpdateRequests { get; }
+
///
/// Shows an informational notification.
///
@@ -70,6 +75,14 @@ public interface INotificationService
/// The notification to show.
void Show(NotificationMessage notification);
+ ///
+ /// Updates the message and optionally the title of an active notification.
+ ///
+ /// The ID of the notification to update.
+ /// The new message content.
+ /// Optional new title. If null, the existing title is preserved.
+ void Update(Guid notificationId, string message, string? title = null);
+
///
/// Dismisses a specific notification.
///
diff --git a/GenHub/GenHub.Core/Interfaces/Parsers/IWebPageParser.cs b/GenHub/GenHub.Core/Interfaces/Parsers/IWebPageParser.cs
index 136018297..2d75645fe 100644
--- a/GenHub/GenHub.Core/Interfaces/Parsers/IWebPageParser.cs
+++ b/GenHub/GenHub.Core/Interfaces/Parsers/IWebPageParser.cs
@@ -1,3 +1,8 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
using GenHub.Core.Models.Parsers;
namespace GenHub.Core.Interfaces.Parsers;
@@ -36,4 +41,56 @@ public interface IWebPageParser
/// Cancellation token.
/// A parsed web page with all extracted content sections.
Task ParseAsync(string url, string html, CancellationToken cancellationToken = default);
+
+ ///
+ /// Parses a specific file or item detail page.
+ /// Default implementation delegates to .
+ ///
+ /// The detail page URL.
+ /// Cancellation token.
+ /// A parsed web page containing the detailed file information.
+ Task ParseFileDetailAsync(string url, CancellationToken cancellationToken = default)
+ => ParseAsync(url, cancellationToken);
+
+ ///
+ /// Parses multiple file or item detail pages in a batch.
+ /// Default implementation delegates to .
+ ///
+ /// The detail page URLs to parse.
+ /// Cancellation token.
+ /// A dictionary mapping each URL to its parsed web page result.
+ async Task> ParseFileDetailsManyAsync(
+ IReadOnlyList urls,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(urls);
+ var results = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var url in urls.Distinct(StringComparer.OrdinalIgnoreCase))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ try
+ {
+ var page = await ParseFileDetailAsync(url, cancellationToken);
+ results[url] = page;
+ }
+ catch (HttpRequestException)
+ {
+ // soft failure per url in batch
+ }
+ catch (IOException)
+ {
+ // soft failure per url in batch
+ }
+ catch (InvalidOperationException)
+ {
+ // soft failure per url in batch
+ }
+ catch (FormatException)
+ {
+ // soft failure per url in batch
+ }
+ }
+
+ return results;
+ }
}
diff --git a/GenHub/GenHub.Core/Interfaces/Tools/IPlaywrightService.cs b/GenHub/GenHub.Core/Interfaces/Tools/IPlaywrightService.cs
index f2007576a..df73f9827 100644
--- a/GenHub/GenHub.Core/Interfaces/Tools/IPlaywrightService.cs
+++ b/GenHub/GenHub.Core/Interfaces/Tools/IPlaywrightService.cs
@@ -6,6 +6,7 @@
using GenHub.Core.Models.Results;
using Microsoft.Playwright;
using System;
+using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
@@ -25,6 +26,31 @@ public interface IPlaywrightService
/// A new IPage instance.
Task CreatePageAsync(BrowserNewContextOptions? options = null, CancellationToken cancellationToken = default);
+ ///
+ /// Creates a page in a persistent, headed browser context whose cookies and storage survive
+ /// across calls. Use this for bot-protected sites (e.g. ModDB's Cloudflare): the user solves
+ /// the challenge once, the resulting clearance cookie is persisted to disk, and subsequent
+ /// pages in the same session (and across app restarts, until the cookie expires) load without
+ /// another challenge. A real browser window is shown while the challenge is pending.
+ ///
+ /// The on-disk profile name (scoped under the app data browser-profile root).
+ /// Cancellation token.
+ /// A new in the persistent context.
+ Task CreatePersistentPageAsync(string profileName, CancellationToken cancellationToken = default);
+
+ ///
+ /// Closes a page from and shuts down the headed Chromium
+ /// window when no active pages remain. Prefer this over page.CloseAsync alone so
+ /// callers do not leave an about:blank window open after a successful ModDB scrape.
+ ///
+ /// The persistent-context page to close.
+ ///
+ /// When , leaves the page open (e.g. so the user can finish a Cloudflare
+ /// challenge) without closing the browser.
+ ///
+ /// A task representing the asynchronous operation.
+ Task ClosePersistentPageAsync(IPage page, bool keepOpen = false);
+
///
/// Fetches HTML content from a URL using Playwright.
///
@@ -41,6 +67,35 @@ public interface IPlaywrightService
/// A parsed AngleSharp IDocument.
Task FetchAndParseAsync(string url, CancellationToken cancellationToken = default);
+ ///
+ /// Fetches and parses a web page in a persistent, headed browser context whose cookies survive
+ /// across calls. Use this for bot-protected URLs (e.g. ModDB) so the Cloudflare clearance cookie
+ /// obtained from a single manual challenge solve is reused.
+ ///
+ /// The on-disk profile name (scoped under the app data browser-profile root).
+ /// The URL to fetch and parse.
+ /// Cancellation token.
+ /// A parsed AngleSharp IDocument.
+ Task FetchAndParsePersistentAsync(string profileName, string url, CancellationToken cancellationToken = default);
+
+ ///
+ /// Fetches and parses multiple URLs in one persistent headed page — open once, navigate each
+ /// URL in order, then close. Use this for ModDB section sweeps so Chromium does not spawn a
+ /// new window per section (and so concurrent NewPage/Close races cannot tear down the context
+ /// mid-navigation).
+ ///
+ /// The on-disk profile name (scoped under the app data browser-profile root).
+ /// URLs to fetch in order. Duplicates are fetched once; order of first occurrence is kept.
+ /// Cancellation token.
+ ///
+ /// A map of URL → parsed document for every URL that loaded successfully. Failed URLs are omitted;
+ /// callers should treat a missing key as a soft failure for that section.
+ ///
+ Task> FetchAndParsePersistentManyAsync(
+ string profileName,
+ IReadOnlyList urls,
+ CancellationToken cancellationToken = default);
+
///
/// Downloads a file using Playwright to handle complex scenarios (like anti-bot protections).
///
@@ -48,4 +103,27 @@ public interface IPlaywrightService
/// Cancellation token.
/// A DownloadResult indicating success or failure.
Task DownloadFileAsync(DownloadConfiguration configuration, CancellationToken cancellationToken = default);
+
+ ///
+ /// Executes an operation within a scoped persistent browser context session.
+ /// The persistent browser window stays open for the duration of the operation and closes
+ /// immediately when the operation completes, avoiding multiple window launches and idle delays.
+ ///
+ /// The return type of the operation.
+ /// The on-disk profile name.
+ /// The asynchronous operation to execute.
+ /// Cancellation token.
+ /// The result of the operation.
+ Task ExecuteInPersistentContextAsync(
+ string profileName,
+ Func> operation,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Asynchronously pre-warms the Playwright driver runtime in the background so subsequent
+ /// browser operations launch with minimal latency.
+ ///
+ /// Cancellation token.
+ /// A task representing the background warmup operation.
+ Task WarmupAsync(CancellationToken cancellationToken = default);
}
diff --git a/GenHub/GenHub.Core/Messages/UpdateSettingsChangedMessage.cs b/GenHub/GenHub.Core/Messages/UpdateSettingsChangedMessage.cs
new file mode 100644
index 000000000..e199ff5b3
--- /dev/null
+++ b/GenHub/GenHub.Core/Messages/UpdateSettingsChangedMessage.cs
@@ -0,0 +1,12 @@
+namespace GenHub.Core.Messages;
+
+///
+/// Message sent when update settings have changed.
+///
+/// Whether to check for updates on startup.
+/// Whether to check for updates periodically.
+/// Interval in minutes between periodic update checks.
+public record UpdateSettingsChangedMessage(
+ bool AutoCheckForUpdatesOnStartup,
+ bool AutoCheckForUpdatesPeriodically,
+ int PeriodicUpdateCheckIntervalMinutes);
diff --git a/GenHub/GenHub.Core/Models/AppUpdate/PullRequestInfo.cs b/GenHub/GenHub.Core/Models/AppUpdate/PullRequestInfo.cs
index a8d2a9693..e9fc4bb56 100644
--- a/GenHub/GenHub.Core/Models/AppUpdate/PullRequestInfo.cs
+++ b/GenHub/GenHub.Core/Models/AppUpdate/PullRequestInfo.cs
@@ -50,6 +50,11 @@ public record PullRequestInfo
///
public string DisplayVersion => LatestArtifact?.DisplayVersion ?? $"0.0.{Number}";
+ ///
+ /// Gets the display title formatted with the PR number (e.g., "#123 - PR Title").
+ ///
+ public string DisplayTitle => $"#{Number} - {Title}";
+
///
/// Gets a value indicating whether this PR is still open.
///
diff --git a/GenHub/GenHub.Core/Models/Common/UserSettings.cs b/GenHub/GenHub.Core/Models/Common/UserSettings.cs
index 4b77fe175..c33263307 100644
--- a/GenHub/GenHub.Core/Models/Common/UserSettings.cs
+++ b/GenHub/GenHub.Core/Models/Common/UserSettings.cs
@@ -39,6 +39,12 @@ public class UserSettings
/// Gets or sets a value indicating whether to automatically check for updates on startup.
public bool AutoCheckForUpdatesOnStartup { get; set; } = true;
+ /// Gets or sets a value indicating whether to automatically check for updates periodically.
+ public bool AutoCheckForUpdatesPeriodically { get; set; } = true;
+
+ /// Gets or sets the interval in minutes between periodic update checks.
+ public int PeriodicUpdateCheckIntervalMinutes { get; set; } = GenHub.Core.Constants.AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes;
+
/// Gets or sets the timestamp of the last update check in ISO 8601 format.
public string? LastUpdateCheckTimestamp { get; set; }
@@ -151,6 +157,8 @@ public UserSettings Clone()
MaxConcurrentDownloads = MaxConcurrentDownloads,
AllowBackgroundDownloads = AllowBackgroundDownloads,
AutoCheckForUpdatesOnStartup = AutoCheckForUpdatesOnStartup,
+ AutoCheckForUpdatesPeriodically = AutoCheckForUpdatesPeriodically,
+ PeriodicUpdateCheckIntervalMinutes = PeriodicUpdateCheckIntervalMinutes,
LastUpdateCheckTimestamp = LastUpdateCheckTimestamp,
EnableDetailedLogging = EnableDetailedLogging,
DefaultWorkspaceStrategy = DefaultWorkspaceStrategy,
diff --git a/GenHub/GenHub.Core/Models/Manifest/ManifestIdJsonConverter.cs b/GenHub/GenHub.Core/Models/Manifest/ManifestIdJsonConverter.cs
index 83d681312..7a93f17cf 100644
--- a/GenHub/GenHub.Core/Models/Manifest/ManifestIdJsonConverter.cs
+++ b/GenHub/GenHub.Core/Models/Manifest/ManifestIdJsonConverter.cs
@@ -9,7 +9,7 @@ namespace GenHub.Core.Models.Manifest;
public sealed class ManifestIdJsonConverter : JsonConverter
{
///
- public override ManifestId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ public override ManifestId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) // skipcq: CS-R1138
{
var s = reader.GetString() ?? string.Empty;
return ManifestId.Create(s);
diff --git a/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs b/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs
index ddb22794a..fc609db20 100644
--- a/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs
+++ b/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs
@@ -166,7 +166,13 @@ public static EntryPointResolution ResolveEntryPoint(
files);
}
- private static bool PathsMatch(string left, string right) =>
+ ///
+ /// Determines whether two relative file paths match, normalizing directory separators and leading slashes.
+ ///
+ /// The first relative path.
+ /// The second relative path.
+ /// true if the paths match; otherwise, false.
+ public static bool PathsMatch(string left, string right) =>
string.Equals(
left.Replace('\\', '/').TrimStart('/'),
right.Replace('\\', '/').TrimStart('/'),
diff --git a/GenHub/GenHub.Core/Models/ModDB/MapDetails.cs b/GenHub/GenHub.Core/Models/ModDB/MapDetails.cs
index 347634147..979071455 100644
--- a/GenHub/GenHub.Core/Models/ModDB/MapDetails.cs
+++ b/GenHub/GenHub.Core/Models/ModDB/MapDetails.cs
@@ -37,4 +37,4 @@ public record MapDetails(
string? FileType = null,
float? Rating = null,
string? RefererUrl = null,
- List? AdditionalFiles = null);
+ List? AdditionalFiles = null);
diff --git a/GenHub/GenHub.Core/Models/ModDB/ModDBCategoryMapper.cs b/GenHub/GenHub.Core/Models/ModDB/ModDBCategoryMapper.cs
index cf2fc5920..c0f1e9a1f 100644
--- a/GenHub/GenHub.Core/Models/ModDB/ModDBCategoryMapper.cs
+++ b/GenHub/GenHub.Core/Models/ModDB/ModDBCategoryMapper.cs
@@ -19,8 +19,8 @@ public static ContentType MapCategory(string? categoryCode)
// Releases (Mods)
"2" => ContentType.Mod, // Full Version
"3" => ContentType.Mod, // Demo
- "4" => ContentType.Patch, // Patch
- "28" => ContentType.Patch, // Script
+ "4" => ContentType.Mod, // Patch (mod release/update)
+ "28" => ContentType.Mod, // Script (mod script/release)
"29" => ContentType.Addon, // Trainer
// Media
@@ -60,11 +60,11 @@ public static ContentType MapCategory(string? categoryCode)
"131" => ContentType.Addon, // Model Pack
// Addons - Skins
- "112" => ContentType.Skin, // Player Skin
- "133" => ContentType.Skin, // Prop Skin
- "113" => ContentType.Skin, // Vehicle Skin
- "114" => ContentType.Skin, // Weapon Skin
- "134" => ContentType.Skin, // Skin Pack
+ "112" => ContentType.Addon, // Player Skin
+ "133" => ContentType.Addon, // Prop Skin
+ "113" => ContentType.Addon, // Vehicle Skin
+ "114" => ContentType.Addon, // Weapon Skin
+ "134" => ContentType.Addon, // Skin Pack
// Addons - Audio
"117" => ContentType.Addon, // Music
@@ -75,8 +75,8 @@ public static ContentType MapCategory(string? categoryCode)
// Addons - Graphics
"124" => ContentType.Addon, // Decal
"136" => ContentType.Addon, // Effects GFX
- "125" => ContentType.Skin, // GUI
- "126" => ContentType.Skin, // HUD
+ "125" => ContentType.Addon, // GUI
+ "126" => ContentType.Addon, // HUD
"128" => ContentType.Addon, // Sprite
"129" => ContentType.Addon, // Texture
@@ -103,10 +103,15 @@ public static ContentType MapCategoryByName(string? categoryName)
{
var s when s.Contains("full version") => ContentType.Mod,
var s when s.Contains("demo") => ContentType.Mod,
- var s when s.Contains("patch") => ContentType.Patch,
- var s when s.Contains("script") => ContentType.Patch,
+ var s when s.Contains("patch") => ContentType.Mod,
+ var s when s.Contains("script") => ContentType.Mod,
var s when s.Contains("trainer") => ContentType.Addon,
+ var s when s.Contains("tool") => ContentType.ModdingTool,
+ var s when s.Contains("sdk") => ContentType.ModdingTool,
+ var s when s.Contains("ide") => ContentType.ModdingTool,
+ var s when s.Contains("source code") => ContentType.ModdingTool,
+
var s when s.Contains("trailer") => ContentType.Video,
var s when s.Contains("movie") => ContentType.Video,
var s when s.Contains("video") => ContentType.Video,
@@ -116,17 +121,12 @@ var s when s.Contains("singleplayer map") => ContentType.Map,
var s when s.Contains("map") => ContentType.Map,
var s when s.Contains("prefab") => ContentType.Map,
- var s when s.Contains("skin") => ContentType.Skin,
- var s when s.Contains("gui") => ContentType.Skin,
- var s when s.Contains("hud") => ContentType.Skin,
+ var s when s.Contains("skin") => ContentType.Addon,
+ var s when s.Contains("gui") => ContentType.Addon,
+ var s when s.Contains("hud") => ContentType.Addon,
var s when s.Contains("language") => ContentType.LanguagePack,
- var s when s.Contains("tool") => ContentType.ModdingTool,
- var s when s.Contains("sdk") => ContentType.ModdingTool,
- var s when s.Contains("ide") => ContentType.ModdingTool,
- var s when s.Contains("source code") => ContentType.ModdingTool,
-
_ => ContentType.Addon,
};
}
diff --git a/GenHub/GenHub.Core/Models/ModDB/ModDBFilter.cs b/GenHub/GenHub.Core/Models/ModDB/ModDBFilter.cs
index a977a5f08..88232974f 100644
--- a/GenHub/GenHub.Core/Models/ModDB/ModDBFilter.cs
+++ b/GenHub/GenHub.Core/Models/ModDB/ModDBFilter.cs
@@ -1,3 +1,7 @@
+using System;
+using System.Collections.Generic;
+using GenHub.Core.Constants;
+
namespace GenHub.Core.Models.ModDB;
///
@@ -21,7 +25,7 @@ public class ModDBFilter
public string? Licence { get; set; }
/// Gets or sets the sort parameter.
- public string? Sort { get; set; }
+ public string? Sort { get; set; } = ModDBConstants.DefaultSort;
/// Gets or sets the page number (1-based).
public int Page { get; set; } = 1;
diff --git a/GenHub/GenHub.Core/Models/Parsers/Comment.cs b/GenHub/GenHub.Core/Models/Parsers/Comment.cs
index 645e1fd5a..dd1532318 100644
--- a/GenHub/GenHub.Core/Models/Parsers/Comment.cs
+++ b/GenHub/GenHub.Core/Models/Parsers/Comment.cs
@@ -1,3 +1,6 @@
+using System;
+using System.Collections.Generic;
+
namespace GenHub.Core.Models.Parsers;
///
@@ -8,9 +11,13 @@ namespace GenHub.Core.Models.Parsers;
/// The comment date (optional).
/// The karma/vote score (optional).
/// Whether the comment is from the content creator (optional).
+/// Indentation depth level for reply threads (optional).
+/// Child replies to this comment (optional).
public record Comment(
string? Author = null,
string? Content = null,
DateTime? Date = null,
int? Karma = null,
- bool? IsCreator = null) : ContentSection(SectionType.Comment, "Comment");
+ bool? IsCreator = null,
+ int IndentLevel = 0,
+ IReadOnlyList? Replies = null) : ContentSection(SectionType.Comment, "Comment");
diff --git a/GenHub/GenHub.Core/Models/Parsers/DownloadableFile.cs b/GenHub/GenHub.Core/Models/Parsers/DownloadableFile.cs
new file mode 100644
index 000000000..0df14e67a
--- /dev/null
+++ b/GenHub/GenHub.Core/Models/Parsers/DownloadableFile.cs
@@ -0,0 +1,42 @@
+namespace GenHub.Core.Models.Parsers;
+
+///
+/// Represents a downloadable file extracted from a web page.
+///
+/// The file name.
+/// The file version (optional).
+/// File size in bytes (optional).
+/// Human-readable file size (optional).
+/// The upload date (optional).
+/// The file category (optional).
+/// The uploader name (optional).
+/// The download URL (optional).
+/// The MD5 hash of the file (optional).
+/// Number of comments (optional).
+/// The thumbnail image URL (optional).
+/// Number of downloads (optional).
+/// The file section type (Downloads or Addons).
+/// The release date (optional, may differ from upload date).
+/// The web page details URL (optional).
+/// The full description or release notes (optional).
+/// List of preview image URLs (optional).
+/// The actual file archive name (optional).
+public record DownloadableFile(
+ string Name,
+ string? Version = null,
+ long? SizeBytes = null,
+ string? SizeDisplay = null,
+ DateTime? UploadDate = null,
+ string? Category = null,
+ string? Uploader = null,
+ string? DownloadUrl = null,
+ string? Md5Hash = null,
+ int? CommentCount = null,
+ string? ThumbnailUrl = null,
+ int? DownloadCount = null,
+ FileSectionType FileSectionType = FileSectionType.Downloads,
+ DateTime? ReleaseDate = null,
+ string? DetailsUrl = null,
+ string? Description = null,
+ System.Collections.Generic.IReadOnlyList? PreviewImages = null,
+ string? Filename = null) : ContentSection(SectionType.File, Name);
diff --git a/GenHub/GenHub.Core/Models/Parsers/FileSectionType.cs b/GenHub/GenHub.Core/Models/Parsers/FileSectionType.cs
new file mode 100644
index 000000000..9fddb1afb
--- /dev/null
+++ b/GenHub/GenHub.Core/Models/Parsers/FileSectionType.cs
@@ -0,0 +1,13 @@
+namespace GenHub.Core.Models.Parsers;
+
+///
+/// Represents the type of file section, distinguishing between main releases and addon files.
+///
+public enum FileSectionType
+{
+ /// Files from the main releases/downloads section.
+ Downloads,
+
+ /// Files from the addons section.
+ Addons,
+}
diff --git a/GenHub/GenHub.Core/Serialization/JsonWorkspaceStrategyConverter.cs b/GenHub/GenHub.Core/Serialization/JsonWorkspaceStrategyConverter.cs
index 24058e7d9..04375f0ea 100644
--- a/GenHub/GenHub.Core/Serialization/JsonWorkspaceStrategyConverter.cs
+++ b/GenHub/GenHub.Core/Serialization/JsonWorkspaceStrategyConverter.cs
@@ -15,7 +15,8 @@ public class JsonWorkspaceStrategyConverter : JsonConverter
///
[SuppressMessage("Maintainability", "CS-R1138:Inappropriate ordering of parameters", Justification = "Signature is defined by System.Text.Json.Serialization.JsonConverter.Read")]
[SuppressMessage("DeepSource", "CS-R1138", Justification = "Signature is defined by System.Text.Json.Serialization.JsonConverter.Read")]
- public override WorkspaceStrategy Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ [SuppressMessage("csharp", "CS-R1138", Justification = "Signature is defined by System.Text.Json.Serialization.JsonConverter.Read")]
+ public override WorkspaceStrategy Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) // skipcq: CS-R1138
{
if (reader.TokenType == JsonTokenType.Number)
{
diff --git a/GenHub/GenHub.Core/Services/Content/LocalContentService.cs b/GenHub/GenHub.Core/Services/Content/LocalContentService.cs
index 8f544eec5..2912aa9a5 100644
--- a/GenHub/GenHub.Core/Services/Content/LocalContentService.cs
+++ b/GenHub/GenHub.Core/Services/Content/LocalContentService.cs
@@ -57,7 +57,8 @@ public async Task> CreateLocalContentManifestAs
GameType targetGame,
string? sourcePath = null,
IProgress? progress = null,
- CancellationToken cancellationToken = default)
+ CancellationToken cancellationToken = default,
+ string? entryPoint = null)
{
try
{
@@ -102,6 +103,29 @@ public async Task> CreateLocalContentManifestAs
var manifest = builder.Build();
manifest.SourcePath = !string.IsNullOrEmpty(sourcePath) ? sourcePath : directoryPath;
+ if (!string.IsNullOrWhiteSpace(entryPoint))
+ {
+ var normalizedEntryPoint = entryPoint.Replace('\\', '/').TrimStart('/');
+
+ var segments = normalizedEntryPoint.Split('/', StringSplitOptions.RemoveEmptyEntries);
+ if (Path.IsPathRooted(entryPoint) || segments.Any(s => s == ".."))
+ {
+ return OperationResult.CreateFailure(
+ $"Entry point '{entryPoint}' is invalid. It must be a relative path without parent directory traversal ('..').");
+ }
+
+ var matchedFile = manifest.Files.FirstOrDefault(f =>
+ ManifestVariantResolver.PathsMatch(f.RelativePath, normalizedEntryPoint));
+
+ if (matchedFile == null)
+ {
+ return OperationResult.CreateFailure(
+ $"Entry point '{entryPoint}' was not found among the files in the directory.");
+ }
+
+ manifest.EntryPoint = matchedFile.RelativePath.Replace('\\', '/');
+ }
+
// Auto-add GameInstallation dependency for GameClient content types
// This ensures auto-resolution logic works correctly for locally added clients
if (contentType == ContentType.GameClient)
@@ -195,13 +219,14 @@ public async Task> UpdateLocalContentManifestAs
GameType targetGame,
string? sourcePath = null,
IProgress? progress = null,
- CancellationToken cancellationToken = default)
+ CancellationToken cancellationToken = default,
+ string? entryPoint = null)
{
try
{
// 1. Create the new manifest/content
// We do this FIRST to ensure the new content is valid before deleting the old one
- var createResult = await CreateLocalContentManifestAsync(directoryPath, name, contentType, targetGame, sourcePath, progress, cancellationToken);
+ var createResult = await CreateLocalContentManifestAsync(directoryPath, name, contentType, targetGame, sourcePath, progress, cancellationToken, entryPoint);
if (!createResult.Success)
{
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs
index 49836a2dc..7cd571b77 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs
@@ -549,6 +549,58 @@ public void GetAutoCheckForUpdatesOnStartup_ReturnsUserSetting(bool userValue)
Assert.Equal(userValue, result);
}
+ ///
+ /// Verifies that GetAutoCheckForUpdatesPeriodically returns user setting when explicitly set.
+ ///
+ /// The value to set for AutoCheckForUpdatesPeriodically in user settings.
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void GetAutoCheckForUpdatesPeriodically_ReturnsUserSetting(bool userValue)
+ {
+ // Arrange
+ var userSettings = new UserSettings { AutoCheckForUpdatesPeriodically = userValue };
+ userSettings.MarkAsExplicitlySet(nameof(UserSettings.AutoCheckForUpdatesPeriodically));
+ _mockUserSettings.Setup(x => x.Get()).Returns(userSettings);
+
+ var provider = CreateProvider();
+
+ // Act
+ var result = provider.GetAutoCheckForUpdatesPeriodically();
+
+ // Assert
+ Assert.Equal(userValue, result);
+ }
+
+ ///
+ /// Verifies that GetPeriodicUpdateCheckIntervalMinutes returns user setting when explicitly set.
+ ///
+ /// The interval to set in user settings.
+ /// The expected clamped interval.
+ [Theory]
+ [InlineData(60, 60)]
+ [InlineData(0, AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes)]
+ [InlineData(20000, AppUpdateConstants.MaxPeriodicUpdateCheckIntervalMinutes)]
+ public void GetPeriodicUpdateCheckIntervalMinutes_ReturnsUserSetting(int intervalMinutes, int expectedMinutes)
+ {
+ // Arrange
+ var userSettings = new UserSettings { PeriodicUpdateCheckIntervalMinutes = intervalMinutes };
+ if (intervalMinutes > 0)
+ {
+ userSettings.MarkAsExplicitlySet(nameof(UserSettings.PeriodicUpdateCheckIntervalMinutes));
+ }
+
+ _mockUserSettings.Setup(x => x.Get()).Returns(userSettings);
+
+ var provider = CreateProvider();
+
+ // Act
+ var result = provider.GetPeriodicUpdateCheckIntervalMinutes();
+
+ // Assert
+ Assert.Equal(expectedMinutes, result);
+ }
+
///
/// Verifies that GetEnableDetailedLogging returns user setting when explicitly set.
///
@@ -784,7 +836,8 @@ public void GetGitHubDiscoveryRepositories_WithNullUserSetting_ReturnsDefaults()
// Assert
Assert.Contains("TheSuperHackers/GeneralsGameCode", result);
- Assert.Single(result);
+ Assert.Contains("TheSuperHackers/GeneralsGamePatch2", result);
+ Assert.Equal(2, result.Count);
}
///
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs
index 9b37967c3..1e105e2ef 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs
@@ -66,6 +66,8 @@ public void Get_WhenNoFileExists_ReturnsDefaultUserSettings()
Assert.Equal(DownloadDefaults.MaxConcurrentDownloads, settings.MaxConcurrentDownloads);
Assert.True(settings.AllowBackgroundDownloads);
Assert.True(settings.AutoCheckForUpdatesOnStartup);
+ Assert.True(settings.AutoCheckForUpdatesPeriodically);
+ Assert.Equal(AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes, settings.PeriodicUpdateCheckIntervalMinutes);
Assert.Equal(WorkspaceConstants.DefaultWorkspaceStrategy, settings.DefaultWorkspaceStrategy);
}
@@ -364,6 +366,25 @@ public void UpdateSettings_EnableDetailedLogging_CanBeSetAndRetrieved(bool enabl
Assert.Equal(enableLogging, currentSettings.EnableDetailedLogging);
}
+ ///
+ /// Verifies that periodic update settings can be set and retrieved correctly.
+ ///
+ [Fact]
+ public void UpdateSettings_PeriodicUpdateSettings_CanBeSetAndRetrieved()
+ {
+ var service = CreateService();
+
+ service.Update(settings =>
+ {
+ settings.AutoCheckForUpdatesPeriodically = false;
+ settings.PeriodicUpdateCheckIntervalMinutes = 15;
+ });
+ var currentSettings = service.Get();
+
+ Assert.False(currentSettings.AutoCheckForUpdatesPeriodically);
+ Assert.Equal(15, currentSettings.PeriodicUpdateCheckIntervalMinutes);
+ }
+
private static IAppConfiguration CreateAppConfigMock()
{
var appConfig = new Mock();
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/AppUpdateConstantsTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/AppUpdateConstantsTests.cs
new file mode 100644
index 000000000..be8cebf08
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/AppUpdateConstantsTests.cs
@@ -0,0 +1,95 @@
+using System;
+using GenHub.Core.Constants;
+using Xunit;
+
+namespace GenHub.Tests.Core.Constants;
+
+///
+/// Unit tests for .
+///
+public class AppUpdateConstantsTests
+{
+ ///
+ /// Tests that tab index constants have expected values.
+ ///
+ [Fact]
+ public void TabIndex_Constants_ShouldHaveExpectedValues()
+ {
+ Assert.Equal(0, AppUpdateConstants.UpdateTabIndex);
+ Assert.Equal(1, AppUpdateConstants.BrowseBuildsTabIndex);
+ Assert.Equal(1, AppUpdateConstants.MaxTabIndex);
+ }
+
+ ///
+ /// Tests that platform and artifact prefix constants have expected values.
+ ///
+ [Fact]
+ public void ArtifactAndPlatform_Constants_ShouldHaveExpectedValues()
+ {
+ Assert.Equal("velopack", AppUpdateConstants.VelopackDirectory);
+ Assert.Equal("genhub-velopack-windows-", AppUpdateConstants.ArtifactPrefixWindows);
+ Assert.Equal("genhub-velopack-linux-", AppUpdateConstants.ArtifactPrefixLinux);
+ Assert.Equal("GenHub-Release", AppUpdateConstants.ArtifactNameRelease);
+ Assert.Equal("windows", AppUpdateConstants.PlatformWindows);
+ Assert.Equal("linux", AppUpdateConstants.PlatformLinux);
+ }
+
+ ///
+ /// Tests that periodic update check interval constants have expected values.
+ ///
+ [Fact]
+ public void PeriodicUpdateCheckInterval_Constants_ShouldHaveExpectedValues()
+ {
+ Assert.Equal(30, AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes);
+ Assert.Equal(5, AppUpdateConstants.MinPeriodicUpdateCheckIntervalMinutes);
+ Assert.Equal(10080, AppUpdateConstants.MaxPeriodicUpdateCheckIntervalMinutes);
+ Assert.Equal(5, AppUpdateConstants.PeriodicUpdateCheckIntervalIncrementMinutes);
+ Assert.True(AppUpdateConstants.MinPeriodicUpdateCheckIntervalMinutes <= AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes);
+ Assert.True(AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes <= AppUpdateConstants.MaxPeriodicUpdateCheckIntervalMinutes);
+ }
+
+ ///
+ /// Tests that timespan constants have expected durations.
+ ///
+ [Fact]
+ public void TimeSpan_Constants_ShouldHaveExpectedValues()
+ {
+ Assert.Equal(TimeSpan.FromSeconds(5), AppUpdateConstants.PostUpdateExitDelay);
+ Assert.Equal(TimeSpan.FromHours(1), AppUpdateConstants.CacheDuration);
+ Assert.Equal(3, AppUpdateConstants.MaxHttpRetries);
+ }
+
+ ///
+ /// Tests that notification title and format constants are non-empty strings.
+ ///
+ [Fact]
+ public void NotificationAndFormat_Constants_ShouldBeValid()
+ {
+ Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.UpdateAvailableNotificationTitle));
+ Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.BranchUpdateAvailableNotificationTitle));
+ Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.PrUpdateAvailableNotificationTitle));
+ Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.UpdatingAppNotificationTitle));
+ Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.UpdateFailedNotificationTitle));
+ Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.UpdateAction));
+ Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.ViewUpdatesAction));
+ Assert.Contains("{0}", AppUpdateConstants.ReleaseUpdateNotificationFormat);
+ Assert.Contains("{0}", AppUpdateConstants.BranchUpdateNotificationFormat);
+ Assert.Contains("{1}", AppUpdateConstants.BranchUpdateNotificationFormat);
+ Assert.Contains("{0}", AppUpdateConstants.PrUpdateNotificationFormat);
+ Assert.Contains("{1}", AppUpdateConstants.PrUpdateNotificationFormat);
+ Assert.Contains("{0}", AppUpdateConstants.UpdateFailedNotificationFormat);
+ }
+
+ ///
+ /// Tests that sort option constants are distinct non-empty strings.
+ ///
+ [Fact]
+ public void SortOption_Constants_ShouldBeDistinctAndNonEmpty()
+ {
+ Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.SortOptionLastUpdated));
+ Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.SortOptionPrNumberDesc));
+ Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.SortOptionPrNumberAsc));
+ Assert.NotEqual(AppUpdateConstants.SortOptionLastUpdated, AppUpdateConstants.SortOptionPrNumberDesc);
+ Assert.NotEqual(AppUpdateConstants.SortOptionPrNumberDesc, AppUpdateConstants.SortOptionPrNumberAsc);
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs
index fed2373b8..b4ea11d25 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs
@@ -1,8 +1,15 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
using GenHub.Core.Interfaces.Common;
+using GenHub.Core.Models.AppUpdate;
+using GenHub.Core.Models.Common;
using GenHub.Features.AppUpdate.Interfaces;
using GenHub.Features.AppUpdate.ViewModels;
using Microsoft.Extensions.Logging;
using Moq;
+using Xunit;
namespace GenHub.Tests.Core.Features.AppUpdate.ViewModels;
@@ -23,7 +30,7 @@ public async Task CheckForUpdatesCommand_WhenNoUpdateAvailable_UpdatesStatusAsyn
.ReturnsAsync((Velopack.UpdateInfo?)null);
var mockUserSettings = new Mock();
- mockUserSettings.Setup(x => x.Get()).Returns(new GenHub.Core.Models.Common.UserSettings());
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
var vm = new UpdateNotificationViewModel(
mockVelopack.Object,
@@ -43,7 +50,7 @@ public async Task CheckForUpdatesCommand_WhenNoUpdateAvailable_UpdatesStatusAsyn
public void Constructor_InitializesSuccessfully()
{
var mockUserSettings = new Mock();
- mockUserSettings.Setup(x => x.Get()).Returns(new GenHub.Core.Models.Common.UserSettings());
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
var vm = new UpdateNotificationViewModel(
Mock.Of(),
@@ -63,7 +70,7 @@ public void Constructor_InitializesSuccessfully()
public void IsCheckButtonEnabled_ReflectsCheckingState()
{
var mockUserSettings = new Mock();
- mockUserSettings.Setup(x => x.Get()).Returns(new GenHub.Core.Models.Common.UserSettings());
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
var vm = new UpdateNotificationViewModel(
Mock.Of(),
@@ -72,4 +79,493 @@ public void IsCheckButtonEnabled_ReflectsCheckingState()
Assert.True(vm.IsCheckButtonEnabled);
}
+
+ ///
+ /// Verifies that pull request display title formats properly with PR number and title.
+ ///
+ [Fact]
+ public void PullRequestInfo_DisplayTitle_ShouldIncludePrNumberAndTitle()
+ {
+ var prInfo = new PullRequestInfo
+ {
+ Number = 265,
+ Title = "feat: UI Downloads",
+ BranchName = "feat/ui-downloads",
+ Author = "developer",
+ State = "open",
+ UpdatedAt = DateTimeOffset.UtcNow,
+ };
+
+ Assert.Equal("#265 - feat: UI Downloads", prInfo.DisplayTitle);
+ }
+
+ ///
+ /// Verifies that subscribing to a PR loads artifacts and auto-selects the latest version.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SubscribeToPr_LoadsArtifactsAndAutoSelectsLatestVersionAsync()
+ {
+ var mockVelopack = new Mock();
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
+
+ var artifacts = new List
+ {
+ new("0.0.1316-pr389", "e1212a5", 389, 1001, "https://github.com/test/run/1", 501, "genhub-velopack-linux-0.0.1316-pr389", DateTime.UtcNow, "https://github.com/test/art/1", 1024),
+ new("0.0.1315-pr389", "a1b2c3d", 389, 1000, "https://github.com/test/run/0", 500, "genhub-velopack-linux-0.0.1315-pr389", DateTime.UtcNow.AddMinutes(-10), "https://github.com/test/art/0", 1024),
+ };
+
+ var loadTcs = new TaskCompletionSource>();
+ mockVelopack.Setup(x => x.GetArtifactsForPullRequestAsync(389, It.IsAny()))
+ .Returns(async (int _, CancellationToken ct) =>
+ {
+ ct.Register(() => loadTcs.TrySetCanceled(ct));
+ return await loadTcs.Task;
+ });
+
+ var vm = new UpdateNotificationViewModel(
+ mockVelopack.Object,
+ Mock.Of>(),
+ mockUserSettings.Object);
+
+ vm.SubscribeToPrCommand.Execute(389);
+
+ Assert.True(vm.IsLoadingVersions);
+ loadTcs.SetResult(artifacts);
+
+ // wait briefly for async continuation
+ var timeout = DateTime.UtcNow.AddSeconds(2);
+ while (vm.IsLoadingVersions && DateTime.UtcNow < timeout)
+ {
+ await Task.Delay(10);
+ }
+
+ Assert.False(vm.IsLoadingVersions);
+ Assert.Equal(2, vm.AvailableVersions.Count);
+ Assert.NotNull(vm.SelectedVersion);
+ Assert.Equal("0.0.1316-pr389", vm.SelectedVersion.Version);
+ Assert.Equal("e1212a5", vm.SelectedVersion.GitHash);
+ Assert.True(vm.CanDownloadUpdate);
+ }
+
+ ///
+ /// Verifies that subscribing to a branch loads artifacts and auto-selects the latest version.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SubscribeToBranch_LoadsArtifactsAndAutoSelectsLatestVersionAsync()
+ {
+ var mockVelopack = new Mock();
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
+
+ var artifacts = new List
+ {
+ new("0.0.1320-development", "f4e3d2c", null, 2001, "https://github.com/test/run/2", 601, "genhub-velopack-linux-0.0.1320-development", DateTime.UtcNow, "https://github.com/test/art/2", 2048),
+ };
+
+ mockVelopack.Setup(x => x.GetArtifactsForBranchAsync("development", It.IsAny()))
+ .ReturnsAsync(artifacts);
+
+ var vm = new UpdateNotificationViewModel(
+ mockVelopack.Object,
+ Mock.Of>(),
+ mockUserSettings.Object);
+
+ vm.SubscribeToBranchCommand.Execute("development");
+
+ var timeout = DateTime.UtcNow.AddSeconds(2);
+ while (vm.IsLoadingVersions && DateTime.UtcNow < timeout)
+ {
+ await Task.Delay(10);
+ }
+
+ Assert.False(vm.IsLoadingVersions);
+ Assert.Single(vm.AvailableVersions);
+ Assert.NotNull(vm.SelectedVersion);
+ Assert.Equal("0.0.1320-development", vm.SelectedVersion.Version);
+ }
+
+ ///
+ /// Verifies that when switching PR subscriptions while a previous load is in flight, the old request is cancelled and only the new subscription artifacts are applied.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SubscribeToPr_WhenSwitchedImmediately_CancelsPreviousLoadAndLoadsNewSubscriptionAsync()
+ {
+ var mockVelopack = new Mock();
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
+
+ var pr391Tcs = new TaskCompletionSource>();
+ var pr389Tcs = new TaskCompletionSource>();
+
+ mockVelopack.Setup(x => x.GetArtifactsForPullRequestAsync(391, It.IsAny()))
+ .Returns(async (int _, CancellationToken ct) =>
+ {
+ ct.Register(() => pr391Tcs.TrySetCanceled(ct));
+ return await pr391Tcs.Task;
+ });
+
+ mockVelopack.Setup(x => x.GetArtifactsForPullRequestAsync(389, It.IsAny()))
+ .Returns(async (int _, CancellationToken ct) =>
+ {
+ ct.Register(() => pr389Tcs.TrySetCanceled(ct));
+ return await pr389Tcs.Task;
+ });
+
+ var vm = new UpdateNotificationViewModel(
+ mockVelopack.Object,
+ Mock.Of>(),
+ mockUserSettings.Object);
+
+ // subscribe to 391 first
+ vm.SubscribeToPrCommand.Execute(391);
+ Assert.True(vm.IsLoadingVersions);
+
+ // immediately switch to 389 while 391 is loading
+ vm.SubscribeToPrCommand.Execute(389);
+
+ // resolve 389 artifacts
+ var pr389Artifacts = new List
+ {
+ new("0.0.1316-pr389", "e1212a5", 389, 1001, "https://github.com/test/run/1", 501, "genhub-velopack-linux-0.0.1316-pr389", DateTime.UtcNow, "https://github.com/test/art/1", 1024),
+ };
+ pr389Tcs.TrySetResult(pr389Artifacts);
+
+ var timeout = DateTime.UtcNow.AddSeconds(2);
+ while (vm.IsLoadingVersions && DateTime.UtcNow < timeout)
+ {
+ await Task.Delay(10);
+ }
+
+ Assert.True(pr391Tcs.Task.IsCanceled);
+ Assert.False(vm.IsLoadingVersions);
+ Assert.Single(vm.AvailableVersions);
+ Assert.NotNull(vm.SelectedVersion);
+ Assert.Equal("0.0.1316-pr389", vm.SelectedVersion.Version);
+ Assert.Equal(389, vm.SelectedVersion.PullRequestNumber);
+ }
+
+ ///
+ /// Verifies that switching from a branch to another branch cancels the previous load and populates the new branch artifacts.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SubscribeToBranch_WhenSwitchedImmediately_CancelsPreviousLoadAndLoadsNewBranchAsync()
+ {
+ var mockVelopack = new Mock();
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
+
+ var branchOldTcs = new TaskCompletionSource>();
+ var branchNewTcs = new TaskCompletionSource>();
+
+ mockVelopack.Setup(x => x.GetArtifactsForBranchAsync("old-branch", It.IsAny()))
+ .Returns(async (string _, CancellationToken ct) =>
+ {
+ ct.Register(() => branchOldTcs.TrySetCanceled(ct));
+ return await branchOldTcs.Task;
+ });
+
+ mockVelopack.Setup(x => x.GetArtifactsForBranchAsync("new-branch", It.IsAny()))
+ .Returns(async (string _, CancellationToken ct) =>
+ {
+ ct.Register(() => branchNewTcs.TrySetCanceled(ct));
+ return await branchNewTcs.Task;
+ });
+
+ var vm = new UpdateNotificationViewModel(
+ mockVelopack.Object,
+ Mock.Of>(),
+ mockUserSettings.Object);
+
+ vm.SubscribeToBranchCommand.Execute("old-branch");
+ Assert.True(vm.IsLoadingVersions);
+
+ vm.SubscribeToBranchCommand.Execute("new-branch");
+
+ var newArtifacts = new List
+ {
+ new("0.0.1400-new-branch", "9998887", null, 3001, "https://github.com/test/run/3", 701, "genhub-velopack-linux-0.0.1400-new-branch", DateTime.UtcNow, "https://github.com/test/art/3", 2048),
+ };
+ branchNewTcs.TrySetResult(newArtifacts);
+
+ var timeout = DateTime.UtcNow.AddSeconds(2);
+ while (vm.IsLoadingVersions && DateTime.UtcNow < timeout)
+ {
+ await Task.Delay(10);
+ }
+
+ Assert.True(branchOldTcs.Task.IsCanceled);
+ Assert.False(vm.IsLoadingVersions);
+ Assert.Single(vm.AvailableVersions);
+ Assert.NotNull(vm.SelectedVersion);
+ Assert.Equal("0.0.1400-new-branch", vm.SelectedVersion.Version);
+ }
+
+ ///
+ /// Verifies that unsubscribing cancels in-flight loads and clears available versions and selection.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task Unsubscribe_CancelsInFlightLoadsAndClearsAvailableVersionsAsync()
+ {
+ var mockVelopack = new Mock();
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
+
+ var prTcs = new TaskCompletionSource>();
+ mockVelopack.Setup(x => x.GetArtifactsForPullRequestAsync(391, It.IsAny()))
+ .Returns(async (int _, CancellationToken ct) =>
+ {
+ ct.Register(() => prTcs.TrySetCanceled(ct));
+ return await prTcs.Task;
+ });
+
+ var vm = new UpdateNotificationViewModel(
+ mockVelopack.Object,
+ Mock.Of>(),
+ mockUserSettings.Object);
+
+ vm.SubscribeToPrCommand.Execute(391);
+ Assert.True(vm.IsLoadingVersions);
+
+ vm.UnsubscribeCommand.Execute(null);
+
+ var timeout = DateTime.UtcNow.AddSeconds(2);
+ while ((vm.IsLoadingVersions || vm.AvailableVersions.Count > 0) && DateTime.UtcNow < timeout)
+ {
+ await Task.Delay(10);
+ }
+
+ Assert.True(prTcs.Task.IsCanceled);
+ Assert.False(vm.IsLoadingVersions);
+ Assert.Empty(vm.AvailableVersions);
+ Assert.Null(vm.SelectedVersion);
+ }
+
+ ///
+ /// Verifies that OpenPullRequestUrlCommand executes without error for valid and invalid PR numbers.
+ ///
+ /// The PR number under test.
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void OpenPullRequestUrlCommand_ExecutesWithoutException(int prNumber)
+ {
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
+
+ var vm = new UpdateNotificationViewModel(
+ Mock.Of(),
+ Mock.Of>(),
+ mockUserSettings.Object);
+
+ // verify command execution does not throw
+ vm.OpenPullRequestUrlCommand.Execute(prNumber);
+ Assert.NotNull(vm);
+ }
+
+ ///
+ /// Verifies that changing the sort option reorders available pull requests accordingly.
+ ///
+ [Fact]
+ public void SelectedSortOption_ReordersAvailablePullRequests()
+ {
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
+
+ var vm = new UpdateNotificationViewModel(
+ Mock.Of(),
+ Mock.Of>(),
+ mockUserSettings.Object);
+
+ var now = DateTimeOffset.UtcNow;
+ var pr100 = new PullRequestInfo { Number = 100, Title = "PR 100", BranchName = "b1", Author = "a1", State = "open", UpdatedAt = now.AddDays(-2) };
+ var pr200 = new PullRequestInfo { Number = 200, Title = "PR 200", BranchName = "b2", Author = "a2", State = "open", UpdatedAt = now.AddDays(-10) };
+ var pr300 = new PullRequestInfo { Number = 300, Title = "PR 300", BranchName = "b3", Author = "a3", State = "open", UpdatedAt = now };
+
+ vm.AvailablePullRequests.Add(pr100);
+ vm.AvailablePullRequests.Add(pr200);
+ vm.AvailablePullRequests.Add(pr300);
+
+ // sort by PR number descending
+ vm.SelectedSortOption = GenHub.Core.Constants.AppUpdateConstants.SortOptionPrNumberDesc;
+ Assert.Equal(300, vm.AvailablePullRequests[0].Number);
+ Assert.Equal(200, vm.AvailablePullRequests[1].Number);
+ Assert.Equal(100, vm.AvailablePullRequests[2].Number);
+
+ // sort by PR number ascending
+ vm.SelectedSortOption = GenHub.Core.Constants.AppUpdateConstants.SortOptionPrNumberAsc;
+ Assert.Equal(100, vm.AvailablePullRequests[0].Number);
+ Assert.Equal(200, vm.AvailablePullRequests[1].Number);
+ Assert.Equal(300, vm.AvailablePullRequests[2].Number);
+
+ // sort by last updated (newest first)
+ vm.SelectedSortOption = GenHub.Core.Constants.AppUpdateConstants.SortOptionLastUpdated;
+ Assert.Equal(300, vm.AvailablePullRequests[0].Number);
+ Assert.Equal(100, vm.AvailablePullRequests[1].Number);
+ Assert.Equal(200, vm.AvailablePullRequests[2].Number);
+ }
+
+ ///
+ /// Verifies that tab commands correctly switch between Update and Browse Builds tabs.
+ ///
+ [Fact]
+ public void TabCommands_UpdatesSelectedTabIndexAndIsBrowseTabSelected()
+ {
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
+
+ var vm = new UpdateNotificationViewModel(
+ Mock.Of(),
+ Mock.Of>(),
+ mockUserSettings.Object);
+
+ Assert.Equal(0, vm.SelectedTabIndex);
+ Assert.False(vm.IsBrowseTabSelected);
+
+ vm.ShowBrowseBuildsTabCommand.Execute(null);
+ Assert.Equal(1, vm.SelectedTabIndex);
+ Assert.True(vm.IsBrowseTabSelected);
+
+ vm.ShowUpdateTabCommand.Execute(null);
+ Assert.Equal(0, vm.SelectedTabIndex);
+ Assert.False(vm.IsBrowseTabSelected);
+
+ vm.SelectTabCommand.Execute("1");
+ Assert.Equal(1, vm.SelectedTabIndex);
+ Assert.True(vm.IsBrowseTabSelected);
+
+ vm.SelectTabCommand.Execute(0);
+ Assert.Equal(0, vm.SelectedTabIndex);
+ Assert.False(vm.IsBrowseTabSelected);
+
+ // Clamping out-of-range inputs
+ vm.SelectTabCommand.Execute(-1);
+ Assert.Equal(0, vm.SelectedTabIndex);
+
+ vm.SelectTabCommand.Execute(5);
+ Assert.Equal(1, vm.SelectedTabIndex);
+
+ vm.SelectTabCommand.Execute("99");
+ Assert.Equal(1, vm.SelectedTabIndex);
+ }
+
+ ///
+ /// Verifies that DisplayCurrentVersion and InstalledVersionDisplay return a valid non-empty version string.
+ ///
+ [Fact]
+ public void DisplayCurrentVersion_ReturnsNonEmptyVersion()
+ {
+ var displayVersion = UpdateNotificationViewModel.DisplayCurrentVersion;
+ Assert.False(string.IsNullOrWhiteSpace(displayVersion));
+ Assert.StartsWith("v", displayVersion);
+
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
+
+ var vm = new UpdateNotificationViewModel(
+ Mock.Of(),
+ Mock.Of>(),
+ mockUserSettings.Object);
+
+ Assert.Equal(displayVersion, vm.InstalledVersionDisplay);
+ }
+
+ ///
+ /// Verifies that setting SelectedVersion to a newer artifact updates StatusMessage and sets IsUpdateAvailable to true.
+ ///
+ [Fact]
+ public void SelectedVersion_WhenNewer_UpdatesStatusMessageAndIsUpdateAvailable()
+ {
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
+
+ var vm = new UpdateNotificationViewModel(
+ Mock.Of(),
+ Mock.Of>(),
+ mockUserSettings.Object);
+
+ var newerArtifact = new ArtifactUpdateInfo("0.0.99999-pr389", "abcdef1", 389, 9999, "https://github.com/test/run/9999", 501, "genhub-linux", DateTime.UtcNow, "https://github.com/test/art/1", 1024);
+ vm.SelectedVersion = newerArtifact;
+
+ Assert.True(vm.IsUpdateAvailable);
+ Assert.Equal("0.0.99999-pr389", vm.LatestVersion);
+ Assert.Contains("0.0.99999-pr389", vm.StatusMessage);
+ }
+
+ ///
+ /// Verifies that selecting an artifact matching dismissed version clears IsUpdateAvailable, LatestVersion, and ReleaseNotesUrl.
+ ///
+ [Fact]
+ public void SelectedVersion_WhenDismissed_ClearsUpdateAvailableState()
+ {
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings { DismissedUpdateVersion = "0.0.99999-pr389" });
+
+ var vm = new UpdateNotificationViewModel(
+ Mock.Of(),
+ Mock.Of>(),
+ mockUserSettings.Object)
+ {
+ IsUpdateAvailable = true,
+ LatestVersion = "0.0.88888",
+ ReleaseNotesUrl = "https://example.com/notes",
+ };
+
+ var dismissedArtifact = new ArtifactUpdateInfo("0.0.99999-pr389", "abcdef1", 389, 9999, "https://github.com/test/run/9999", 501, "genhub-linux", DateTime.UtcNow, "https://github.com/test/art/1", 1024);
+ vm.SelectedVersion = dismissedArtifact;
+
+ Assert.False(vm.IsUpdateAvailable);
+ Assert.Empty(vm.LatestVersion);
+ Assert.Empty(vm.ReleaseNotesUrl);
+ Assert.Contains("dismissed", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Verifies that Unsubscribe resets subscription fields, clears update available state, and updates status message.
+ ///
+ [Fact]
+ public void Unsubscribe_ClearsArtifactUpdateStateAndSwitchesToMain()
+ {
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings { SubscribedPrNumber = 389 });
+
+ var mockVelopack = new Mock();
+ mockVelopack.SetupProperty(x => x.SubscribedPrNumber, 389);
+ mockVelopack.SetupProperty(x => x.SubscribedBranch, null);
+
+ var vm = new UpdateNotificationViewModel(
+ mockVelopack.Object,
+ Mock.Of>(),
+ mockUserSettings.Object)
+ {
+ SubscribedPr = new PullRequestInfo
+ {
+ Number = 389,
+ Title = "Test PR",
+ BranchName = "feature/test",
+ Author = "testuser",
+ State = "open",
+ },
+ SelectedVersion = new ArtifactUpdateInfo("0.0.99999-pr389", "abcdef1", 389, 9999, "https://github.com/test/run/9999", 501, "genhub-linux", DateTime.UtcNow, "https://github.com/test/art/1", 1024),
+ IsUpdateAvailable = true,
+ LatestVersion = "0.0.99999-pr389",
+ ReleaseNotesUrl = "https://example.com/notes",
+ };
+
+ vm.UnsubscribeCommand.Execute(null);
+
+ Assert.Null(vm.SubscribedPr);
+ Assert.Null(vm.SubscribedBranch);
+ Assert.Null(vm.SelectedVersion);
+ Assert.False(vm.IsUpdateAvailable);
+ Assert.Empty(vm.LatestVersion);
+ Assert.Empty(vm.ReleaseNotesUrl);
+ Assert.False(string.IsNullOrEmpty(vm.StatusMessage));
+ Assert.Null(mockVelopack.Object.SubscribedPrNumber);
+ }
}
\ No newline at end of file
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs
index 5422334ce..33d4f7d2a 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs
@@ -475,4 +475,62 @@ public async Task AcquireContentAsync_WhenInstallationDetectionCancels_Propagate
await Assert.ThrowsAnyAsync(
() => orchestrator.AcquireContentAsync(searchResult, progress: null, cts.Token));
}
+
+ ///
+ /// Verifies that SearchAsync deduplicates results by manifest ID, preferring specialized providers.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SearchAsync_DeduplicatesResultsById_PrefersSpecializedProviderOverGitHubAsync()
+ {
+ // Arrange
+ var specializedProviderMock = new Mock();
+ var githubProviderMock = new Mock();
+
+ const string duplicateId = "1.0.thesuperhackers.patch.generalsgamepatch2";
+
+ var specializedResult = new ContentSearchResult
+ {
+ Id = duplicateId,
+ Name = "TheSuperHackers Patch 2",
+ ProviderName = "thesuperhackers",
+ };
+
+ var githubResult = new ContentSearchResult
+ {
+ Id = duplicateId,
+ Name = "GeneralsGamePatch2",
+ ProviderName = "GitHub",
+ };
+
+ specializedProviderMock.Setup(p => p.IsEnabled).Returns(true);
+ specializedProviderMock.Setup(p => p.SearchAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(OperationResult>.CreateSuccess([specializedResult]));
+
+ githubProviderMock.Setup(p => p.IsEnabled).Returns(true);
+ githubProviderMock.Setup(p => p.SearchAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(OperationResult>.CreateSuccess([githubResult]));
+
+ var orchestrator = new ContentOrchestrator(
+ _loggerMock.Object,
+ [githubProviderMock.Object, specializedProviderMock.Object],
+ [],
+ [],
+ _cacheMock.Object,
+ _contentValidatorMock.Object,
+ _manifestPoolMock.Object,
+ _installationServiceMock.Object,
+ _installationCasPoolServiceMock.Object);
+
+ // Act
+ var result = await orchestrator.SearchAsync(new ContentSearchQuery());
+
+ // Assert
+ Assert.True(result.Success);
+ var items = result.Data?.ToList();
+ Assert.NotNull(items);
+ Assert.Single(items);
+ Assert.Equal("thesuperhackers", items[0].ProviderName);
+ Assert.Equal("TheSuperHackers Patch 2", items[0].Name);
+ }
}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ModDB/ModDBCategoryMapperTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ModDB/ModDBCategoryMapperTests.cs
new file mode 100644
index 000000000..73e18587f
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ModDB/ModDBCategoryMapperTests.cs
@@ -0,0 +1,63 @@
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.ModDB;
+using Xunit;
+using ContentType = GenHub.Core.Models.Enums.ContentType;
+
+namespace GenHub.Tests.Core.Features.Content.ModDB;
+
+///
+/// Unit tests for .
+///
+public class ModDBCategoryMapperTests
+{
+ ///
+ /// Verifies that MapCategory maps ModDB category codes correctly, especially mapping patches and scripts to Mod.
+ ///
+ /// The category code to map.
+ /// The expected content type.
+ [Theory]
+ [InlineData("2", ContentType.Mod)]
+ [InlineData("3", ContentType.Mod)]
+ [InlineData("4", ContentType.Mod)]
+ [InlineData("28", ContentType.Mod)]
+ [InlineData("29", ContentType.Addon)]
+ [InlineData("7", ContentType.Video)]
+ [InlineData("8", ContentType.Video)]
+ [InlineData("101", ContentType.Map)]
+ [InlineData("102", ContentType.Map)]
+ [InlineData("112", ContentType.Addon)]
+ [InlineData("125", ContentType.Addon)]
+ [InlineData("126", ContentType.Addon)]
+ [InlineData("20", ContentType.ModdingTool)]
+ [InlineData("30", ContentType.LanguagePack)]
+ public void MapCategory_MapsCategoryCodesCorrectly(string categoryCode, ContentType expected)
+ {
+ var result = ModDBCategoryMapper.MapCategory(categoryCode);
+ Assert.Equal(expected, result);
+ }
+
+ ///
+ /// Verifies that MapCategoryByName maps category names correctly, mapping patch and script names to Mod.
+ ///
+ /// The category name to map.
+ /// The expected content type.
+ [Theory]
+ [InlineData("Full Version", ContentType.Mod)]
+ [InlineData("Demo", ContentType.Mod)]
+ [InlineData("Patch", ContentType.Mod)]
+ [InlineData("v1.01 Patch", ContentType.Mod)]
+ [InlineData("Script", ContentType.Mod)]
+ [InlineData("Multiplayer Map", ContentType.Map)]
+ [InlineData("Singleplayer Map", ContentType.Map)]
+ [InlineData("Player Skin", ContentType.Addon)]
+ [InlineData("GUI", ContentType.Addon)]
+ [InlineData("HUD", ContentType.Addon)]
+ [InlineData("Mapping Tool", ContentType.ModdingTool)]
+ [InlineData("Language Pack", ContentType.LanguagePack)]
+ [InlineData("Trailer", ContentType.Video)]
+ public void MapCategoryByName_MapsNamesCorrectly(string categoryName, ContentType expected)
+ {
+ var result = ModDBCategoryMapper.MapCategoryByName(categoryName);
+ Assert.Equal(expected, result);
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Parsers/ModDBPageParserTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Parsers/ModDBPageParserTests.cs
new file mode 100644
index 000000000..a630b8b5c
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Parsers/ModDBPageParserTests.cs
@@ -0,0 +1,1691 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using AngleSharp;
+using AngleSharp.Dom;
+using GenHub.Core.Constants;
+using GenHub.Core.Interfaces.Tools;
+using GenHub.Core.Models.Parsers;
+using GenHub.Features.Content.Services.Parsers;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace GenHub.Tests.Core.Features.Content.Parsers;
+
+///
+/// Regression tests for the current ModDB detail markup and Cloudflare-aware section loading.
+///
+public sealed class ModDBPageParserTests
+{
+ ///
+ /// Verifies the current game-addon detail page maps its metadata and /addons/start route into
+ /// a usable file rather than returning an empty download URL.
+ ///
+ /// A task that represents the asynchronous test.
+ [Fact]
+ public async Task ParseAsync_CurrentAddonDetailMarkup_ExtractsArchiveNameAndAddonStartUrlAsync()
+ {
+ // Arrange
+ var playwright = CreatePlaywrightMock();
+ var pageUrl = "https://www.moddb.com/games/cc-generals-zero-hour/addons/lemuria-2026-fixes";
+ var doc = await CreateDocumentAsync("""
+
+
+
Filename
Lemuria_2026_Fixes.rar
+
+
+
Added
+
Size
1.07mb (1,125,450 bytes)
+
+
+
+ """);
+ playwright
+ .Setup(service => service.FetchAndParsePersistentManyAsync(
+ ModDBConstants.BrowserProfileName,
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc });
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ // Act
+ var parsed = await parser.ParseAsync(pageUrl);
+
+ // Assert
+ var file = Assert.Single(parsed.Sections.OfType());
+ Assert.Equal("Lemuria_2026_Fixes.rar", file.Name);
+ Assert.Equal("https://www.moddb.com/addons/start/302328", file.DownloadUrl);
+ Assert.Equal("Singleplayer Map", file.Category);
+ Assert.Equal(1_125_450, file.SizeBytes);
+ }
+
+ ///
+ /// Game-scoped FileDetail URLs (from the ModDB downloads listing) have no parent /mods/ page
+ /// to sweep, so the detail view must still populate Community from comments on the file page
+ /// itself instead of leaving only a single Releases row.
+ ///
+ /// A task that represents the asynchronous test.
+ [Fact]
+ public async Task ParseAsync_GameFileDetail_ExtractsOnPageCommentsWithoutParentSweepAsync()
+ {
+ // Arrange
+ const string pageUrl = "https://www.moddb.com/games/cc-generals-zero-hour/downloads/genbigeditbig-editor";
+ var playwright = CreatePlaywrightMock();
+ var doc = await CreateDocumentAsync("""
+
+
+
Filename
GenBigEdit.zip
+
Size
174.33mb (182,801,143 bytes)
+
+
+
+
+ """);
+ playwright
+ .Setup(service => service.FetchAndParsePersistentManyAsync(
+ ModDBConstants.BrowserProfileName,
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc });
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ // Act
+ var parsed = await parser.ParseAsync(pageUrl);
+
+ // Assert
+ Assert.Equal(PageType.FileDetail, parsed.PageType);
+ var file = Assert.Single(parsed.Sections.OfType());
+ Assert.Equal("GenBigEdit.zip", file.Name);
+ Assert.Equal("https://www.moddb.com/downloads/start/310120", file.DownloadUrl);
+
+ var comment = Assert.Single(parsed.Sections.OfType());
+ Assert.Equal("mah_boi", comment.Author);
+ Assert.Equal("Please, provide us the source code of this program.", comment.Content);
+
+ // Must not attempt a parent-mod section sweep for /games/... FileDetail URLs (fetches only the single URL).
+ playwright.Verify(
+ service => service.FetchAndParsePersistentManyAsync(
+ ModDBConstants.BrowserProfileName,
+ It.Is>(urls => urls.Count == 1 && urls[0] == pageUrl),
+ It.IsAny()),
+ Times.Once);
+ }
+
+ ///
+ /// Verifies an addons-list row retains its ModDB category so a map does not become a generic
+ /// add-on later in the resolver and manifest pipeline.
+ ///
+ /// A task that represents the asynchronous test.
+ [Fact]
+ public async Task ParseAsync_AddonsListRow_ExtractsSingleplayerMapCategoryAsync()
+ {
+ // Arrange
+ const string pageUrl = "https://www.moddb.com/games/cc-generals-zero-hour/addons";
+ var playwright = CreatePlaywrightMock();
+ var doc = await CreateDocumentAsync("""
+
+
+
Lemuria 2026
+
Singleplayer Map
+
1.07 MB
+
Download
+
+
+ """);
+ playwright
+ .Setup(service => service.FetchAndParsePersistentManyAsync(
+ ModDBConstants.BrowserProfileName,
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc });
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ // Act
+ var parsed = await parser.ParseAsync(pageUrl);
+
+ // Assert
+ var file = Assert.Single(parsed.Sections.OfType());
+ Assert.Equal("Singleplayer Map", file.Category);
+ Assert.Equal(FileSectionType.Addons, file.FileSectionType);
+ Assert.Equal("https://www.moddb.com/addons/start/302328", file.DownloadUrl);
+ }
+
+ ///
+ /// Verifies that rich ModDB sections use the verified persistent Chromium profile instead of
+ /// a separate headless browser that loses Cloudflare clearance.
+ ///
+ /// A task that represents the asynchronous test.
+ [Fact]
+ public async Task ParseAsync_ModDetail_UsesPersistentProfileForDownloadsAndAddonsAsync()
+ {
+ // Arrange
+ const string pageUrl = "https://www.moddb.com/mods/example-mod";
+ var documents = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ [pageUrl] = await CreateDocumentAsync("Example Mod
"),
+ [pageUrl + "/downloads"] = await CreateDocumentAsync("""
+
+ """),
+ [pageUrl + "/addons"] = await CreateDocumentAsync("""
+
+ """),
+ [pageUrl + "/videos"] = await CreateDocumentAsync(""),
+ [pageUrl + "/images"] = await CreateDocumentAsync(""),
+ [pageUrl + "/reviews"] = await CreateDocumentAsync(""),
+ [pageUrl + "/articles"] = await CreateDocumentAsync(""),
+ };
+ var playwright = CreatePlaywrightMock();
+ playwright
+ .Setup(service => service.FetchAndParsePersistentManyAsync(
+ ModDBConstants.BrowserProfileName,
+ It.IsAny>(),
+ It.IsAny()))
+ .Returns((string _, IReadOnlyList urls, CancellationToken _) =>
+ {
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var url in urls)
+ {
+ if (documents.TryGetValue(url, out var d))
+ {
+ result[url] = d;
+ }
+ }
+
+ return Task.FromResult>(result);
+ });
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ // Act
+ var parsed = await parser.ParseAsync(pageUrl);
+
+ // Assert
+ var files = parsed.Sections.OfType().ToList();
+ Assert.Contains(files, file => file.Name == "Example Release" && file.DownloadUrl == "https://www.moddb.com/downloads/start/100");
+ Assert.Contains(files, file => file.Name == "Example Addon" && file.DownloadUrl == "https://www.moddb.com/addons/start/200");
+ playwright.Verify(
+ service => service.FetchAndParsePersistentManyAsync(
+ ModDBConstants.BrowserProfileName,
+ It.Is>(urls =>
+ urls.Contains(pageUrl) && urls.Contains(pageUrl + "/downloads") && urls.Contains(pageUrl + "/addons")),
+ It.IsAny()),
+ Times.Once);
+ playwright.Verify(service => service.FetchAndParseAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ ///
+ /// Verifies the file-only acquisition path resolves a FileDetail download without fetching the
+ /// parent mod's downloads/addons/videos/images/reviews/articles sections (the seven-page sweep
+ /// that previously fired on every card download).
+ ///
+ /// A task that represents the asynchronous test.
+ [Fact]
+ public async Task ParseFileDetailAsync_FetchesOnlyFileDetailPageAndSkipsSectionSweepAsync()
+ {
+ // Arrange: the FileDetail page already carries a real (non-guest) icon, so the parent-mod
+ // icon fallback fetch is skipped too — exactly one fetch total.
+ const string pageUrl = "https://www.moddb.com/mods/genspeed/downloads/genspeed-v25";
+ var playwright = CreatePlaywrightMock();
+ playwright
+ .Setup(service => service.FetchAndParsePersistentAsync(
+ ModDBConstants.BrowserProfileName,
+ pageUrl,
+ It.IsAny()))
+ .ReturnsAsync(await CreateDocumentAsync("""
+
+
+
+
+
Filename
GenSpeed-v2.5.zip
+
Size
65.04mb (68,197,650 bytes)
+
+
+
+ """));
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ // Act
+ var parsed = await parser.ParseFileDetailAsync(pageUrl);
+
+ // Assert: exactly one DownloadableFile, no section sweep, icon from the FileDetail page.
+ var file = Assert.Single(parsed.Sections.OfType());
+ Assert.Equal("GenSpeed v2.5", file.Name);
+ Assert.Equal("GenSpeed-v2.5.zip", file.Filename);
+ Assert.Equal("https://www.moddb.com/downloads/start/311183", file.DownloadUrl);
+ Assert.Equal(68_197_650, file.SizeBytes);
+ Assert.Equal("https://static.moddb.com/mods/genspeed/icon.png", parsed.Context.IconUrl);
+
+ playwright.Verify(
+ service => service.FetchAndParsePersistentAsync(
+ ModDBConstants.BrowserProfileName,
+ It.Is(url => url != pageUrl),
+ It.IsAny()),
+ Times.Never);
+ }
+
+ ///
+ /// Verifies that ParseFileDetailAsync performs only a single page fetch for file details without
+ /// secondary parent mod fetches or section sweeps.
+ ///
+ /// A task that represents the asynchronous test.
+ [Fact]
+ public async Task ParseFileDetailAsync_WithGuestIcon_FetchesOnlyFileDetailPageAsync()
+ {
+ // Arrange
+ const string pageUrl = "https://www.moddb.com/mods/genspeed/downloads/genspeed-v25";
+ var fetchedUrls = new List();
+ var playwright = CreatePlaywrightMock();
+ playwright
+ .Setup(service => service.FetchAndParsePersistentAsync(
+ ModDBConstants.BrowserProfileName,
+ It.IsAny(),
+ It.IsAny()))
+ .Returns((string _, string url, CancellationToken _) =>
+ {
+ fetchedUrls.Add(url);
+ return Task.FromResult(CreateDocumentAsync("""
+
+
+
+
+
Filename
GenSpeed-v2.5.zip
+
+
+
+ """).GetAwaiter().GetResult());
+ });
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ // Act
+ var parsed = await parser.ParseFileDetailAsync(pageUrl);
+
+ // Assert: exactly one fetch (FileDetail), never parent mod or section pages.
+ Assert.Equal(new[] { pageUrl }, fetchedUrls);
+ Assert.Contains(parsed.Sections.OfType(), f => f.Filename == "GenSpeed-v2.5.zip");
+ }
+
+ ///
+ /// Verifies that comment parsing creates nested reply threads with correct author attribution
+ /// and cleans out ModDB action text like 'Reply Good karma Bad karma+1 vote'.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task ParseAsync_NestedComments_ParsesThreadHierarchyAndCleansActionTextAsync()
+ {
+ // Arrange
+ const string pageUrl = "https://www.moddb.com/mods/example-mod/comments";
+ var playwright = CreatePlaywrightMock();
+ var doc = await CreateDocumentAsync("""
+
+
+
+ """);
+ playwright
+ .Setup(service => service.FetchAndParsePersistentManyAsync(
+ ModDBConstants.BrowserProfileName,
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc });
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ // Act
+ var parsed = await parser.ParseAsync(pageUrl);
+
+ // Assert
+ var topLevelComments = parsed.Sections.OfType().ToList();
+ var parentComment = Assert.Single(topLevelComments);
+ Assert.Equal("Scorpionwins", parentComment.Author);
+ Assert.Equal("How to activate additional weapons?", parentComment.Content);
+ Assert.Equal(0, parentComment.IndentLevel);
+
+ var reply = Assert.Single(parentComment.Replies!);
+ Assert.Equal("BagaturKhan", reply.Author);
+ Assert.Equal("If you are talking about stolen tech, train your infiltrator.", reply.Content);
+ Assert.Equal(1, reply.IndentLevel);
+ }
+
+ ///
+ /// Verifies reply markup nested inside .commentbody does not inflate the parent content
+ /// into a huge whitespace block (the layout bug seen in the Community tab).
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task ParseAsync_NestedCommentsInsideCommentBody_DoesNotPolluteParentContentAsync()
+ {
+ // Arrange
+ const string pageUrl = "https://www.moddb.com/mods/example-mod/comments";
+ var playwright = CreatePlaywrightMock();
+ var doc = await CreateDocumentAsync("""
+
+
+
+ """);
+ playwright
+ .Setup(service => service.FetchAndParsePersistentManyAsync(
+ ModDBConstants.BrowserProfileName,
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc });
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ // Act
+ var parsed = await parser.ParseAsync(pageUrl);
+
+ // Assert
+ var parentComment = Assert.Single(parsed.Sections.OfType());
+ Assert.Equal("How to activate additional weapons?", parentComment.Content);
+ Assert.DoesNotContain("BagaturKhan", parentComment.Content);
+ Assert.DoesNotContain("infiltrator", parentComment.Content, StringComparison.OrdinalIgnoreCase);
+
+ var reply = Assert.Single(parentComment.Replies!);
+ Assert.Equal("BagaturKhan", reply.Author);
+ Assert.Equal("Train your infiltrator.", reply.Content);
+ }
+
+ ///
+ /// Verifies rating widgets without author/body are not surfaced as empty Community review cards.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task ParseAsync_BareRatingWidget_IsNotTreatedAsReviewAsync()
+ {
+ // Arrange
+ const string pageUrl = "https://www.moddb.com/mods/example-mod/reviews";
+ var playwright = CreatePlaywrightMock();
+ var doc = await CreateDocumentAsync("""
+
+ 9.0people found this helpful
+
+
Alice
+
Solid patch for ROTR.
+
8.5
+
+
+ """);
+ playwright
+ .Setup(service => service.FetchAndParsePersistentManyAsync(
+ ModDBConstants.BrowserProfileName,
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc });
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ // Act
+ var parsed = await parser.ParseAsync(pageUrl);
+
+ // Assert
+ var review = Assert.Single(parsed.Sections.OfType());
+ Assert.Equal("Alice", review.Author);
+ Assert.Equal("Solid patch for ROTR.", review.Content);
+ }
+
+ ///
+ /// The live ModDB composer (#commentform plus guest/email rows and injected CSS) must
+ /// not appear as Community comments.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task ParseAsync_CommentComposer_IsNotTreatedAsCommentsAsync()
+ {
+ const string pageUrl = "https://www.moddb.com/mods/cc-generals-undone/downloads/cc-generals-undone";
+ var playwright = CreatePlaywrightMock();
+ var doc = await CreateDocumentAsync("""
+
+ C&C Generals Undone file
+ C&C Generals Undone
+
+
Filename
GeneralsUndone_v1.0.zip
+
+
+
+
+ """);
+ playwright
+ .Setup(service => service.FetchAndParsePersistentManyAsync(
+ ModDBConstants.BrowserProfileName,
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc });
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ var parsed = await parser.ParseAsync(pageUrl);
+
+ Assert.Empty(parsed.Sections.OfType());
+ }
+
+ ///
+ /// File-page chrome (game icon, developer avatar, download title art) must not appear in Media.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task ParseAsync_FileDetailChromeImages_AreNotGalleryMediaAsync()
+ {
+ const string pageUrl = "https://www.moddb.com/mods/cc-generals-undone/downloads/cc-generals-undone";
+ var playwright = CreatePlaywrightMock();
+ var doc = await CreateDocumentAsync("""
+
+ C&C Generals Undone
+
+
Filename
GeneralsUndone_v1.0.zip
+
+
+
+
+
+
+
+ """);
+ playwright
+ .Setup(service => service.FetchAndParsePersistentManyAsync(
+ ModDBConstants.BrowserProfileName,
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc });
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ var parsed = await parser.ParseAsync(pageUrl);
+
+ Assert.Empty(parsed.Sections.OfType());
+ }
+
+ ///
+ /// The images tab should yield unique gallery shots, not share icons or duplicate featured thumbs.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task ParseAsync_ImagesPage_ExtractsUniqueGalleryShotsAsync()
+ {
+ const string pageUrl = "https://www.moddb.com/mods/cc-generals-undone/images";
+ var playwright = CreatePlaywrightMock();
+ var doc = await CreateDocumentAsync("""
+
+
+
+ """);
+ playwright
+ .Setup(service => service.FetchAndParsePersistentManyAsync(
+ ModDBConstants.BrowserProfileName,
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc });
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ var parsed = await parser.ParseAsync(pageUrl);
+
+ var images = parsed.Sections.OfType().ToList();
+ Assert.Equal(2, images.Count);
+ Assert.Contains(images, image => image.Title.Contains("ICBM", StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(images, image => image.Title.Contains("Spectre", StringComparison.OrdinalIgnoreCase));
+ Assert.DoesNotContain(images, image => image.Title.Contains("Share", StringComparison.OrdinalIgnoreCase));
+ Assert.DoesNotContain(images, image => image.ThumbnailUrl?.StartsWith("data:", StringComparison.OrdinalIgnoreCase) == true);
+ Assert.All(images, image => Assert.DoesNotContain("crop_", image.ThumbnailUrl ?? string.Empty));
+ Assert.All(images, image => Assert.DoesNotContain("/cache/", image.ThumbnailUrl ?? string.Empty));
+ }
+
+ ///
+ /// Image titles with CamelCase or raw filenames should be formatted with clean spaces.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task ParseAsync_ImageTitles_FormatsCamelCaseAndFilenamesAsync()
+ {
+ const string pageUrl = "https://www.moddb.com/mods/test-mod/images";
+ var playwright = CreatePlaywrightMock();
+ var doc = await CreateDocumentAsync("""
+
+
+
+ """);
+ playwright
+ .Setup(service => service.FetchAndParsePersistentManyAsync(
+ ModDBConstants.BrowserProfileName,
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc });
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ var parsed = await parser.ParseAsync(pageUrl);
+
+ var images = parsed.Sections.OfType().ToList();
+ Assert.Equal(2, images.Count);
+ Assert.Equal("Life Of BRRRRTTT", images[0].Title);
+ Assert.Equal("BASSBASSBASSASS", images[1].Title);
+ Assert.Equal("https://media.moddb.com/images/mods/1/73/72174/LifeOfBRRRRTTT.png", images[0].ThumbnailUrl);
+ Assert.Equal("https://media.moddb.com/images/mods/1/73/72174/LifeOfBRRRRTTT.png", images[0].FullSizeUrl);
+ }
+
+ ///
+ /// FileDetail filename plus the parent downloads listing of the same start URL must collapse
+ /// to one release, keeping the human listing name.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task ParseAsync_FileDetailAndParentDownloads_DedupesSameBinaryAsync()
+ {
+ const string pageUrl = "https://www.moddb.com/mods/cc-generals-undone/downloads/cc-generals-undone";
+ const string parentUrl = "https://www.moddb.com/mods/cc-generals-undone";
+ var documents = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ [pageUrl] = await CreateDocumentAsync("""
+
+ C&C Generals Undone file
+ C&C Generals Undone
+ register
+
+ Games : C&C: Generals Zero Hour : Mods : C&C Generals Undone : Files
+ This is the first version of Undone, and I know it's still very much in development.
+
+
Filename
GeneralsUndone_v1.0.zip
+
+
+
+ """),
+ [parentUrl] = await CreateDocumentAsync("C&C Generals Undone
"),
+ [parentUrl + "/downloads"] = await CreateDocumentAsync("""
+
+
C&C Generals Undone
+
289.6 MB
+
Download
+
+
+
Generals Undone v1.01 Patch
+
1 MB
+
Download
+
+ """),
+ [parentUrl + "/addons"] = await CreateDocumentAsync(""),
+ [parentUrl + "/videos"] = await CreateDocumentAsync(""),
+ [parentUrl + "/images"] = await CreateDocumentAsync(""),
+ [parentUrl + "/reviews"] = await CreateDocumentAsync(""),
+ [parentUrl + "/articles"] = await CreateDocumentAsync(""),
+ };
+ var playwright = CreatePlaywrightMock();
+ playwright
+ .Setup(service => service.FetchAndParsePersistentManyAsync(
+ ModDBConstants.BrowserProfileName,
+ It.IsAny>(),
+ It.IsAny()))
+ .Returns((string _, IReadOnlyList urls, CancellationToken _) =>
+ {
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var url in urls)
+ {
+ if (documents.TryGetValue(url, out var doc))
+ {
+ result[url] = doc;
+ }
+ }
+
+ return Task.FromResult>(result);
+ });
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ var parsed = await parser.ParseAsync(pageUrl);
+
+ var files = parsed.Sections.OfType().ToList();
+ Assert.Equal(2, files.Count);
+ Assert.Contains(files, file => file.Name == "C&C Generals Undone" && file.DownloadUrl == "https://www.moddb.com/downloads/start/313719");
+ Assert.Contains(files, file => file.Name == "Generals Undone v1.01 Patch");
+ Assert.DoesNotContain(files, file => file.Name == "GeneralsUndone_v1.0.zip");
+ Assert.Equal("C&C Generals Undone", parsed.Context.Title);
+ Assert.Equal("WhiteSkull#9044", parsed.Context.Developer);
+ Assert.Contains("first version of Undone", parsed.Context.Description, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("Games :", parsed.Context.Description, StringComparison.Ordinal);
+ }
+
+ ///
+ /// Verifies that ParseFileDetailAsync correctly parses metadata when ModDB uses alternative label names
+ /// such as "File Name", "File Size", "Uploaded By", "MD5 Checksum", and "Total Downloads".
+ ///
+ /// A task that represents the asynchronous test.
+ [Fact]
+ public async Task ParseFileDetailAsync_WithAlternativeLabels_ParsesMd5ChecksumTotalDownloadsAndUploaderAsync()
+ {
+ const string pageUrl = "https://www.moddb.com/mods/cc-generals-undone/downloads/generals-undone-v101-patch";
+ var playwright = CreatePlaywrightMock();
+ playwright
+ .Setup(service => service.FetchAndParsePersistentAsync(
+ ModDBConstants.BrowserProfileName,
+ pageUrl,
+ It.IsAny()))
+ .ReturnsAsync(await CreateDocumentAsync("""
+
+
+
File Name
GeneralsUndone_v1.01.csf
+
Category
Patch
+
Uploaded By
WhiteSkull#9044
+
File Size
289.6mb (303,663,235 bytes)
+
MD5 Checksum
6e5b1fd58fc7a58cf21af86933116942
+
Total Downloads
185
+
+
+
+ """));
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ var parsed = await parser.ParseFileDetailAsync(pageUrl);
+
+ var file = Assert.Single(parsed.Sections.OfType());
+ Assert.Equal("GeneralsUndone_v1.01.csf", file.Filename);
+ Assert.Equal("Patch", file.Category);
+ Assert.Equal("WhiteSkull#9044", file.Uploader);
+ Assert.Equal(303_663_235, file.SizeBytes);
+ Assert.Equal("6e5b1fd58fc7a58cf21af86933116942", file.Md5Hash);
+ Assert.Equal(185, file.DownloadCount);
+ Assert.Equal("https://www.moddb.com/downloads/start/313720", file.DownloadUrl);
+ }
+
+ ///
+ /// Verifies that ModDB download listing rows with subheading metadata (size in subheading, button class)
+ /// extract size, category, uploader, details URL, and download URL correctly.
+ ///
+ /// A task that represents the asynchronous test.
+ [Fact]
+ public async Task ParseAsync_ModernModDBDownloadsListing_ExtractsSubheadingSizeAndLinksAsync()
+ {
+ const string pageUrl = "https://www.moddb.com/mods/cc-generals-undone/downloads/cc-generals-undone";
+ const string parentUrl = "https://www.moddb.com/mods/cc-generals-undone";
+ var documents = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ [pageUrl] = await CreateDocumentAsync("""
+
+ C&C Generals Undone file
+ C&C Generals Undone
+
+ First release of Undone.
+
+
Filename
GeneralsUndone_v1.0.zip
+
Category
Full Version
+
Size
289.6mb (303,663,235 bytes)
+
MD5 Hash
6e5b3fcf30fc7a58ef21af869551bb942
+
+
+
+ """),
+ [parentUrl] = await CreateDocumentAsync("C&C Generals Undone
"),
+ [parentUrl + "/downloads"] = await CreateDocumentAsync("""
+
+
+
+
- Full Version, 289.6mb
+
+
+
+
+
+
+
- Patch, 1 MB
+
+
+
+ """),
+ [parentUrl + "/addons"] = await CreateDocumentAsync(""),
+ [parentUrl + "/videos"] = await CreateDocumentAsync(""),
+ [parentUrl + "/images"] = await CreateDocumentAsync(""),
+ [parentUrl + "/reviews"] = await CreateDocumentAsync(""),
+ [parentUrl + "/articles"] = await CreateDocumentAsync(""),
+ };
+ var playwright = CreatePlaywrightMock();
+ playwright
+ .Setup(service => service.FetchAndParsePersistentManyAsync(
+ ModDBConstants.BrowserProfileName,
+ It.IsAny>(),
+ It.IsAny()))
+ .Returns((string _, IReadOnlyList urls, CancellationToken _) =>
+ {
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var url in urls)
+ {
+ if (documents.TryGetValue(url, out var doc))
+ {
+ result[url] = doc;
+ }
+ }
+
+ return Task.FromResult>(result);
+ });
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ var parsed = await parser.ParseAsync(pageUrl);
+
+ var files = parsed.Sections.OfType().ToList();
+ Assert.Equal(2, files.Count);
+
+ var mainRelease = Assert.Single(files, f => f.Name == "C&C Generals Undone");
+ Assert.Equal("GeneralsUndone_v1.0.zip", mainRelease.Filename);
+ Assert.Equal("https://www.moddb.com/downloads/start/313719", mainRelease.DownloadUrl);
+ Assert.Equal("https://www.moddb.com/mods/cc-generals-undone/downloads/cc-generals-undone", mainRelease.DetailsUrl);
+ Assert.Equal("Full Version", mainRelease.Category);
+ Assert.Equal(303_663_235, mainRelease.SizeBytes);
+ Assert.Equal("6e5b3fcf30fc7a58ef21af869551bb942", mainRelease.Md5Hash);
+
+ var patchRelease = Assert.Single(files, f => f.Name == "Generals Undone v1.01 Patch");
+ Assert.Equal("https://www.moddb.com/mods/cc-generals-undone/downloads/generals-undone-v101-patch", patchRelease.DetailsUrl);
+ Assert.Equal("Patch", patchRelease.Category);
+ Assert.Equal(1048576, patchRelease.SizeBytes);
+ Assert.Equal("1 MB", patchRelease.SizeDisplay);
+ }
+
+ ///
+ /// Verifies that embedded YouTube iframes on mod pages have their title, thumbnail, platform,
+ /// and normalized embed URL properly extracted.
+ ///
+ /// A task that represents the asynchronous test.
+ [Fact]
+ public async Task ParseAsync_WithYouTubeIframe_ExtractsTitlePlatformThumbnailAndEmbedUrlAsync()
+ {
+ // Arrange
+ const string pageUrl = "https://www.moddb.com/mods/korean-war-2";
+ var doc = await CreateDocumentAsync("""
+
+
+
+
+
+
+
Gameplay Teaser
+
+
+
+ """);
+ var playwright = CreatePlaywrightMock();
+ playwright
+ .Setup(service => service.FetchAndParsePersistentManyAsync(
+ ModDBConstants.BrowserProfileName,
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc });
+
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ // Act
+ var parsed = await parser.ParseAsync(pageUrl);
+
+ // Assert
+ var videos = parsed.Sections.OfType