using GenshinAssistant.InputHelper; using System.Drawing; using System.Drawing.Imaging; using System.Text.Json; namespace GenshinAssistant.InputHelper.Tests; internal static class Program { private static int failures; private sealed record ArtifactStarReportEntry( int? Sequence, string ImagePath, int? StarCount, double Confidence, string? Source); private static int Main(string[] args) { if (TryRunArtifactStarReport(args, out var reportExitCode)) return reportExitCode; VerifyReadyOnlyForExpectedForeground(); VerifyEveryStopSignalWins(); VerifyFocusLossBlocksWithoutRefocus(); VerifyKeyDispatchRequiresConfirmedFocus(); VerifyTopResetIsBoundedAndCoversTheSupportedRange(); VerifyWheelPacingKeepsEveryEventGuardableWithoutPerEventSleep(); VerifyOnlyTheRealGameProcessCanBecomeTheTarget(); VerifyArtifactStarDetectorCountsThreeFourAndFiveStars(); VerifyArtifactStarDetectorFailsClosedForAmbiguousNoise(); VerifyArtifactStarDetectorRealCropsFromEnvironment(); if (failures == 0) { Console.WriteLine("InputHelper safety policy and ArtifactStarDetector: all tests passed."); return 0; } Console.Error.WriteLine($"InputHelper safety policy: {failures} test(s) failed."); return 1; } /// /// Read-only batch report for already captured native Artifact cards. This /// gives the review-corpus workflow an auditable rarity signal without /// focusing Genshin or changing any saved scan result. /// private static bool TryRunArtifactStarReport(string[] args, out int exitCode) { exitCode = 0; if (args.Length == 0 || !string.Equals(args[0], "--report-artifact-stars", StringComparison.Ordinal)) return false; var directory = args.Skip(1) .FirstOrDefault(argument => argument.StartsWith("--directory=", StringComparison.OrdinalIgnoreCase))? .Split('=', 2)[1] .Trim(); var output = args.Skip(1) .FirstOrDefault(argument => argument.StartsWith("--output=", StringComparison.OrdinalIgnoreCase))? .Split('=', 2)[1] .Trim(); if (string.IsNullOrWhiteSpace(directory) || !Directory.Exists(directory)) { Console.Error.WriteLine("--report-artifact-stars requires --directory=."); exitCode = 2; return true; } try { var entries = Directory .EnumerateFiles(directory, "artifact-*.png", SearchOption.TopDirectoryOnly) .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) .Select(ReportArtifactStar) .ToArray(); var report = new { version = "native-artifact-star-report-v1", createdAt = DateTimeOffset.UtcNow.ToString("O"), runDir = Path.GetFullPath(directory), total = entries.Length, counts = entries .GroupBy(entry => entry.StarCount?.ToString() ?? "unknown") .OrderBy(group => group.Key, StringComparer.Ordinal) .ToDictionary(group => group.Key, group => group.Count()), entries, }; var json = JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }); if (string.IsNullOrWhiteSpace(output)) { Console.WriteLine(json); } else { var fullOutputPath = Path.GetFullPath(output); Directory.CreateDirectory(Path.GetDirectoryName(fullOutputPath)!); File.WriteAllText(fullOutputPath, json + Environment.NewLine); Console.WriteLine($"Artifact star report: {fullOutputPath}"); } } catch (Exception exception) { Console.Error.WriteLine($"Artifact star report failed: {exception.Message}"); exitCode = 1; } return true; } private static ArtifactStarReportEntry ReportArtifactStar(string path) { using var bitmap = new Bitmap(path); var detection = ArtifactStarDetector.Detect(bitmap); return new ArtifactStarReportEntry( SequenceFromArtifactPath(path), Path.GetFullPath(path), detection.StarCount, detection.Confidence, detection.Source); } private static int? SequenceFromArtifactPath(string path) { var fileName = Path.GetFileNameWithoutExtension(path); var prefix = "artifact-"; return fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && int.TryParse(fileName[prefix.Length..], out var sequence) ? sequence : null; } private static void VerifyReadyOnlyForExpectedForeground() { var decision = Evaluate(expectedHwnd: 42, foregroundHwnd: 42); Check(decision.CanContinue, "expected Genshin foreground should allow input"); Check(decision.Outcome == NativeScannerInputGuardOutcome.Ready, "ready decision should use Ready outcome"); } private static void VerifyEveryStopSignalWins() { var cases = new[] { (Name: "stop request", Decision: Evaluate(stopRequested: true, foregroundHwnd: 7)), (Name: "ESC", Decision: Evaluate(escapePressed: true, foregroundHwnd: 7)), (Name: "Enter", Decision: Evaluate(enterPressed: true, foregroundHwnd: 7)), (Name: "F9", Decision: Evaluate(f9Pressed: true, foregroundHwnd: 7)), }; foreach (var item in cases) { Check(!item.Decision.CanContinue, $"{item.Name} should reject input"); Check(item.Decision.Outcome == NativeScannerInputGuardOutcome.Stopped, $"{item.Name} should stop cleanly"); } } private static void VerifyFocusLossBlocksWithoutRefocus() { var mismatch = Evaluate(expectedHwnd: 42, foregroundHwnd: 7, foregroundProcess: "explorer"); Check(!mismatch.CanContinue, "foreground mismatch should reject input"); Check(mismatch.Outcome == NativeScannerInputGuardOutcome.Blocked, "foreground mismatch should block the run"); Check(mismatch.Message.Contains("without refocusing", StringComparison.Ordinal), "focus-loss message should make no-refocus behavior explicit"); Check(mismatch.Message.Contains("explorer", StringComparison.Ordinal), "focus-loss message should name the actual foreground process"); var missingTarget = Evaluate(expectedHwnd: 0, foregroundHwnd: 0); Check(missingTarget.Outcome == NativeScannerInputGuardOutcome.Blocked, "missing target window should block the run"); } private static void VerifyKeyDispatchRequiresConfirmedFocus() { var sendCalls = 0; var unfocused = GenshinAssistant.InputHelper.Program.DispatchKeyPressWhenFocused(false, () => { sendCalls++; return 2; }); Check(sendCalls == 0, "unfocused key dispatch must not call SendInput"); Check(unfocused.EventsSent == 0, "unfocused key dispatch should report zero events"); Check(unfocused.InputBlocked, "unfocused key dispatch should report blocked input"); var focused = GenshinAssistant.InputHelper.Program.DispatchKeyPressWhenFocused(true, () => { sendCalls++; return 2; }); Check(sendCalls == 1, "focused key dispatch should call SendInput exactly once"); Check(focused.EventsSent == 2, "focused key dispatch should report both key events"); Check(!focused.InputBlocked, "focused complete key dispatch should not report blocked input"); var partial = GenshinAssistant.InputHelper.Program.DispatchKeyPressWhenFocused(true, () => 1); Check(partial.EventsSent == 1, "partial key dispatch should preserve the event count"); Check(partial.InputBlocked, "partial key dispatch should report blocked input"); } private static void VerifyTopResetIsBoundedAndCoversTheSupportedRange() { var requiredPages = (int)Math.Ceiling( NativeScannerInputSafetyPolicy.MaximumArtifactInventoryCapacity / (double)NativeScannerInputSafetyPolicy.VisibleArtifactTargetsPerPage); var minimumEvents = requiredPages * NativeScannerInputSafetyPolicy.PageScrollWheelEvents; Check(NativeScannerInputSafetyPolicy.InventoryTopResetWheelEvents >= minimumEvents, "top reset should cover every supported artifact page"); Check(NativeScannerInputSafetyPolicy.InventoryTopResetWheelEvents <= 3200, "top reset should remain explicitly bounded"); Check(NativeScannerInputSafetyPolicy.InventoryTopResetStabilizationMs >= 250, "top reset should include a visible stabilization window"); } private static void VerifyWheelPacingKeepsEveryEventGuardableWithoutPerEventSleep() { Check(NativeScannerInputSafetyPolicy.WheelPacingBatchEvents == NativeScannerInputSafetyPolicy.PageScrollWheelEvents, "wheel pacing should yield once per calibrated page-sized batch"); Check(NativeScannerInputSafetyPolicy.WheelPacingDelayMs > 0 && NativeScannerInputSafetyPolicy.WheelPacingDelayMs <= 5, "wheel pacing delay should remain short and explicitly bounded"); Check(!NativeScannerInputSafetyPolicy.ShouldPauseAfterWheelEvent(1), "wheel pacing should not sleep after every single event"); Check(NativeScannerInputSafetyPolicy.ShouldPauseAfterWheelEvent(NativeScannerInputSafetyPolicy.WheelPacingBatchEvents), "wheel pacing should yield at the batch boundary"); Check(NativeScannerInputSafetyPolicy.ShouldPauseAfterWheelEvent(NativeScannerInputSafetyPolicy.WheelPacingBatchEvents * 2), "wheel pacing should yield at repeated batch boundaries"); } private static void VerifyOnlyTheRealGameProcessCanBecomeTheTarget() { Check(GenshinAssistant.InputHelper.Program.IsSupportedGenshinProcessName("GenshinImpact"), "the global Genshin client process should be allowed"); Check(GenshinAssistant.InputHelper.Program.IsSupportedGenshinProcessName("yuanshen"), "the Chinese Genshin client process should be allowed case-insensitively"); Check(!GenshinAssistant.InputHelper.Program.IsSupportedGenshinProcessName("Genshin Artifact Assistant"), "the assistant must never target itself"); Check(!GenshinAssistant.InputHelper.Program.IsSupportedGenshinProcessName("GenshinLauncher"), "a launcher or similarly named process must not become the game target"); Check(!GenshinAssistant.InputHelper.Program.IsSupportedGenshinProcessName("Genshin"), "partial process-name matches must not be accepted"); } private static void VerifyArtifactStarDetectorCountsThreeFourAndFiveStars() { foreach (var count in new[] { 3, 4, 5 }) { using var bitmap = CreateSyntheticArtifactCard(count); var detection = ArtifactStarDetector.Detect(bitmap); Check(detection.StarCount == count, $"synthetic {count}-star card should retain its exact count"); Check(detection.Confidence >= 0.80, $"synthetic {count}-star card should be high confidence"); Check(detection.Source == ArtifactStarDetector.SourceName, $"synthetic {count}-star card should name the native source"); } } private static void VerifyArtifactStarDetectorFailsClosedForAmbiguousNoise() { using var bitmap = new Bitmap(492, 838, PixelFormat.Format32bppArgb); using var graphics = Graphics.FromImage(bitmap); graphics.Clear(Color.FromArgb(188, 138, 87)); var roi = ArtifactStarDetector.StarRowRoiFor(bitmap.Width, bitmap.Height); using var gold = new SolidBrush(Color.FromArgb(255, 204, 50)); var rowY = roi.Y + roi.Height / 2; // Thin gold marks and scattered pixels resemble a noisy capture but do // not form full star glyphs. The detector must not convert this into a // lower-rarity decision. for (var index = 0; index < 5; index++) { graphics.FillRectangle(gold, roi.X + 22 + index * 34, rowY, 22, 4); } var random = new Random(20260711); for (var index = 0; index < 120; index++) { graphics.FillRectangle( gold, roi.X + random.Next(roi.Width), roi.Y + random.Next(roi.Height), 1, 1); } var detection = ArtifactStarDetector.Detect(bitmap); Check(detection.StarCount is null, "ambiguous gold noise must not produce a star count"); Check(detection.Confidence < 0.80, "ambiguous gold noise must remain low confidence"); Check(detection.Source is null, "ambiguous gold noise must not claim a native rarity source"); } /// /// Optional offline regression check for user-provided, already-captured /// cards. The environment value is intentionally explicit rather than /// probing a user's inventory: 3=C:\\sample3.png;4=C:\\sample4.png. /// This keeps the test read-only and makes the expected rarity auditable. /// private static void VerifyArtifactStarDetectorRealCropsFromEnvironment() { var samples = Environment.GetEnvironmentVariable("ARTIFACT_STAR_REAL_CROPS"); if (string.IsNullOrWhiteSpace(samples)) return; foreach (var sample in samples.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { var separator = sample.IndexOf('='); var expectedText = separator > 0 ? sample[..separator] : string.Empty; var path = separator > 0 ? sample[(separator + 1)..].Trim() : string.Empty; if (!int.TryParse(expectedText, out var expected) || expected is < 1 or > 5 || !File.Exists(path)) { Check(false, $"real-card sample must use '<1-5>=': {sample}"); continue; } using var bitmap = new Bitmap(path); var detection = ArtifactStarDetector.Detect(bitmap); Check(detection.StarCount == expected, $"real-card sample '{Path.GetFileName(path)}' should detect {expected} stars, got {detection.StarCount?.ToString() ?? "unknown"}"); Check(detection.Confidence >= 0.80, $"real-card sample '{Path.GetFileName(path)}' should meet the accepted confidence threshold"); Check(detection.Source == ArtifactStarDetector.SourceName, $"real-card sample '{Path.GetFileName(path)}' should retain the native source"); } } private static Bitmap CreateSyntheticArtifactCard(int starCount) { var bitmap = new Bitmap(492, 838, PixelFormat.Format32bppArgb); using var graphics = Graphics.FromImage(bitmap); graphics.Clear(Color.FromArgb(188, 138, 87)); var roi = ArtifactStarDetector.StarRowRoiFor(bitmap.Width, bitmap.Height); var centerY = roi.Y + roi.Height / 2; var firstCenterX = roi.X + 28; using var gold = new SolidBrush(Color.FromArgb(255, 204, 50)); for (var index = 0; index < starCount; index++) { DrawStar(graphics, gold, firstCenterX + index * 34, centerY, 13, 5.5f); } return bitmap; } private static void DrawStar(Graphics graphics, Brush brush, int centerX, int centerY, float outerRadius, float innerRadius) { var points = new PointF[10]; for (var index = 0; index < points.Length; index++) { var radius = index % 2 == 0 ? outerRadius : innerRadius; var angle = -Math.PI / 2 + index * Math.PI / 5; points[index] = new PointF( centerX + radius * (float)Math.Cos(angle), centerY + radius * (float)Math.Sin(angle)); } graphics.FillPolygon(brush, points); } private static NativeScannerInputGuardDecision Evaluate( bool stopRequested = false, bool escapePressed = false, bool enterPressed = false, bool f9Pressed = false, long expectedHwnd = 42, long foregroundHwnd = 42, string foregroundProcess = "GenshinImpact") => NativeScannerInputSafetyPolicy.Evaluate( stopRequested, escapePressed, enterPressed, f9Pressed, expectedHwnd, foregroundHwnd, foregroundProcess, "test input"); private static void Check(bool condition, string message) { if (condition) return; failures++; Console.Error.WriteLine($"FAIL: {message}"); } }