diff --git a/NAPS2.Sdk.ScannerTests/.gitignore b/NAPS2.Sdk.ScannerTests/.gitignore
new file mode 100644
index 000000000..30794521b
--- /dev/null
+++ b/NAPS2.Sdk.ScannerTests/.gitignore
@@ -0,0 +1,33 @@
+Thumbs.db
+*.obj
+*.exe
+*.pdb
+*.user
+*.aps
+*.pch
+*.vspscc
+*_i.c
+*_p.c
+*.ncb
+*.suo
+*.sln.docstates
+*.tlb
+*.tlh
+*.bak
+*.cache
+*.ilk
+*.log
+[Bb]in
+[Dd]ebug*/
+*.lib
+*.sbr
+obj/
+[Rr]elease*/
+_ReSharper*/
+[Tt]est[Rr]esult*
+*.vssscc
+$tf*/
+publish/
+bin/
+temp/
+*.credentials.json
\ No newline at end of file
diff --git a/NAPS2.Sdk.ScannerTests/HowToRunScannerTests.cs b/NAPS2.Sdk.ScannerTests/HowToRunScannerTests.cs
new file mode 100644
index 000000000..e09dce366
--- /dev/null
+++ b/NAPS2.Sdk.ScannerTests/HowToRunScannerTests.cs
@@ -0,0 +1,11 @@
+namespace NAPS2.Sdk.ScannerTests;
+
+public class HowToRunScannerTests
+{
+ // 1. Print out the NAPS2 test page (naps2_test_page.pdf) and put it in your scanner.
+
+ // 2. Set to "" to use the first scanner device, otherwise a substring of the scanner name (e.g. "Canon").
+ public const string SCANNER_NAME = "";
+
+ // 3. Start debugging the test you want to run.
+}
\ No newline at end of file
diff --git a/NAPS2.Sdk.ScannerTests/NAPS2.Sdk.ScannerTests.csproj b/NAPS2.Sdk.ScannerTests/NAPS2.Sdk.ScannerTests.csproj
new file mode 100644
index 000000000..8be1abd8a
--- /dev/null
+++ b/NAPS2.Sdk.ScannerTests/NAPS2.Sdk.ScannerTests.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net462
+ NAPS2.Sdk.ScannerTests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/NAPS2.Sdk.ScannerTests/ScannerFactAttribute.cs b/NAPS2.Sdk.ScannerTests/ScannerFactAttribute.cs
new file mode 100644
index 000000000..8e840c357
--- /dev/null
+++ b/NAPS2.Sdk.ScannerTests/ScannerFactAttribute.cs
@@ -0,0 +1,14 @@
+using Xunit;
+
+namespace NAPS2.Sdk.ScannerTests;
+
+public sealed class ScannerFactAttribute : FactAttribute
+{
+ public ScannerFactAttribute()
+ {
+ if (!Debugger.IsAttached)
+ {
+ Skip = "Scanner tests can only run when debugging as they require user interaction.";
+ }
+ }
+}
\ No newline at end of file
diff --git a/NAPS2.Sdk.ScannerTests/ScannerTests.cs b/NAPS2.Sdk.ScannerTests/ScannerTests.cs
new file mode 100644
index 000000000..f41de029e
--- /dev/null
+++ b/NAPS2.Sdk.ScannerTests/ScannerTests.cs
@@ -0,0 +1,58 @@
+using NAPS2.Images.Gdi;
+using NAPS2.Scan;
+using Xunit;
+
+namespace NAPS2.Sdk.ScannerTests;
+
+public class ScannerTests
+{
+ // TODO: Make real tests for WIA/TWAIN, Flatbed/Feeder, Color/Gray/BW, DPIs, etc.
+
+ [ScannerFact]
+ public async Task Test1()
+ {
+ var imageContext = new GdiImageContext();
+ using var scanningContext = new ScanningContext(imageContext);
+
+ var scanController = new ScanController(scanningContext);
+ var devices = await scanController.GetDeviceList();
+ var device = GetUserDevice(devices);
+
+ var options = new ScanOptions
+ {
+ Device = device,
+ Driver = Driver.Wia,
+ PaperSource = PaperSource.Flatbed,
+ Dpi = 100
+ };
+
+ var source = scanController.Scan(options);
+ var image = await source.Next();
+
+ Assert.NotNull(image);
+
+ using var rendered = imageContext.Render(image);
+
+ // TODO: Aside from generating the relevant files/resources, we also need to consider how to compare images when ImageAsserts assumes perfect pixel alignment.
+ // TODO: One possibility is having a section of the test page with gradual gradients and only compare that subsection of the images.
+ // ImageAsserts.Similar(ScannerTestResources.naps2_test_page, rendered);
+ }
+
+ // TODO: Generalize the common infrastructure into helper classes (ScannerTests as a base class, FlatbedTests, FeederTests, etc.?)
+ private static ScanDevice GetUserDevice(List devices)
+ {
+ if (devices.Count == 0)
+ {
+ throw new InvalidOperationException("No scanner available");
+ }
+ foreach (var device in devices)
+ {
+ if (device.Name!.IndexOf(HowToRunScannerTests.SCANNER_NAME, StringComparison.OrdinalIgnoreCase) != -1)
+ {
+ return device;
+ }
+ }
+ throw new InvalidOperationException("Set SCANNER_NAME to one of: " +
+ string.Join(",", devices.Select(x => x.Name)));
+ }
+}
\ No newline at end of file
diff --git a/NAPS2.Sdk.ScannerTests/xunit.runner.json b/NAPS2.Sdk.ScannerTests/xunit.runner.json
new file mode 100644
index 000000000..369786b65
--- /dev/null
+++ b/NAPS2.Sdk.ScannerTests/xunit.runner.json
@@ -0,0 +1,4 @@
+{
+ "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
+ "parallelizeTestCollections": false
+}
\ No newline at end of file
diff --git a/NAPS2.Sdk/Scan/ScanDevice.cs b/NAPS2.Sdk/Scan/ScanDevice.cs
index f20b08ab6..0ada1a916 100644
--- a/NAPS2.Sdk/Scan/ScanDevice.cs
+++ b/NAPS2.Sdk/Scan/ScanDevice.cs
@@ -1,5 +1,6 @@
namespace NAPS2.Scan;
+// TODO: Can we make this a record and/or make properties non-nullable?
///
/// The representation of a scanning device identified by a driver.
///
diff --git a/NAPS2.sln b/NAPS2.sln
index e84f486a1..b838318f9 100644
--- a/NAPS2.sln
+++ b/NAPS2.sln
@@ -55,6 +55,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NAPS2.Images.Gdi", "NAPS2.I
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NAPS2.App.Tests", "NAPS2.App.Tests\NAPS2.App.Tests.csproj", "{3CA45B6B-F055-4FFB-B9B6-0FF381A752F9}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NAPS2.Sdk.ScannerTests", "NAPS2.Sdk.ScannerTests\NAPS2.Sdk.ScannerTests.csproj", "{D291C9E9-42D2-4601-9EE3-1CBCA200B897}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -253,6 +255,16 @@ Global
{3CA45B6B-F055-4FFB-B9B6-0FF381A752F9}.InstallerMSI|Any CPU.Build.0 = Debug|Any CPU
{3CA45B6B-F055-4FFB-B9B6-0FF381A752F9}.Standalone|Any CPU.ActiveCfg = Debug|Any CPU
{3CA45B6B-F055-4FFB-B9B6-0FF381A752F9}.Standalone|Any CPU.Build.0 = Debug|Any CPU
+ {D291C9E9-42D2-4601-9EE3-1CBCA200B897}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D291C9E9-42D2-4601-9EE3-1CBCA200B897}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D291C9E9-42D2-4601-9EE3-1CBCA200B897}.DebugLang|Any CPU.ActiveCfg = Debug|Any CPU
+ {D291C9E9-42D2-4601-9EE3-1CBCA200B897}.DebugLang|Any CPU.Build.0 = Debug|Any CPU
+ {D291C9E9-42D2-4601-9EE3-1CBCA200B897}.InstallerEXE|Any CPU.ActiveCfg = Debug|Any CPU
+ {D291C9E9-42D2-4601-9EE3-1CBCA200B897}.InstallerEXE|Any CPU.Build.0 = Debug|Any CPU
+ {D291C9E9-42D2-4601-9EE3-1CBCA200B897}.InstallerMSI|Any CPU.ActiveCfg = Debug|Any CPU
+ {D291C9E9-42D2-4601-9EE3-1CBCA200B897}.InstallerMSI|Any CPU.Build.0 = Debug|Any CPU
+ {D291C9E9-42D2-4601-9EE3-1CBCA200B897}.Standalone|Any CPU.ActiveCfg = Debug|Any CPU
+ {D291C9E9-42D2-4601-9EE3-1CBCA200B897}.Standalone|Any CPU.Build.0 = Debug|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE