Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ dotnet msbuild "Simple QR Code Maker.slnx" /restore /p:Configuration=Release /p:
```

- Visual Studio 2022 with the Windows App SDK workload and MSIX tooling is required; see `.vsconfig`.
- There is no automated test project in this repository, so there is no single-test command to run.
- Run headless tests with `.\scripts\Test.ps1 -Platform ARM64` (or `x64` on x64 hosts); add `-Coverage` for TRX and Cobertura reports.
- Run a single suite with `dotnet test --project "tests\Simple QR Code Maker.Core.Tests\Simple QR Code Maker.Core.Tests.csproj" -p:Platform=ARM64`. Use MTP filtering options shown by that project's `--help` to select tests.
- Core tests are portable; Windows tests require Windows and run without launching the app. Filesystem integration tests use isolated temporary directories.
- There is no dedicated lint command configured in the repository.

## UI automation testing
Expand All @@ -25,7 +27,7 @@ Always ask the user and wait for explicit approval before running UI automation

## High-level architecture

- The solution has two projects: `Simple QR Code Maker` is the WinUI 3 app, and `Simple QR Code Maker.Core` contains shared infrastructure such as `FileService`.
- The app references portable `Simple QR Code Maker.Core` (builders, parsing, workflow/print policies, infrastructure) and headless `Simple QR Code Maker.Windows` (rendering, persistence, Windows models). Each library has an MSTest/MTP project under `tests`.
- `App.xaml.cs` is the composition root. It builds a `Microsoft.Extensions.Hosting` host, registers services/view models/views, and routes startup through `IActivationService`.
- Activation is split between `DefaultActivationHandler` and `ShareTargetActivationHandler`. Shared images route into `DecodingViewModel`; shared text and URIs route into `MainViewModel`.
- `ShellPage` hosts navigation. `PageService` maps view-model keys to pages, and `NavigationService` performs frame navigation and forwards `INavigationAware` lifecycle calls.
Expand Down
65 changes: 46 additions & 19 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -1,38 +1,65 @@
name: Build

on:
# push:
# branches:
# - main
# - dev
# pull_request:
# branches:
# - main
# - dev
push:
branches: [main, dev]
pull_request:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
name: Tests (${{ matrix.os }}, ${{ matrix.platform }})
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
platform: x64
- os: windows-latest
platform: x64
- os: windows-11-arm
platform: ARM64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'
- name: Run headless tests with coverage
shell: pwsh
run: .\scripts\Test.ps1 -Platform ${{ matrix.platform }} -Coverage
- name: Upload results and coverage
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-${{ matrix.os }}-${{ matrix.platform }}
path: TestResults
if-no-files-found: warn

build:
name: Packaged app (x64)
runs-on: windows-latest

steps:
- name: Checkout code
uses: actions/checkout@v6

- name: Setup .NET
- name: Setup .NET 10 runtime and SDK
uses: actions/setup-dotnet@v5
with:
dotnet-version: '9.0.x'

dotnet-version: '10.0.x'
# Existing app sources use preview collection-expression arguments.
- name: Setup preview compiler
uses: actions/setup-dotnet@v5
with:
dotnet-version: '11.0.x'
dotnet-quality: preview
- name: Setup MSBuild
uses: microsoft/setup-msbuild@v2

- name: Restore
run: msbuild "Simple QR Code Maker.sln" /t:Restore /p:Configuration=Release /p:Platform=x64

- name: Build
run: msbuild "Simple QR Code Maker.sln" /p:Configuration=Release /p:Platform=x64
run: dotnet msbuild "Simple QR Code Maker.slnx" /restore /p:Configuration=Release /p:Platform=x64 /nologo
8 changes: 5 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ dotnet msbuild "Simple QR Code Maker.slnx" -p:Configuration=Release -p:Platform=

Visual Studio 2022 with the Windows App SDK workload and MSIX packaging tools is required. See `.vsconfig` for the exact required components.

## No Automated Tests
## Automated Tests

There are no unit or integration test projects. Testing is done manually using MSIX packages in `AppPackages/`.
Run `.\scripts\Test.ps1 -Platform ARM64` (use `x64` on x64 hosts), adding `-Coverage` for TRX and Cobertura reports in `TestResults`. `-Suite Core` selects portable tests; `-Suite Windows` selects headless Windows tests. Tests use MSTest with Microsoft.Testing.Platform, not VSTest. No app activation, real app data, Excel installation, or desktop interaction is needed.

## UI Automation Testing

Expand All @@ -41,7 +41,9 @@ The app follows **MVVM + Dependency Injection** using `CommunityToolkit.Mvvm` an
### Solution Structure

- **`Simple QR Code Maker/`** — Main WinUI 3 app project
- **`Simple QR Code Maker.Core/`** — Shared library (currently just `FileService` for JSON I/O)
- **`Simple QR Code Maker.Core/`** — Portable builders, parsers, workflow/print policies, and JSON/file infrastructure
- **`Simple QR Code Maker.Windows/`** — Headless Windows rendering, persistence, and models
- **`tests/`** — MSTest/MTP projects exercising the actual production libraries

### App Entry & DI

Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,29 @@ On the reading side, v2 can decode codes from files, folders, drag-and-drop, the
- Export settings, brands, and history to a ZIP backup.
- Import settings, brands, and history from a ZIP backup.

## Development and testing

The app targets .NET 10 and uses WinUI 3/MSIX. Build it with Visual Studio's Windows App SDK/MSIX tooling and `dotnet msbuild "Simple QR Code Maker.slnx" /restore /p:Configuration=Debug /p:Platform=ARM64 /nologo` (or `x64`). Existing app sources use preview collection-expression arguments, so the app build currently needs the .NET 11 preview SDK as well as the .NET 10 runtime.

The production logic lives in two libraries: portable `Simple QR Code Maker.Core`, and headless Windows-specific `Simple QR Code Maker.Windows`. Both are referenced by the app and by MSTest/Microsoft.Testing.Platform test projects. Tests do not launch the app or touch its settings.

```powershell
# Run both headless suites on Windows; Core only on other platforms.
.\scripts\Test.ps1 -Platform ARM64

# Run one suite, including coverage and TRX results in TestResults.
.\scripts\Test.ps1 -Platform ARM64 -Suite Core -Coverage

# Direct runner invocation (use x64 on x64 hosts).
dotnet test --project "tests\Simple QR Code Maker.Windows.Tests\Simple QR Code Maker.Windows.Tests.csproj" -p:Platform=ARM64
```

The test projects require .NET 10. Use `-Suite Core` on Linux/macOS. Real filesystem checks are marked `Integration`, operate only in unique temporary directories, and clean up after themselves. Native image processing is exercised on matching x64/ARM64 hosts. CI runs portable tests on Linux and both suites on Windows x64/ARM64, and publishes results and coverage even when a suite fails.

Coverage runs enforce the measured production-library floors in `tests\thresholds.json`. Keep these ratcheting upward; generated code is excluded, but entire helper/service classes are not. Core requires 95% line and 90% branch coverage; Windows requires 85% line and 80% branch coverage.

Keep tests deterministic: use synthetic data, fixed timestamps/cultures where relevant, controllable tasks instead of sleeps, and structural image/PDF assertions instead of whole-file snapshots. Regression tests should cover success, invalid input, boundaries, failures, and legacy compatibility. XAML bindings, actual pickers, clipboard, camera, Windows AI availability, Excel COM integration, and printing remain platform/UI checks; **running anything that launches or interacts with the app requires explicit user approval**.

---

Made with ❤️ in Milwaukee by Joe Finney.
Expand Down
4 changes: 2 additions & 2 deletions Simple QR Code Maker.Core/Contracts/Services/IFileService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

public interface IFileService
{
T Read<T>(string folderPath, string fileName);
T? Read<T>(string folderPath, string fileName);

void Save<T>(string folderPath, string fileName, T content);

void Delete(string folderPath, string fileName);
void Delete(string folderPath, string? fileName);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace Simple_QR_Code_Maker;
namespace Simple_QR_Code_Maker;

public enum FileKind
{
Expand Down
43 changes: 43 additions & 0 deletions Simple QR Code Maker.Core/Helpers/AsyncBatchProcessor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
namespace Simple_QR_Code_Maker.Helpers;

public static class AsyncBatchProcessor
{
public static async Task RunAsync<T>(
IReadOnlyList<T> items,
int maxConcurrency,
Func<T, Task> process,
Action<int>? updateProgress = null,
CancellationToken cancellationToken = default)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxConcurrency);
ArgumentNullException.ThrowIfNull(process);
using SemaphoreSlim semaphore = new(maxConcurrency, maxConcurrency);
int completed = 0;
await Task.WhenAll(items.Select(ProcessAsync));

async Task ProcessAsync(T item)
{
await semaphore.WaitAsync(cancellationToken);
try
{
cancellationToken.ThrowIfCancellationRequested();
await process(item);
}
finally
{
try
{
if (updateProgress is not null)
{
lock (semaphore)
updateProgress(++completed);
}
}
finally
{
semaphore.Release();
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,15 @@ public static class CsvParser
/// </summary>
public static List<List<string>> Parse(string csv, char delimiter = ',')
{
ValidateDelimiter(delimiter);
List<List<string>> rows = [];
if (string.IsNullOrEmpty(csv))
return rows;

// Normalise line endings to LF only so we can treat \n as the row separator.
csv = csv.Replace("\r\n", "\n").Replace('\r', '\n');

List<string> currentRow = [];
StringBuilder currentField = new();
bool inQuotes = false;
bool fieldStarted = false;
int i = 0;

while (i < csv.Length)
Expand Down Expand Up @@ -57,6 +56,7 @@ public static List<List<string>> Parse(string csv, char delimiter = ',')
if (c == '"')
{
inQuotes = true;
fieldStarted = true;
i++;
}
else if (c == delimiter)
Expand All @@ -65,13 +65,16 @@ public static List<List<string>> Parse(string csv, char delimiter = ',')
currentField.Clear();
i++;
}
else if (c == '\n')
else if (c is '\r' or '\n')
{
currentRow.Add(currentField.ToString());
currentField.Clear();
rows.Add(currentRow);
currentRow = [];
fieldStarted = false;
i++;
if (c == '\r' && i < csv.Length && csv[i] == '\n')
i++;
}
else
{
Expand All @@ -82,17 +85,19 @@ public static List<List<string>> Parse(string csv, char delimiter = ',')
}

// Flush the last field and row (file may not end with a newline).
currentRow.Add(currentField.ToString());
// Only add the last row if it is non-empty (avoids a phantom blank row for
// files that do end with a newline).
if (currentRow.Count > 1 || (currentRow.Count == 1 && currentRow[0].Length > 0))
if (currentRow.Count > 0 || currentField.Length > 0 || fieldStarted)
{
currentRow.Add(currentField.ToString());
rows.Add(currentRow);
}

return rows;
}

public static string Serialize(IEnumerable<IReadOnlyList<string>> rows, char delimiter = ',')
{
ArgumentNullException.ThrowIfNull(rows);
ValidateDelimiter(delimiter);
StringBuilder builder = new();
bool isFirstRow = true;

Expand All @@ -106,7 +111,9 @@ public static string Serialize(IEnumerable<IReadOnlyList<string>> rows, char del
if (index > 0)
builder.Append(delimiter);

builder.Append(EscapeField(row[index], delimiter));
builder.Append(row.Count == 1 && row[index].Length == 0
? "\"\""
: EscapeField(row[index], delimiter));
}

isFirstRow = false;
Expand All @@ -115,6 +122,12 @@ public static string Serialize(IEnumerable<IReadOnlyList<string>> rows, char del
return builder.ToString();
}

private static void ValidateDelimiter(char delimiter)
{
if (delimiter is '"' or '\r' or '\n')
throw new ArgumentOutOfRangeException(nameof(delimiter), "A delimiter cannot be a quote or newline.");
}

private static string EscapeField(string field, char delimiter)
{
bool requiresQuotes = field.Contains(delimiter)
Expand Down
43 changes: 43 additions & 0 deletions Simple QR Code Maker.Core/Helpers/DelimitedSpreadsheetHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using Simple_QR_Code_Maker.Models;

namespace Simple_QR_Code_Maker.Helpers;

public static class DelimitedSpreadsheetHelper
{
public static async Task<SpreadsheetReadResult> ReadAsync(string filePath, char delimiter)
{
string contents = await File.ReadAllTextAsync(filePath);
List<List<string>> rows = CsvParser.Parse(contents, delimiter);
return new SpreadsheetReadResult
{
Rows = [.. rows.Select((row, index) => new SpreadsheetSourceRow
{
SourceRowIndex = index,
Cells = [.. row],
})],
};
}

public static async Task WriteGeneratedIdsAsync(string filePath, char delimiter, SpreadsheetIdWriteRequest request)
{
string contents = await File.ReadAllTextAsync(filePath);
List<List<string>> rows = CsvParser.Parse(contents, delimiter);
SpreadsheetImportPolicy.ApplyGeneratedIds(rows, request);
string updatedContents = CsvParser.Serialize(rows, delimiter);
string fullPath = Path.GetFullPath(filePath);
string directory = Path.GetDirectoryName(fullPath)
?? throw new InvalidOperationException("Unable to determine the spreadsheet file directory.");
string tempFilePath = Path.Combine(directory, $"{Path.GetFileName(fullPath)}.{Guid.NewGuid():N}.tmp");

try
{
await File.WriteAllTextAsync(tempFilePath, updatedContents);
File.Replace(tempFilePath, fullPath, destinationBackupFileName: null, ignoreMetadataErrors: true);
}
finally
{
if (File.Exists(tempFilePath))
File.Delete(tempFilePath);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ public static string Serialize(EmailBuilderState state)

StringBuilder builder = new();
builder.Append(MailtoPrefix);
builder.Append(state.To.Trim());
builder.Append(Uri.EscapeDataString(state.To.Trim())
.Replace("%40", "@")
.Replace("%2C", ",")
.Replace("%3B", ";"));

List<string> queryParts = [];

Expand Down
6 changes: 3 additions & 3 deletions Simple QR Code Maker.Core/Helpers/Json.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ public static class Json
TypeInfoResolver = new DefaultJsonTypeInfoResolver()
};

public static async Task<T> ToObjectAsync<T>(string value)
public static async Task<T?> ToObjectAsync<T>(string value)
{
return await Task.Run<T>(() =>
return await Task.Run<T?>(() =>
{
return JsonSerializer.Deserialize<T>(value, _options);
});
}

public static async Task<string> StringifyAsync(object value)
public static async Task<string> StringifyAsync(object? value)
{
return await Task.Run<string>(() =>
{
Expand Down
Loading
Loading