376 lines
17 KiB
C#
376 lines
17 KiB
C#
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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=<existing native run 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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Optional offline regression check for user-provided, already-captured
|
|
/// cards. The environment value is intentionally explicit rather than
|
|
/// probing a user's inventory: <c>3=C:\\sample3.png;4=C:\\sample4.png</c>.
|
|
/// This keeps the test read-only and makes the expected rarity auditable.
|
|
/// </summary>
|
|
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>=<existing png path>': {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}");
|
|
}
|
|
}
|