diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0d17fda --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +*.pdf binary +*.nupkg binary +*.snupkg binary +*.vsix binary diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 94b383f..0d06ca6 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -1,17 +1,27 @@ -# This workflow will build a .NET project +# This workflow will build and test the .NET projects, and pack the NuGet packages # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net +# +# The tests run on Microsoft Testing Platform (MTP): `dotnet test` is switched to MTP mode by the "test" section of +# global.json (the .NET 10 SDK is needed), and the reports (TRX, code coverage) are produced by MTP extensions. name: .NET permissions: + contents: read pull-requests: write on: push: branches: [ "main" ] + tags: [ "v*" ] pull_request: branches: [ "main" ] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true defaults: run: @@ -20,29 +30,55 @@ defaults: jobs: build: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + os: [ ubuntu-latest, windows-latest ] + + runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup .NET - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v4 with: dotnet-version: | - 6.0.x - 7.0.x 8.0.x 9.0.x + 10.0.x - name: Restore dependencies run: dotnet restore - name: Build run: dotnet build --no-restore - - name: Test - run: dotnet test --no-build --verbosity normal --collect:"XPlat Code Coverage" --settings .runsettings + + # One target framework at a time. Every run keeps going when the one before it failed, so that all the failures show up, + # and the job still fails at the end. The reports go to TestResults/ (at the root of the repository). + - name: Test (net8.0) + run: dotnet test --solution MDAT.slnx --no-build --framework net8.0 --results-directory ../TestResults/net8.0 --report-trx + - name: Test (net9.0) + if: ${{ !cancelled() }} + run: dotnet test --solution MDAT.slnx --no-build --framework net9.0 --results-directory ../TestResults/net9.0 --report-trx + - name: Test (net10.0) + if: ${{ !cancelled() && runner.os != 'Linux' }} + run: dotnet test --solution MDAT.slnx --no-build --framework net10.0 --results-directory ../TestResults/net10.0 --report-trx + - name: Test (net10.0, with code coverage) + if: ${{ !cancelled() && runner.os == 'Linux' }} + run: dotnet test --solution MDAT.slnx --no-build --framework net10.0 --results-directory ../TestResults/net10.0 --report-trx --coverage --coverage-output-format cobertura --coverage-output coverage.cobertura.xml + + # The TRX reports of the Microsoft Testing Platform and the coverage, kept even when a test fails + - name: Upload test results + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ matrix.os }} + path: TestResults/** + if-no-files-found: error - name: Code Coverage Report + if: runner.os == 'Linux' uses: irongut/CodeCoverageSummary@v1.3.0 with: - filename: src/MDATTests/TestResults/**/coverage.cobertura.xml + filename: TestResults/net10.0/coverage.cobertura.xml badge: true fail_below_min: true format: markdown @@ -50,11 +86,73 @@ jobs: hide_complexity: true indicators: true output: both - thresholds: '80 80' + # line rate / branch rate (measured: ~85% / ~75%) + thresholds: '80 70' - name: Add Coverage PR Comment uses: marocchino/sticky-pull-request-comment@v2 - if: github.event_name == 'pull_request' + if: runner.os == 'Linux' && github.event_name == 'pull_request' with: recreate: true path: code-coverage-results.md + + # Packages for review only: nothing is published from here + - name: Pack + if: runner.os == 'Linux' + run: dotnet pack MDAT.slnx -c Release -o ../artifacts + - name: Upload packages + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: nuget-packages + path: artifacts/* + if-no-files-found: error + + # The files to download: the NuGet packages and the Visual Studio extension. Nothing goes to nuget.org from here. + # Every run keeps them as the "dist" artifact; a tag "v..." also attaches them to a GitHub release. + dist: + needs: build + runs-on: windows-latest + permissions: + contents: write + defaults: + run: + working-directory: . + + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + - name: Pack the NuGet packages + run: dotnet pack src/MDAT.slnx -c Release -o dist + # The extension is built for .NET Framework: MSBuild of Visual Studio (found with vswhere), not `dotnet build`. + # Only the actions of GitHub are used here: the organization may not allow the others. + - name: Build the Visual Studio extension + shell: pwsh + run: | + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + $msbuild = & $vswhere -latest -prerelease -requires Microsoft.Component.MSBuild -find "MSBuild\**\Bin\MSBuild.exe" | Select-Object -First 1 + & $msbuild src/MDAT.VisualStudio/MDAT.VisualStudio.csproj -restore -p:Configuration=Release -v:minimal + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Copy-Item src/MDAT.VisualStudio/bin/Release/net472/MDAT.VisualStudio.vsix dist/ + - name: Upload dist + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/* + if-no-files-found: error + - name: Attach to the GitHub release + if: startsWith(github.ref, 'refs/tags/v') + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $options = @() + if ($env:GITHUB_REF_NAME.Contains('-')) { $options += '--prerelease' } + gh release create $env:GITHUB_REF_NAME (Get-ChildItem dist/*).FullName --title $env:GITHUB_REF_NAME --generate-notes @options + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 6a224fc..5f36a51 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -7,7 +7,7 @@ "type": "process", "args": [ "build", - "${workspaceFolder}/src/MDAT/MDAT.csproj", + "${workspaceFolder}/src/MDAT.Core/MDAT.Core.csproj", "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], @@ -19,7 +19,7 @@ "type": "process", "args": [ "publish", - "${workspaceFolder}/src/MDAT/MDAT.csproj", + "${workspaceFolder}/src/MDAT.Core/MDAT.Core.csproj", "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], @@ -33,9 +33,29 @@ "watch", "run", "--project", - "${workspaceFolder}/src/MDAT/MDAT.csproj" + "${workspaceFolder}/src/MDAT.Core/MDAT.Core.csproj" ], "problemMatcher": "$msCompile" + }, + { + "label": "Run MDAT tests (net8)", + "type": "shell", + "command": "dotnet", + "args": [ + "run", + "--project", + "src/MDAT.Tests/MDAT.Tests.csproj", + "-f", + "net8.0" + ], + "options": { + "cwd": "${workspaceFolder}" + }, + "group": { + "kind": "test", + "isDefault": true + }, + "problemMatcher": [] } ] } \ No newline at end of file diff --git a/README.md b/README.md index d980732..ca61983 100644 --- a/README.md +++ b/README.md @@ -2,26 +2,65 @@ # mdAT - Tests automatiques en Markdown -``mdAT`` est une bibliothèque simple qui remplace les tests unitaires et d'intégration. Avec ``mdAT``, les développeurs peuvent écrire des tests unitaires de manière traditionnelle, mais sans avoir besoin d'ajouter de nombreuses données de test à partir de fichiers JSON, XML, YAML, ou d'utiliser manuellement ``[InlideData]`` avec des objets JSON échappés. +``mdAT`` est une bibliothèque simple qui remplace les tests unitaires et d'intégration. Avec ``mdAT``, les développeurs peuvent écrire des tests unitaires de manière traditionnelle, mais sans avoir besoin d'ajouter de nombreuses données de test à partir de fichiers JSON, XML, YAML, ou d'utiliser manuellement ``[Arguments]`` avec des objets JSON échappés. + +| Paquet | Contenu | +|---|---| +| `MDAT` | Le paquet à installer : il apporte `MDAT.TUnit` et `MDAT.Core`. | +| `MDAT.TUnit` | Les sources de données pour [TUnit](https://tunit.dev) : ``[MarkdownDataSource]`` et ``[YamlFileDataSource]``. | +| `MDAT.Core` | Lecture des fichiers Markdown/YAML, ``Verify.Assert()`` et comparaison JSON. Indépendant de tout framework de test. | + +Cibles : .NET 8, .NET 9 et .NET 10. Pour utiliser mdAT, suivez ces étapes : -1. Créez un test MSTestV2 normal avec ``[TestClass]``, ``[TestMethod]``. -2. Codez votre test unitaire de manière traditionnelle en utilisant Moq, si nécessaire, puis appelez la méthode que vous souhaitez tester. -3. Ajoutez tous les paramètres souhaités à votre méthode de test, ils seront exposés pour être remplacés par le fichier de test Markdown de ``mdAT``. -4. Ajoutez ``[MarkdownTest("~/Tests/{method}.md")]`` avec votre dossier ``Tests`` comme référentiel de cas (laissez-le vide au départ). -5. Assurez-vous d'entourer votre méthode testée avec ``Verify.Assert()``, cela validera automatiquement le résultat JSON attendu, même s'il s'agit d'une exception. -6. Votre fichier de test ``.md`` sera généré automatiquement lors de la première exécution. +1. Créez un projet de test [TUnit](https://tunit.dev) (`Exe`) et ajoutez le paquet ``MDAT``. +2. Créez un test normal avec ``[Test]``. +3. Codez votre test unitaire de manière traditionnelle (avec Moq, si nécessaire), puis appelez la méthode que vous souhaitez tester. +4. Ajoutez tous les paramètres souhaités à votre méthode de test, ils seront exposés pour être remplacés par le fichier de test Markdown de ``mdAT``. +5. Ajoutez ``[MarkdownDataSource("~/Tests/{method}.md")]`` avec votre dossier ``Tests`` comme référentiel de cas (laissez-le vide au départ). +6. Assurez-vous d'entourer votre méthode testée avec ``Verify.Assert()``, cela validera automatiquement le résultat JSON attendu, même s'il s'agit d'une exception. +7. Votre fichier de test ``.md`` sera généré automatiquement lors de la première exécution. Modifiez votre fichier ``.md`` comme vous le souhaitez. -7. Profitez-en ! +8. Profitez-en ! + +Chaque cas du fichier Markdown devient un test distinct dans l'explorateur de tests, et son emplacement est son titre dans le fichier Markdown (ou le début de son document dans un fichier YAML), pas la méthode C# partagée par tous les cas. TUnit transmet cet emplacement à l'explorateur de tests, qui s'en sert pour « Aller à la source » (double-clic). Les arguments sont construits à partir du YAML et passés tels quels à votre méthode : aucune sérialisation intermédiaire, quel que soit leur type (`DateOnly`, `Dictionary`, `JsonDocument`, ...). + +### Lien vers le test C# dans le fichier Markdown + +Chaque fichier Markdown garde, juste sous son titre, un lien vers le test C# qui le lit, avec la ligne (cliquable dans VS Code et sur GitHub ; dans Visual Studio, avec l'[extension](#extension-visual-studio--lien-cliquable) ci-dessous) : + +```md +# Md1 + +> C# : [MarkdownTestAttributeTests.cs:49](../MarkdownTestAttributeTests.cs#L49) +``` + +mdAT ajoute cette ligne et la corrige quand le test change de ligne, à chaque découverte des tests : + +- le lien est relatif au fichier Markdown, donc valable pour toute l'équipe ; +- **le fichier n'est réécrit que si la ligne change** (même contenu, aucune écriture : la date de modification du fichier ne bouge pas) ; +- le reste du fichier est conservé tel quel (encodage, BOM, fins de ligne) ; +- le commentaire `` identifie la ligne gérée par mdAT : vous pouvez écrire autour sans qu'elle soit perdue. Un fichier partagé par plusieurs tests garde une ligne par test ; +- un fichier en lecture seule, ou dont le fichier C# est introuvable à ce chemin, est laissé tel quel, sans erreur ; +- à la première exécution, **tous vos fichiers Markdown existants reçoivent cette ligne** : c'est un changement unique à relire puis à committer. Ensuite, un fichier ne change que si son test change de ligne. + +#### Extension Visual Studio : lien cliquable +L'éditeur de Visual Studio ne suit pas les liens relatifs d'un fichier Markdown. L'extension `MDAT.VisualStudio.vsix` règle cela : la ligne `> C# :` est soulignée comme un lien et **Ctrl+clic ouvre le fichier C# à la ligne du test**. Seule la ligne gérée par mdAT (celle qui porte le commentaire ``) est concernée, les autres liens du fichier ne sont pas touchés. + +- Fichier : [`dist/MDAT.VisualStudio.vsix`](dist/MDAT.VisualStudio.vsix) dans ce dépôt (construit aussi par l'intégration continue). +- **F7 va d'un fichier à l'autre** : dans un `.md`, F7 (« Afficher le code ») ouvre le test C# (le lien de la ligne du curseur, sinon le premier du fichier) ; dans une méthode de test C#, F7 ouvre le `.md` qui porte son marqueur ``. Le curseur peut être dans le corps, dans les attributs ou dans la documentation de la méthode. Le `.md` n'a son marqueur qu'après la première découverte des tests : tant que les tests n'ont pas tourné, F7 l'indique dans la barre d'état. Dans les autres cas, F7 garde son comportement habituel. +- Installation : fermer Visual Studio, puis double-cliquer sur le fichier. Essayée avec Visual Studio 2026 ; déclarée aussi pour 2022 (x64 et ARM64), sans essai. **Copiez le fichier hors d'un dossier synchronisé par OneDrive avant de l'installer** : l'installateur échoue sur ces fichiers. +- Construction : `msbuild src\MDAT.VisualStudio\MDAT.VisualStudio.csproj -restore -p:Configuration=Release` (MSBuild de Visual Studio ; ce projet n'est pas dans `MDAT.slnx`). ````csharp -[TestClass] +using MDAT; + public class Calc { - [TestMethod] - [MarkdownTest("~/Tests/{method}.md")] + [Test] + [MarkdownDataSource("~/Tests/{method}.md")] public async Task Add(int val1, int val2, string expected) { _ = await Verify.Assert(() => Task.FromResult(Add(val1, val2)), expected); @@ -68,27 +107,280 @@ expected: Avec `mdAT`, les analystes et les développeurs peuvent travailler ensemble avec une source unique de tests, et les mainteneurs peuvent modifier et valider tous les tests localement ou sur un serveur de build. +## Catégories et propriétés des cas + +Un cas peut avoir des catégories et des propriétés, celles de TUnit (elles servent à filtrer les tests et à les regrouper dans l'explorateur) : + +- **pour un cas** : des paires `nom=valeur` dans la ligne de sa clôture, à côté de `skipped` et `selected` ; +- **pour tout le fichier** : `tags:` dans l'en-tête YAML du fichier Markdown. + +````md +--- +tags: + category: Facturation + domaine: Taxes +--- +# Add + +## Cas 1 + +``````yaml category=Smoke,Rapide priorite=Haute +a: 1 +b: 2 +expected: 3 +`````` +```` + +- `category` donne des **catégories** ; tout autre nom (`domaine`, `priorite`) donne une **propriété**. +- Ici, le cas a les catégories `Facturation`, `Smoke` et `Rapide`, et les propriétés `domaine=Taxes` et `priorite=Haute`. +- Les catégories du fichier et celles du cas **s'additionnent**. Pour une propriété, celle du cas **remplace** celle du fichier de même nom. +- Plusieurs catégories se séparent par des virgules. Dans l'en-tête, une liste YAML convient aussi (`env: [dev, test]` donne deux propriétés `env`). Une valeur avec des espaces s'écrit entre guillemets (`category="Très lent"`, `responsable="Équipe facturation"`). +- Un cas ignoré (`skipped`) garde ses catégories et ses propriétés. +- Deux cas qui portent exactement le même nom et des propriétés différentes ne reçoivent aucune propriété (ils ne peuvent pas être distingués) : donnez-leur des titres différents. + +## Options d'une étape de vérification + +Chaque élément de `verify` accepte : + +| Option | Défaut | Rôle | +|---|---|---| +| `type` | `match` | Type de vérification (seul `match` existe). | +| `jsonPath` | `$` | Partie du résultat à comparer. Se termine par `.length()` pour comparer une taille. | +| `data` | | Valeur attendue. | +| `allowAdditionalProperties` | `false` | Ignore les propriétés présentes dans le résultat mais absentes de `data`. | +| `ignoreArrayOrder` | `false` | Deux tableaux sont égaux s'ils contiennent les mêmes valeurs, dans n'importe quel ordre (les doublons sont comptés). S'applique à tous les niveaux. | + +```yaml +expected: + verify: + - type: match + jsonPath: $.items + ignoreArrayOrder: true + allowAdditionalProperties: true + data: + - A: second + - A: first +``` + +## Comparaison JSON + +La comparaison est faite par `MDAT.Core` (aucune dépendance à xunit ou Quibble) : + +- les nombres sont comparés exactement, quelle que soit leur écriture (`1`, `1.0` et `1e0` sont égaux, `12345678901234567890` et `12345678901234567891` sont différents) ; +- les éléments d'un tableau sont appariés par valeur : un élément qui en remplace un autre est comparé en détail (`$.items[1].name`), un élément en trop ou manquant est listé avec son index ; +- une propriété en double dans un JSON (`{"a":1,"a":2}`) est refusée, au lieu d'en ignorer une silencieusement ; +- l'écart est décrit dans `JsonAssertException` (`DiffMessages`, `UserMessage`). + +## Migrer vers mdAT 2.0 + +mdAT 2.0 abandonne MSTest au profit de TUnit. Les projets qui restent sur MSTest peuvent continuer d'utiliser mdAT 1.x. + +### 1. Le projet de test + +- Cibler **.NET 8 ou plus** (.NET 6 et .NET 7 ne sont plus pris en charge). +- Ajouter `Exe` : un projet TUnit est un exécutable. +- Retirer `Microsoft.NET.Test.Sdk`, `MSTest.TestAdapter`, `MSTest.TestFramework` et `coverlet.collector`. +- Mettre à jour le paquet `MDAT` en 2.0 (il apporte maintenant `MDAT.TUnit` et `MDAT.Core`) et ajouter `TUnit`. + +```xml + + + Exe + net9.0 + + + + + + +``` + +Les fichiers `.md` ne changent pas et restent aux mêmes chemins (`~/` désigne toujours la racine du projet de test). + +### 2. Le code des tests + +| mdAT 1.x / MSTest | mdAT 2.0 / TUnit | +|---|---| +| `[TestClass]` | (retirer) | +| `[TestMethod]` | `[Test]` | +| `[MarkdownTest("~/Tests/{method}.md")]` | `[MarkdownDataSource("~/Tests/{method}.md")]` (l'ancien nom reste utilisable, voir ci-dessous) | +| `[YamlFileData("~/Tests/x.yaml")]` | `[YamlFileDataSource("~/Tests/x.yaml")]` (idem) | +| `[DataRow(...)]` | `[Arguments(...)]` | +| `[DynamicData(...)]` | `[MethodDataSource(...)]` | +| `[TestInitialize]` / `[TestCleanup]` | `[Before(Test)]` / `[After(Test)]` | +| `[ClassInitialize]` | `[Before(Class)]` | +| `[DoNotParallelize]` | `[NotInParallel]` | +| `Assert.AreEqual(a, b)` | `await Assert.That(b).IsEqualTo(a)` | + +`Verify.Assert()`, `Expected`, `ObjectOrException`, `ReturnsOrThrows` et l'inclusion de fichiers (`!include`) fonctionnent sans changement. Les types restent dans l'espace de noms `MDAT`. + +Les noms `[MarkdownTest]` et `[YamlFileData]` continuent de fonctionner dans `MDAT.TUnit` : ce sont des alias exacts de `[MarkdownDataSource]` et `[YamlFileDataSource]`, sans avertissement. Les deux noms sont valides : vos fichiers de test n'ont à changer que pour passer de MSTest à TUnit (`[TestClass]`, `[TestMethod]`, etc.), pas pour mdAT. + +### 3. Changements d'API + +| mdAT 1.x | mdAT 2.0 | +|---|---| +| `MarkdownTestAttribute` | `MarkdownDataSourceAttribute` (`MDAT.TUnit`) ; la lecture des cas est dans `MarkdownTestSource.GetCases()` (`MDAT.Core`). `MarkdownTestAttribute` reste disponible, comme alias de `MarkdownDataSourceAttribute`. | +| `YamlFileDataAttribute` | `YamlFileDataSourceAttribute` ; lecture dans `YamlFileTestSource.GetCases()`. `YamlFileDataAttribute` reste disponible, comme alias de `YamlFileDataSourceAttribute`. | +| `InternalTestFailureException` (MSTest) | `MdatTestDefinitionException` | +| `Quibble.Xunit.JsonAssertException`, `JsonDiffConfig`, `JsonAssert` | `MDAT.JsonAssertException`, `MDAT.JsonDiffConfig`, `MDAT.JsonAssert` | + +Les dépendances `Quibble.Xunit` (donc xunit), `MSTest.TestFramework`, `System.Data.SqlClient` et `System.Text.RegularExpressions` sont retirées, ce qui supprime leurs vulnérabilités transitives connues. `Moq` reste une dépendance de `MDAT.Core` pour les méthodes `ReturnsOrThrows`. + +Un cas marqué `skipped` dans le fichier Markdown (`` ``````yaml skipped ``) **n'est plus absent de la liste** : il apparaît dans l'explorateur de tests comme **ignoré**, avec une raison dans la langue d'interface du poste (« Marqué comme ignoré (skipped) dans le fichier Markdown » en français, « Marked as skipped in the markdown file » sinon). Son YAML n'est pas lu (il peut être inachevé) et la méthode de test n'est pas appelée. De même, quand un cas est marqué `selected`, seuls les cas `selected` s'exécutent : **les autres restent dans la liste, ignorés** (« Un autre cas est marqué selected dans le fichier Markdown » en français, « Another case is marked selected in the markdown file » sinon), au lieu d'être masqués. Leur YAML n'est pas lu non plus. Un cas à la fois `selected` et `skipped` reste ignoré et ne sélectionne rien. + +### 4. Convertisseurs YAML globaux + +TUnit lit les cas des sources de données **pendant la découverte des tests**, avant l'exécution de tout hook de classe ou de test. Un convertisseur enregistré avec `MdatConfig.AddYamlTypeConverter()` dans un `[ClassInitialize]` / `[Before(Class)]` arrive donc trop tard. Enregistrez-le dans un hook de découverte : + +```csharp +public static class Setup +{ + [Before(TestDiscovery)] + public static void RegisterConverters() + => MdatConfig.AddYamlTypeConverter(new MonConvertisseur()); +} +``` + +Le convertisseur s'applique alors à tous les fichiers `.md` du projet de test. + +### 5. Comparaison JSON : ce qui peut changer + +Le verdict d'un test (réussi ou échoué) est le même qu'avec mdAT 1.x dans tous les cas que nous avons comparés, **sauf deux comportements devenus plus stricts**. Un test qui passait grâce à eux peut maintenant échouer : + +- les nombres sont comparés exactement et non plus en `double` : `12345678901234567890` et `12345678901234567891`, ou `0.1` et `0.10000000000000001`, étaient jugés égaux et sont maintenant différents ; +- un JSON avec une propriété en double (`{"a":1,"a":2}`) est refusé, au lieu d'en ignorer une silencieusement. + +Les autres changements portent sur les messages d'écart, qui sont plus précis : + +- les valeurs répétées dans un tableau sont toutes listées (Quibble en oubliait et pouvait produire un message vide) ; +- les éléments de tableau sont appariés par valeur, pas par écriture (`1` et `1.0`), ni selon l'ordre des clés de leurs objets ; +- `allowAdditionalProperties` s'applique aussi aux objets contenus dans des tableaux ; +- le texte de l'exception change si votre code l'analysait (`MDAT.JsonAssertException` n'hérite plus d'une exception xunit) ; +- le nom d'un cas de test est maintenant `Méthode_Titre_Sous titre@fichier.md` (nom du fichier à la fin) au lieu de `Méthode@~/chemin/fichier.md_Titre_Sous titre`. Les filtres sur ces noms et l'historique de vos tests (par exemple dans Azure DevOps) doivent être mis à jour. + +Sérialisation de `System.Text.Json` : un `JsonDocument` (ou un `JsonElement`) dont la racine est une **chaîne contenant un objet ou un tableau JSON** est écrit comme cet objet ou ce tableau, ce qui permet d'attendre un objet YAML dans `data:`. C'était le comportement de mdAT 1.4. mdAT 1.5.0 et 1.5.1 l'avaient changé par erreur en chaîne, ce qui obligeait à écrire l'attendu sous forme de chaîne JSON : la 2.0 rétablit le comportement de la 1.4. Les autres chaînes (`"hello"`, `"123"`, un texte qui n'est pas du JSON valide) restent des chaînes. + +### 6. Intégration continue + +- Un projet TUnit s'exécute avec `dotnet run --project MyTests -f net9.0` (ou `dotnet test` avec le SDK .NET 10). +- La couverture de code change de configuration : voir la section 7. +- Si votre dépôt contient des fichiers binaires dont les tests lisent le contenu (PDF, images), déclarez-les dans `.gitattributes` (`*.pdf binary`) : sous Windows, la conversion automatique des fins de ligne les corrompt. + +### 7. Couverture de code : ancienne et nouvelle configuration + +Avec MSTest, la couverture reposait sur le collecteur `coverlet.collector` (ou « XPlat Code Coverage ») et sur un fichier `.runsettings`. TUnit s'exécute sur Microsoft Testing Platform, qui a sa propre extension de couverture : le `.runsettings` n'est plus lu et `coverlet.collector` ne sert plus. + +| mdAT 1.x / MSTest | mdAT 2.0 / TUnit | +|---|---| +| Paquet `coverlet.collector` | Le retirer : rien à ajouter, la couverture est fournie par TUnit. | +| `dotnet test --collect:"XPlat Code Coverage" --settings .runsettings` | `dotnet run --project MyTests -- --coverage --coverage-output-format cobertura --coverage-output coverage.cobertura.xml` | +| `.runsettings` : `cobertura` | Option `--coverage-output-format cobertura` | +| `.runsettings` : `MDAT` (limiter aux assemblies mesurées) | Fichier XML de réglages passé avec `--coverage-settings` (voir ci-dessous) | +| Résultat dans `TestResults//coverage.cobertura.xml` | `bin///TestResults/coverage.cobertura.xml`, ou le dossier donné par `--results-directory` | +| `reportgenerator -reports:.\TestResults\*\coverage.cobertura.xml ...` | Inchangé (même format cobertura) : seul le chemin de `-reports:` change | + +Pour limiter la mesure à vos assemblies (et exclure le projet de test lui-même), créez un fichier, par exemple `coverage.settings.xml` : + +```xml + + + + + + .*MonProjet\.dll$ + + + + +``` + +puis passez-le à la commande : + +``` +dotnet run --project MyTests -- --coverage --coverage-output-format cobertura --coverage-output coverage.cobertura.xml --coverage-settings coverage.settings.xml +``` + +Dans un workflow GitHub, remplacez `dotnet test --collect:"XPlat Code Coverage" --settings .runsettings` par cette commande et pointez l'étape de rapport (par exemple `irongut/CodeCoverageSummary`) vers le nouveau chemin du fichier `coverage.cobertura.xml`. Le [dotnet.yml](.github/workflows/dotnet.yml) de ce dépôt en est un exemple. + +### 8. Parallélisme : TUnit exécute les tests en parallèle + +MSTest exécute les tests l'un après l'autre par défaut. **TUnit les exécute en parallèle**. Un projet dont les tests partagent un état statique (une fabrique `static`, une base de données de test, un fichier, une variable globale) peut donc se mettre à échouer sans que mdAT ni le test n'aient changé. Retour d'expérience d'une migration de 428 tests : 228 échecs causés par une fabrique statique partagée. + +Deux solutions : + +- isoler l'état (une instance par test, par exemple avec `[Before(Test)]`) ; +- ou désactiver le parallélisme pour tout le projet de test, avec un attribut d'assembly : + +```csharp +[assembly: NotInParallel] +``` + +Pour ne sérialiser que certains tests, placez `[NotInParallel]` sur la classe ou la méthode (équivalent de `[DoNotParallelize]`). + +--- + # mdAT - Markdown Auto-Tests -``mdAT`` is a simple library that replaces unit and integration tests. With ``mdAT``, developers can write unit tests in the traditional way, but without the need to add many test data from JSON, XML, YAML files, or use manual ``[InlideData]`` with escaped JSON object. +``mdAT`` is a simple library that replaces unit and integration tests. With ``mdAT``, developers can write unit tests in the traditional way, but without the need to add many test data from JSON, XML, YAML files, or use manual ``[Arguments]`` with escaped JSON object. + +| Package | Content | +|---|---| +| `MDAT` | The package to install: it brings `MDAT.TUnit` and `MDAT.Core`. | +| `MDAT.TUnit` | The data sources for [TUnit](https://tunit.dev): ``[MarkdownDataSource]`` and ``[YamlFileDataSource]``. | +| `MDAT.Core` | Markdown/YAML parsing, ``Verify.Assert()`` and JSON comparison. Independent from any test framework. | + +Targets: .NET 8, .NET 9 and .NET 10. To use ``mdAT``, follow these steps: -1. Create a normal MSTestV2 with `[TestClass]`, `[TestMethod]`. -1. Code your unit test in the traditional way using `Moq`, if needed, then call your method you want to test. +1. Create a [TUnit](https://tunit.dev) test project (`Exe`) and add the ``MDAT`` package. +1. Create a normal test with `[Test]`. +1. Code your unit test in the traditional way (using `Moq`, if needed), then call your method you want to test. 1. Add all your desired parameters to your test method, theses will be exposed to be replaced by ``mdAT`` markdown test file. -1. Add `[MarkdownTest("~/Tests/{method}.md")]` with your `Tests` folder as cases repository (let it being empty in first place). +1. Add `[MarkdownDataSource("~/Tests/{method}.md")]` with your `Tests` folder as cases repository (let it being empty in first place). 1. Ensure to surround your tested method with `Verify.Assert()`, it will automatically validate the JSON expected result, even if it is an exception. 1. Your `.md` test file will be autogenerated during the first run. 1. Edit your `.md` file as you wish. 1. Enjoy! +Each case of the markdown file is its own test in the test explorer, and its location is its title in the markdown file (or the start of its document in a YAML file), not the C# method shared by all the cases. TUnit hands this location to the test explorer, which uses it for "Go to source" (double click). Arguments are built from the YAML and handed as is to your method: no intermediate serialization, whatever their type (`DateOnly`, `Dictionary`, `JsonDocument`, ...). + +### Link to the C# test in the markdown file + +Each markdown file keeps, right under its title, a link to the C# test that reads it, with the line (clickable in VS Code and on GitHub; in Visual Studio, with the [extension](#visual-studio-extension-clickable-link) below): + +```md +# Md1 + +> C# : [MarkdownTestAttributeTests.cs:49](../MarkdownTestAttributeTests.cs#L49) +``` + +mdAT adds this line and corrects it when the test moves to another line, each time the tests are discovered: + +- the link is relative to the markdown file, so it is valid for the whole team; +- **the file is written again only if the line changes** (same content, no write: the modification time of the file does not move); +- the rest of the file is kept as it is (encoding, BOM, line endings); +- the `` comment identifies the line managed by mdAT: you can write around it without losing it. A file shared by several tests keeps one line per test; +- a read-only file, or a file whose C# file cannot be found at that path, is left as it is, without any error; +- on the first run, **all your existing markdown files get this line**: this is a one-time change to review and commit. After that, a file only changes when its test moves to another line. + +#### Visual Studio extension: clickable link + +The Visual Studio editor does not follow the relative links of a markdown file. The `MDAT.VisualStudio.vsix` extension fixes this: the `> C# :` line is underlined like a link and **Ctrl+click opens the C# file at the line of the test**. Only the line managed by mdAT (the one with the `` comment) is concerned, the other links of the file are left alone. + +- File: [`dist/MDAT.VisualStudio.vsix`](dist/MDAT.VisualStudio.vsix) in this repository (also built by continuous integration). +- **F7 goes from one file to the other**: in a `.md`, F7 ("View Code") opens the C# test (the link of the line of the cursor, else the first one of the file); in a C# test method, F7 opens the `.md` that carries its `` marker. The cursor can be in the body, in the attributes or in the documentation of the method. The `.md` only has its marker after the tests have been discovered once: until then, F7 says so in the status bar. Anywhere else, F7 keeps its usual behavior. +- Installation: close Visual Studio, then double-click the file. Tried with Visual Studio 2026; also declared for 2022 (x64 and ARM64), untried. **Copy the file out of a folder synchronized by OneDrive before installing it**: the installer fails on such files. +- Build: `msbuild src\MDAT.VisualStudio\MDAT.VisualStudio.csproj -restore -p:Configuration=Release` (MSBuild of Visual Studio; this project is not in `MDAT.slnx`). + ````csharp -[TestClass] +using MDAT; + public class Calc { - [TestMethod] - [MarkdownTest("~/Tests/{method}.md")] + [Test] + [MarkdownDataSource("~/Tests/{method}.md")] public async Task Add(int val1, int val2, string expected) { _ = await Verify.Assert(() => Task.FromResult(Add(val1, val2)), expected); @@ -133,4 +425,215 @@ expected: `````` ``````` -Using `mdAT`, analysts and developers can work together with a unique source of tests, and maintainers can edit and validate all tests locally or on a build server. \ No newline at end of file +Using `mdAT`, analysts and developers can work together with a unique source of tests, and maintainers can edit and validate all tests locally or on a build server. + +## Case categories and properties + +A case can have categories and properties, the TUnit ones (they are used to filter the tests and to group them in the test explorer): + +- **for one case**: `name=value` pairs in the line of its code fence, next to `skipped` and `selected`; +- **for the whole file**: `tags:` in the YAML header (front matter) of the markdown file. + +````md +--- +tags: + category: Billing + area: Taxes +--- +# Add + +## Case 1 + +``````yaml category=Smoke,Fast priority=High +a: 1 +b: 2 +expected: 3 +`````` +```` + +- `category` gives **categories**; any other name (`area`, `priority`) gives a **property**. +- Here the case has the categories `Billing`, `Smoke` and `Fast`, and the properties `area=Taxes` and `priority=High`. +- The categories of the file and the ones of the case **add up**. For a property, the one of the case **replaces** the one of the file with the same name. +- Several categories are separated by commas. In the header, a YAML list works too (`env: [dev, test]` gives two `env` properties). A value with spaces is written between quotes (`category="Very slow"`, `owner="Billing team"`). +- An ignored case (`skipped`) keeps its categories and properties. +- Two cases with exactly the same name and different properties get no property (they cannot be told apart): give them different titles. + +## Verification step options + +Each `verify` item accepts: + +| Option | Default | Purpose | +|---|---|---| +| `type` | `match` | Kind of verification (only `match` exists). | +| `jsonPath` | `$` | Part of the result to compare. Ends with `.length()` to compare a size. | +| `data` | | Expected value. | +| `allowAdditionalProperties` | `false` | Ignores properties found in the result but not in `data`. | +| `ignoreArrayOrder` | `false` | Two arrays are equal when they hold the same values, in any order (duplicates are counted). Applies at every level. | + +```yaml +expected: + verify: + - type: match + jsonPath: $.items + ignoreArrayOrder: true + allowAdditionalProperties: true + data: + - A: second + - A: first +``` + +## JSON comparison + +The comparison is done by `MDAT.Core` (no dependency on xunit or Quibble): + +- numbers are compared exactly whatever their spelling (`1`, `1.0` and `1e0` are equal, `12345678901234567890` and `12345678901234567891` are different); +- array items are paired by value: an item replacing another one is compared in depth (`$.items[1].name`), an extra or missing item is listed with its index; +- a duplicated property in a JSON document (`{"a":1,"a":2}`) is rejected instead of silently ignoring one of them; +- the difference is described in `JsonAssertException` (`DiffMessages`, `UserMessage`). + +## Migrating to mdAT 2.0 + +mdAT 2.0 drops MSTest in favour of TUnit. Projects staying on MSTest can keep using mdAT 1.x. + +### 1. The test project + +- Target **.NET 8 or later** (.NET 6 and .NET 7 are no longer supported). +- Add `Exe`: a TUnit project is an executable. +- Remove `Microsoft.NET.Test.Sdk`, `MSTest.TestAdapter`, `MSTest.TestFramework` and `coverlet.collector`. +- Update the `MDAT` package to 2.0 (it now brings `MDAT.TUnit` and `MDAT.Core`) and add `TUnit`. + +```xml + + + Exe + net9.0 + + + + + + +``` + +The `.md` files do not change and stay at the same paths (`~/` still means the root of the test project). + +### 2. Test code + +| mdAT 1.x / MSTest | mdAT 2.0 / TUnit | +|---|---| +| `[TestClass]` | (remove) | +| `[TestMethod]` | `[Test]` | +| `[MarkdownTest("~/Tests/{method}.md")]` | `[MarkdownDataSource("~/Tests/{method}.md")]` (the old name still works, see below) | +| `[YamlFileData("~/Tests/x.yaml")]` | `[YamlFileDataSource("~/Tests/x.yaml")]` (same) | +| `[DataRow(...)]` | `[Arguments(...)]` | +| `[DynamicData(...)]` | `[MethodDataSource(...)]` | +| `[TestInitialize]` / `[TestCleanup]` | `[Before(Test)]` / `[After(Test)]` | +| `[ClassInitialize]` | `[Before(Class)]` | +| `[DoNotParallelize]` | `[NotInParallel]` | +| `Assert.AreEqual(a, b)` | `await Assert.That(b).IsEqualTo(a)` | + +`Verify.Assert()`, `Expected`, `ObjectOrException`, `ReturnsOrThrows` and file inclusion (`!include`) work unchanged. Types stay in the `MDAT` namespace. + +The `[MarkdownTest]` and `[YamlFileData]` names keep working in `MDAT.TUnit`: they are exact aliases of `[MarkdownDataSource]` and `[YamlFileDataSource]`, without any warning. Both names are valid: your test files only have to change to go from MSTest to TUnit (`[TestClass]`, `[TestMethod]`, etc.), not because of mdAT. + +### 3. API changes + +| mdAT 1.x | mdAT 2.0 | +|---|---| +| `MarkdownTestAttribute` | `MarkdownDataSourceAttribute` (`MDAT.TUnit`); reading the cases is done by `MarkdownTestSource.GetCases()` (`MDAT.Core`). `MarkdownTestAttribute` remains available, as an alias of `MarkdownDataSourceAttribute`. | +| `YamlFileDataAttribute` | `YamlFileDataSourceAttribute`; reading done by `YamlFileTestSource.GetCases()`. `YamlFileDataAttribute` remains available, as an alias of `YamlFileDataSourceAttribute`. | +| `InternalTestFailureException` (MSTest) | `MdatTestDefinitionException` | +| `Quibble.Xunit.JsonAssertException`, `JsonDiffConfig`, `JsonAssert` | `MDAT.JsonAssertException`, `MDAT.JsonDiffConfig`, `MDAT.JsonAssert` | + +The `Quibble.Xunit` (hence xunit), `MSTest.TestFramework`, `System.Data.SqlClient` and `System.Text.RegularExpressions` dependencies are removed, which removes their known transitive vulnerabilities. `Moq` remains a dependency of `MDAT.Core` for the `ReturnsOrThrows` methods. + +A case marked `skipped` in the markdown file (`` ``````yaml skipped ``) **is no longer missing from the list**: it shows in the test explorer as **ignored**, with a reason in the interface language of the computer ("Marked as skipped in the markdown file", or "Marqué comme ignoré (skipped) dans le fichier Markdown" on a French one). Its YAML is not read (it may be unfinished) and the test method is not called. Likewise, when a case is marked `selected`, only the `selected` cases run: **the others stay in the list, ignored** ("Another case is marked selected in the markdown file", or "Un autre cas est marqué selected dans le fichier Markdown" on a French computer), instead of being hidden. Their YAML is not read either. A case that is both `selected` and `skipped` stays ignored and selects nothing. + +### 4. Global YAML converters + +TUnit reads the cases of data sources **while discovering the tests**, before any class or test hook runs. A converter registered with `MdatConfig.AddYamlTypeConverter()` in a `[ClassInitialize]` / `[Before(Class)]` is therefore too late. Register it in a discovery hook: + +```csharp +public static class Setup +{ + [Before(TestDiscovery)] + public static void RegisterConverters() + => MdatConfig.AddYamlTypeConverter(new MyConverter()); +} +``` + +The converter then applies to every `.md` file of the test project. + +### 5. JSON comparison: what may change + +A test verdict (pass or fail) is the same as with mdAT 1.x in every case we compared, **except for two behaviours that became stricter**. A test that was passing thanks to them may now fail: + +- numbers are compared exactly instead of as `double`: `12345678901234567890` and `12345678901234567891`, or `0.1` and `0.10000000000000001`, were considered equal and are now different; +- a JSON document with a duplicated property (`{"a":1,"a":2}`) is rejected, instead of silently ignoring one of the values. + +The other changes concern the difference messages, which are more precise: + +- repeated values in an array are all listed (Quibble dropped some and could even produce an empty message); +- array items are paired by value, not by spelling (`1` and `1.0`) nor by the key order of their objects; +- `allowAdditionalProperties` also applies to objects held in arrays; +- the exception text changes if your code parsed it (`MDAT.JsonAssertException` no longer derives from an xunit exception); +- the name of a test case is now `Method_Title_Sub title@file.md` (file name last) instead of `Method@~/path/file.md_Title_Sub title`. Filters on these names and the history of your tests (for example in Azure DevOps) must be updated. + +`System.Text.Json` serialization: a `JsonDocument` (or a `JsonElement`) whose root is a **string holding a JSON object or array** is written as that object or array, so a YAML object can be expected in `data:`. This was the behaviour of mdAT 1.4. mdAT 1.5.0 and 1.5.1 changed it by mistake into a string, which forced the expected value to be written as a JSON string: 2.0 restores the 1.4 behaviour. Other strings (`"hello"`, `"123"`, text that is not valid JSON) stay strings. + +### 6. Continuous integration + +- A TUnit project runs with `dotnet run --project MyTests -f net9.0` (or `dotnet test` with the .NET 10 SDK). +- Code coverage uses a different configuration: see section 7. +- If your repository holds binary files whose content is read by tests (PDF, images), declare them in `.gitattributes` (`*.pdf binary`): on Windows, automatic line ending conversion corrupts them. + +### 7. Code coverage: old and new configuration + +With MSTest, coverage relied on the `coverlet.collector` collector (or "XPlat Code Coverage") and on a `.runsettings` file. TUnit runs on Microsoft Testing Platform, which has its own coverage extension: the `.runsettings` file is no longer read and `coverlet.collector` is no longer needed. + +| mdAT 1.x / MSTest | mdAT 2.0 / TUnit | +|---|---| +| `coverlet.collector` package | Remove it: nothing to add, coverage is provided by TUnit. | +| `dotnet test --collect:"XPlat Code Coverage" --settings .runsettings` | `dotnet run --project MyTests -- --coverage --coverage-output-format cobertura --coverage-output coverage.cobertura.xml` | +| `.runsettings`: `cobertura` | `--coverage-output-format cobertura` option | +| `.runsettings`: `MDAT` (limit to the measured assemblies) | XML settings file passed with `--coverage-settings` (see below) | +| Result in `TestResults//coverage.cobertura.xml` | `bin///TestResults/coverage.cobertura.xml`, or the folder given by `--results-directory` | +| `reportgenerator -reports:.\TestResults\*\coverage.cobertura.xml ...` | Unchanged (same cobertura format): only the `-reports:` path changes | + +To limit the measure to your assemblies (and exclude the test project itself), create a file, for example `coverage.settings.xml`: + +```xml + + + + + + .*MyProject\.dll$ + + + + +``` + +then pass it to the command: + +``` +dotnet run --project MyTests -- --coverage --coverage-output-format cobertura --coverage-output coverage.cobertura.xml --coverage-settings coverage.settings.xml +``` + +In a GitHub workflow, replace `dotnet test --collect:"XPlat Code Coverage" --settings .runsettings` with this command and point the report step (for example `irongut/CodeCoverageSummary`) to the new path of the `coverage.cobertura.xml` file. This repository's [dotnet.yml](.github/workflows/dotnet.yml) is an example. + +### 8. Parallelism: TUnit runs tests in parallel + +MSTest runs tests one after the other by default. **TUnit runs them in parallel**. A project whose tests share static state (a `static` factory, a test database, a file, a global variable) may therefore start failing even though neither mdAT nor the tests changed. Feedback from a 428-test migration: 228 failures caused by a shared static factory. + +Two solutions: + +- isolate the state (one instance per test, for example with `[Before(Test)]`); +- or turn parallelism off for the whole test project, with an assembly attribute: + +```csharp +[assembly: NotInParallel] +``` + +To serialize only some tests, put `[NotInParallel]` on the class or the method (equivalent of `[DoNotParallelize]`). diff --git a/dist/MDAT.VisualStudio.vsix b/dist/MDAT.VisualStudio.vsix new file mode 100644 index 0000000..9dad8be Binary files /dev/null and b/dist/MDAT.VisualStudio.vsix differ diff --git a/global.json b/global.json new file mode 100644 index 0000000..062772b --- /dev/null +++ b/global.json @@ -0,0 +1,9 @@ +{ + "sdk": { + "version": "10.0.102", + "rollForward": "latestFeature" + }, + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/src/.runsettings b/src/.runsettings deleted file mode 100644 index 795c9a9..0000000 --- a/src/.runsettings +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - cobertura - MDAT - - - - - \ No newline at end of file diff --git a/src/MDAT/.editorconfig b/src/MDAT.Core/.editorconfig similarity index 100% rename from src/MDAT/.editorconfig rename to src/MDAT.Core/.editorconfig diff --git a/src/MDAT/.vscode/launch.json b/src/MDAT.Core/.vscode/launch.json similarity index 100% rename from src/MDAT/.vscode/launch.json rename to src/MDAT.Core/.vscode/launch.json diff --git a/src/MDAT/.vscode/tasks.json b/src/MDAT.Core/.vscode/tasks.json similarity index 100% rename from src/MDAT/.vscode/tasks.json rename to src/MDAT.Core/.vscode/tasks.json diff --git a/src/MDAT/Expected.cs b/src/MDAT.Core/Expected.cs similarity index 100% rename from src/MDAT/Expected.cs rename to src/MDAT.Core/Expected.cs diff --git a/src/MDAT.Core/JsonAssert.cs b/src/MDAT.Core/JsonAssert.cs new file mode 100644 index 0000000..5f9be7a --- /dev/null +++ b/src/MDAT.Core/JsonAssert.cs @@ -0,0 +1,75 @@ +using MDAT.JsonCompare; + +namespace MDAT; + +/// Options of a JSON comparison. +public sealed class JsonDiffConfig +{ + public JsonDiffConfig(bool allowAdditionalProperties, bool ignoreArrayOrder = false) + { + AllowAdditionalProperties = allowAdditionalProperties; + IgnoreArrayOrder = ignoreArrayOrder; + } + + /// When true, properties found only in the actual JSON are not reported. + public bool AllowAdditionalProperties { get; } + + /// When true, two arrays are equal if they hold the same values, in any order (duplicates are counted). + public bool IgnoreArrayOrder { get; } + + public override string ToString() => $"AllowAdditionalProperties={AllowAdditionalProperties}, IgnoreArrayOrder={IgnoreArrayOrder}"; +} + +/// Thrown when two JSON documents differ. +public sealed class JsonAssertException : Exception +{ + public JsonAssertException(IReadOnlyList diffMessages, string expected, string actual) + : base(BuildMessage(diffMessages, expected, actual)) + { + DiffMessages = diffMessages; + UserMessage = BuildUserMessage(diffMessages); + } + + /// One message per difference. + public IReadOnlyList DiffMessages { get; } + + /// The differences without the expected and actual documents. + public string UserMessage { get; } + + private static string BuildUserMessage(IReadOnlyList diffMessages) + { + if (diffMessages.Count == 1) + { + return diffMessages[0]; + } + + var numbered = diffMessages.Select((message, index) => $"# {index + 1}: {message}"); + return $"Found {diffMessages.Count} differences.\n{string.Join("\n", numbered)}"; + } + + private static string BuildMessage(IReadOnlyList diffMessages, string expected, string actual) + { + return $"{BuildUserMessage(diffMessages)}{Environment.NewLine}Expected: {expected}{Environment.NewLine}Actual: {actual}"; + } +} + +/// Asserts that two JSON documents are equal, with a readable description of the differences. +public static class JsonAssert +{ + public static void Equal(string expected, string actual) => EqualOverrideDefault(expected, actual, new JsonDiffConfig(false)); + + public static void EqualOverrideDefault(string expected, string actual, JsonDiffConfig config) + { + ArgumentNullException.ThrowIfNull(config); + + var actualTree = JsonTree.Parse(actual, "leftJsonString"); + var expectedTree = JsonTree.Parse(expected, "rightJsonString"); + + var differences = JsonComparer.Compare(expectedTree, actualTree, config); + + if (differences.Count > 0) + { + throw new JsonAssertException(differences, expected, actual); + } + } +} diff --git a/src/MDAT.Core/JsonCompare/ArrayMatcher.cs b/src/MDAT.Core/JsonCompare/ArrayMatcher.cs new file mode 100644 index 0000000..dbaacc8 --- /dev/null +++ b/src/MDAT.Core/JsonCompare/ArrayMatcher.cs @@ -0,0 +1,71 @@ +namespace MDAT.JsonCompare; + +/// +/// Pairs the items of two arrays that are equal. The longest common run of items is kept first, then both +/// remaining sides (before and after the run) are matched independently, the same strategy as difflib. +/// +internal static class ArrayMatcher +{ + /// Returns, for each item of , the index of its twin in or -1. + public static int[] MatchInOrder(IReadOnlyList left, IReadOnlyList right, Func areEqual) + { + var matches = new int[left.Count]; + Array.Fill(matches, -1); + + Match(left, right, areEqual, 0, left.Count, 0, right.Count, matches); + + return matches; + } + + private static void Match(IReadOnlyList left, IReadOnlyList right, Func areEqual, + int leftStart, int leftEnd, int rightStart, int rightEnd, int[] matches) + { + if (leftStart >= leftEnd || rightStart >= rightEnd) + { + return; + } + + var bestLength = 0; + var bestLeftEnd = -1; + var bestRightEnd = -1; + + // previous[c] / current[c] = length of the common run ending at the previous / current left item and right item c - 1 + var previous = new int[rightEnd - rightStart + 1]; + var current = new int[rightEnd - rightStart + 1]; + + for (var i = leftStart; i < leftEnd; i++) + { + for (var j = rightStart; j < rightEnd; j++) + { + var column = j - rightStart + 1; + current[column] = areEqual(left[i], right[j]) ? previous[column - 1] + 1 : 0; + + if (current[column] > bestLength) + { + bestLength = current[column]; + bestLeftEnd = i; + bestRightEnd = j; + } + } + + (previous, current) = (current, previous); + Array.Clear(current); + } + + if (bestLength == 0) + { + return; + } + + var leftRunStart = bestLeftEnd - bestLength + 1; + var rightRunStart = bestRightEnd - bestLength + 1; + + for (var k = 0; k < bestLength; k++) + { + matches[leftRunStart + k] = rightRunStart + k; + } + + Match(left, right, areEqual, leftStart, leftRunStart, rightStart, rightRunStart, matches); + Match(left, right, areEqual, bestLeftEnd + 1, leftEnd, bestRightEnd + 1, rightEnd, matches); + } +} diff --git a/src/MDAT.Core/JsonCompare/JsonComparer.cs b/src/MDAT.Core/JsonCompare/JsonComparer.cs new file mode 100644 index 0000000..cfd18db --- /dev/null +++ b/src/MDAT.Core/JsonCompare/JsonComparer.cs @@ -0,0 +1,245 @@ +namespace MDAT.JsonCompare; + +/// Compares an expected JSON tree with an actual one and lists the differences as readable messages. +internal sealed class JsonComparer +{ + private readonly JsonDiffConfig _config; + + private JsonComparer(JsonDiffConfig config) + { + _config = config; + } + + public static IReadOnlyList Compare(JsonTree expected, JsonTree actual, JsonDiffConfig config) + { + var messages = new List(); + new JsonComparer(config).Walk(expected, actual, "$", messages); + return messages; + } + + private void Walk(JsonTree expected, JsonTree actual, string path, List messages) + { + if (TypeClass(expected) != TypeClass(actual)) + { + messages.Add($"Type mismatch at {path}.\nExpected {JsonTreeText.Describe(expected)} but was {JsonTreeText.Describe(actual)}."); + return; + } + + switch (expected.Kind) + { + case JsonKind.Null: + break; + case JsonKind.True: + case JsonKind.False: + if (expected.Kind != actual.Kind) + { + messages.Add($"Boolean value mismatch at {path}.\nExpected {(expected.Kind == JsonKind.True ? "true" : "false")} but was {(actual.Kind == JsonKind.True ? "true" : "false")}."); + } + break; + case JsonKind.Number: + if (expected.NumberKey != actual.NumberKey) + { + messages.Add($"Number value mismatch at {path}.\nExpected {expected.Text} but was {actual.Text}."); + } + break; + case JsonKind.String: + if (!string.Equals(expected.Text, actual.Text, StringComparison.Ordinal)) + { + messages.Add($"String value mismatch at {path}.\nExpected {expected.Text} but was {actual.Text}."); + } + break; + case JsonKind.Object: + WalkObject(expected, actual, path, messages); + break; + default: + WalkArray(expected, actual, path, messages); + break; + } + } + + private void WalkObject(JsonTree expected, JsonTree actual, string path, List messages) + { + var missing = expected.Properties.Where(p => actual.Property(p.Key) is null).ToList(); + var additional = _config.AllowAdditionalProperties + ? new List>() + : actual.Properties.Where(p => expected.Property(p.Key) is null).ToList(); + + var sections = new List(); + + if (missing.Count > 0) + { + sections.Add(Section(missing.Count == 1 ? "Missing property:" : "Missing properties:", + missing.Select(p => $" - '{p.Key}' ({JsonTreeText.TypeName(p.Value)})"))); + } + + if (additional.Count > 0) + { + sections.Add(Section(additional.Count == 1 ? "Additional property:" : "Additional properties:", + additional.Select(p => $" + '{p.Key}' ({JsonTreeText.TypeName(p.Value)})"))); + } + + if (sections.Count > 0) + { + messages.Add($"Object mismatch at {path}.\n{string.Join("\n", sections)}"); + } + + foreach (var property in expected.Properties) + { + var other = actual.Property(property.Key); + + if (other is not null) + { + Walk(property.Value, other, JsonTreeText.PropertyPath(path, property.Key), messages); + } + } + } + + private void WalkArray(JsonTree expected, JsonTree actual, string path, List messages) + { + List missing; + List additional; + + if (_config.IgnoreArrayOrder) + { + (missing, additional) = MatchUnordered(expected.Items, actual.Items); + } + else + { + var matches = ArrayMatcher.MatchInOrder(expected.Items, actual.Items, Equivalent); + var matchedActual = new HashSet(matches.Where(m => m >= 0)); + + missing = Enumerable.Range(0, expected.Items.Count).Where(i => matches[i] < 0).ToList(); + additional = Enumerable.Range(0, actual.Items.Count).Where(j => !matchedActual.Contains(j)).ToList(); + + // The same positions are left over on both sides: the items at those positions replace each other + if (missing.Count > 0 && missing.SequenceEqual(additional)) + { + foreach (var index in missing) + { + Walk(expected.Items[index], actual.Items[index], $"{path}[{index}]", messages); + } + + return; + } + } + + var sections = new List(); + + if (missing.Count > 0) + { + sections.Add(Section(missing.Count == 1 ? "Missing item:" : "Missing items:", + missing.Select(i => $" - [{i}]: {JsonTreeText.RenderItem(expected.Items[i])}"))); + } + + if (additional.Count > 0) + { + sections.Add(Section(additional.Count == 1 ? "Additional item:" : "Additional items:", + additional.Select(i => $" + [{i}]: {JsonTreeText.RenderItem(actual.Items[i])}"))); + } + + if (sections.Count > 0) + { + messages.Add($"Array mismatch at {path}.\n{string.Join("\n", sections)}"); + } + } + + /// + /// True when satisfies under the configuration: + /// the same comparison as , without building any message. + /// + private bool Equivalent(JsonTree expected, JsonTree actual) + { + if (TypeClass(expected) != TypeClass(actual)) + { + return false; + } + + switch (expected.Kind) + { + case JsonKind.True: + case JsonKind.False: + return expected.Kind == actual.Kind; + case JsonKind.Number: + return expected.NumberKey == actual.NumberKey; + case JsonKind.String: + return string.Equals(expected.Text, actual.Text, StringComparison.Ordinal); + case JsonKind.Object: + if (!_config.AllowAdditionalProperties && expected.Properties.Count != actual.Properties.Count) + { + return false; + } + + return expected.Properties.All(p => actual.Property(p.Key) is { } other && Equivalent(p.Value, other)); + case JsonKind.Array: + if (expected.Items.Count != actual.Items.Count) + { + return false; + } + + if (_config.IgnoreArrayOrder) + { + var (missing, _) = MatchUnordered(expected.Items, actual.Items); + return missing.Count == 0; + } + + return expected.Items.Zip(actual.Items, Equivalent).All(equivalent => equivalent); + default: + return true; + } + } + + /// + /// Pairs every expected item with a distinct equivalent actual item (maximum bipartite matching, so a + /// complete pairing is found whenever one exists) and returns the indexes left over on each side. + /// + private (List Missing, List Additional) MatchUnordered(IReadOnlyList expected, IReadOnlyList actual) + { + var compatible = new bool[expected.Count, actual.Count]; + + for (var i = 0; i < expected.Count; i++) + { + for (var j = 0; j < actual.Count; j++) + { + compatible[i, j] = Equivalent(expected[i], actual[j]); + } + } + + var expectedOfActual = new int[actual.Count]; + Array.Fill(expectedOfActual, -1); + + for (var i = 0; i < expected.Count; i++) + { + TryAssign(i, new bool[actual.Count]); + } + + var pairedExpected = new HashSet(expectedOfActual.Where(i => i >= 0)); + + return (Enumerable.Range(0, expected.Count).Where(i => !pairedExpected.Contains(i)).ToList(), + Enumerable.Range(0, actual.Count).Where(j => expectedOfActual[j] < 0).ToList()); + + bool TryAssign(int i, bool[] visited) + { + for (var j = 0; j < actual.Count; j++) + { + if (!compatible[i, j] || visited[j]) + { + continue; + } + + visited[j] = true; + + if (expectedOfActual[j] < 0 || TryAssign(expectedOfActual[j], visited)) + { + expectedOfActual[j] = i; + return true; + } + } + + return false; + } + } + + private static string Section(string title, IEnumerable lines) => $"{title}\n{string.Join("\n", lines)}"; + + private static int TypeClass(JsonTree value) => value.Kind is JsonKind.True or JsonKind.False ? (int)JsonKind.True : (int)value.Kind; +} diff --git a/src/MDAT.Core/JsonCompare/JsonTree.cs b/src/MDAT.Core/JsonCompare/JsonTree.cs new file mode 100644 index 0000000..529d1cb --- /dev/null +++ b/src/MDAT.Core/JsonCompare/JsonTree.cs @@ -0,0 +1,136 @@ +using System.Numerics; +using System.Text.Json; + +namespace MDAT.JsonCompare; + +internal enum JsonKind { Null, True, False, Number, String, Array, Object } + +/// +/// Minimal immutable JSON tree used by the comparison engine. +/// Numbers keep their raw text (used in messages) next to an exact canonical form (used for equality). +/// +internal sealed class JsonTree +{ + private Dictionary? _propertiesByName; + + private JsonTree(JsonKind kind) { Kind = kind; } + + public JsonKind Kind { get; } + + /// Raw text of a number, or the content of a string. + public string Text { get; private init; } = ""; + + /// Exact canonical form of a number: 1, 1.0, 1e0 and 10e-1 share the same key, -0 equals 0. + public string NumberKey { get; private init; } = ""; + + public IReadOnlyList Items { get; private init; } = Array.Empty(); + + public IReadOnlyList> Properties { get; private init; } = Array.Empty>(); + + public bool IsScalar => Kind != JsonKind.Array && Kind != JsonKind.Object; + + public static JsonTree Parse(string? json, string parameterName) + { + try + { + ArgumentNullException.ThrowIfNull(json); + + using var document = JsonDocument.Parse(json); + return From(document.RootElement); + } + catch (Exception ex) when (ex is JsonException or ArgumentNullException) + { + throw new ArgumentException($"Failed to parse the value passed for {parameterName} as a JSON value. See inner exception for details.", parameterName, ex); + } + } + + private static JsonTree From(JsonElement element) + { + switch (element.ValueKind) + { + case JsonValueKind.Null: + return new JsonTree(JsonKind.Null); + case JsonValueKind.True: + return new JsonTree(JsonKind.True); + case JsonValueKind.False: + return new JsonTree(JsonKind.False); + case JsonValueKind.Number: + var raw = element.GetRawText(); + return new JsonTree(JsonKind.Number) { Text = raw, NumberKey = CanonicalNumber(raw) }; + case JsonValueKind.String: + return new JsonTree(JsonKind.String) { Text = element.GetString()! }; + case JsonValueKind.Array: + return new JsonTree(JsonKind.Array) { Items = element.EnumerateArray().Select(From).ToList() }; + case JsonValueKind.Object: + var seen = new HashSet(StringComparer.Ordinal); + var properties = new List>(); + foreach (var property in element.EnumerateObject()) + { + // Ambiguous: which value would be the right one? Do not silently pick one. + if (!seen.Add(property.Name)) + { + throw new JsonException($"Duplicate property '{property.Name}'."); + } + + properties.Add(new(property.Name, From(property.Value))); + } + return new JsonTree(JsonKind.Object) { Properties = properties }; + default: + throw new JsonException($"Unsupported JSON value kind '{element.ValueKind}'."); + } + } + + /// Canonical text of a JSON number (already validated by the JSON reader), exact whatever its size. + private static string CanonicalNumber(string raw) + { + var text = raw; + var negative = text[0] == '-'; + + if (negative) + { + text = text[1..]; + } + + BigInteger exponent = 0; + var exponentIndex = text.IndexOfAny(new[] { 'e', 'E' }); + + if (exponentIndex >= 0) + { + exponent = BigInteger.Parse(text[(exponentIndex + 1)..], System.Globalization.CultureInfo.InvariantCulture); + text = text[..exponentIndex]; + } + + var dotIndex = text.IndexOf('.'); + var digits = text; + + if (dotIndex >= 0) + { + digits = text[..dotIndex] + text[(dotIndex + 1)..]; + exponent -= text.Length - dotIndex - 1; + } + + digits = digits.TrimStart('0'); + + if (digits.Length == 0) + { + return "0"; + } + + var significant = digits.TrimEnd('0'); + exponent += digits.Length - significant.Length; + + return $"{(negative ? "-" : "")}{significant}E{exponent}"; + } + + public JsonTree? Property(string name) + { + if (Properties.Count == 0) + { + return null; + } + + _propertiesByName ??= Properties.ToDictionary(p => p.Key, p => p.Value, StringComparer.Ordinal); + + return _propertiesByName.GetValueOrDefault(name); + } +} diff --git a/src/MDAT.Core/JsonCompare/JsonTreeText.cs b/src/MDAT.Core/JsonCompare/JsonTreeText.cs new file mode 100644 index 0000000..7343143 --- /dev/null +++ b/src/MDAT.Core/JsonCompare/JsonTreeText.cs @@ -0,0 +1,110 @@ +using System.Text; + +namespace MDAT.JsonCompare; + +/// Human readable descriptions of JSON values used in assertion messages. +internal static class JsonTreeText +{ + public static string Describe(JsonTree value) => value.Kind switch + { + JsonKind.Null => "null", + JsonKind.True => "the boolean true", + JsonKind.False => "the boolean false", + JsonKind.Number => $"the number {value.Text}", + JsonKind.String => $"the string {value.Text}", + JsonKind.Array => value.Items.Count switch + { + 0 => "an empty array", + 1 => "an array with 1 item", + var n => $"an array with {n} items", + }, + _ => "an object", + }; + + /// Type name shown next to a property in "Missing/Additional property" sections. + public static string TypeName(JsonTree value) => value.Kind switch + { + JsonKind.Null => "null", + JsonKind.True or JsonKind.False => "bool", + JsonKind.Number => "number", + JsonKind.String => "string", + JsonKind.Array => "array", + _ => "object", + }; + + /// Value shown for an array item: scalars are described, containers are expanded one level. + public static string RenderItem(JsonTree value) + { + if (value.IsScalar) + { + return Describe(value); + } + + var text = new StringBuilder(); + + if (value.Kind == JsonKind.Array) + { + text.Append('['); + foreach (var item in value.Items) + { + text.Append("\n ").Append(Inline(item)); + } + text.Append("\n]"); + } + else + { + text.Append('{'); + foreach (var property in value.Properties) + { + text.Append("\n '").Append(property.Key).Append("': ").Append(Inline(property.Value)); + } + text.Append("\n}"); + } + + return text.ToString(); + } + + private static string Inline(JsonTree value) => value.Kind switch + { + JsonKind.Null => "null", + JsonKind.True => "true", + JsonKind.False => "false", + JsonKind.Number => value.Text, + JsonKind.String => "'" + value.Text + "'", + JsonKind.Array => value.Items.Count switch + { + 0 => "[]", + 1 => "[ 1 item ]", + var n => $"[ {n} items ]", + }, + _ => value.Properties.Count switch + { + 0 => "{}", + 1 => "{ 1 property }", + var n => $"{{ {n} properties }}", + }, + }; + + public static string PropertyPath(string parent, string property) + { + return IsPlainName(property) ? $"{parent}.{property}" : $"{parent}['{property.Replace("\\", "\\\\").Replace("'", "\\'")}']"; + } + + private static bool IsPlainName(string name) + { + if (name.Length == 0) + { + return false; + } + + foreach (var c in name) + { + if (!(c is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9')) + { + return false; + } + } + + return true; + } +} diff --git a/src/MDAT.Core/MDAT.Core.csproj b/src/MDAT.Core/MDAT.Core.csproj new file mode 100644 index 0000000..8a86074 --- /dev/null +++ b/src/MDAT.Core/MDAT.Core.csproj @@ -0,0 +1,54 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + MDAT + MDAT.Core + False + MDAT.Core + false + anisite + MESS + https://github.com/MTESSDev/mdAT + https://github.com/MTESSDev/mdAT + 2.0.0 + Markdown Auto-Tests core for .net 8+: markdown/YAML test case parsing and JSON verification, independent of any test framework. + True + $(NoWarn);CS1591 + markdown;testing;yaml;json;assert;test-automation + mdAT 2.0: framework-independent core. Own JSON comparison engine (no xunit/Quibble dependency), ignoreArrayOrder option, net8.0/net9.0/net10.0. In the markdown files: a test case is named Method_Title@file.md; the test explorer points each case to its title in the markdown file (or to the start of its document in a YAML file); each markdown test file keeps a link to its C# test; a case marked skipped is listed and reported as ignored, and when a case is marked selected the other cases of the file stay in the list, ignored; a case can have categories and properties (category=Smoke,Fast priority=High in the line of its code block, tags: in the front matter of the file); a JsonDocument or JsonElement whose root is a string holding a JSON object or array is written as that object or array again (mdAT 1.4 behaviour); the file of generateExpectedData is written again when another process is using it. See the migration section of the README. + git + true + snupkg + README.md + LICENSE + + + + + True + \ + + + True + \ + + + + + + + + + + + + + + + + + + diff --git a/src/MDAT.Core/MarkdownSourceLink.cs b/src/MDAT.Core/MarkdownSourceLink.cs new file mode 100644 index 0000000..d6ce1cb --- /dev/null +++ b/src/MDAT.Core/MarkdownSourceLink.cs @@ -0,0 +1,286 @@ +using Markdig; +using Markdig.Extensions.Yaml; +using Markdig.Syntax; +using System.Collections.Concurrent; +using System.Text; + +namespace MDAT; + +/// A C# test that reads a markdown file: where it is written, and its name. +/// Full path of the C# file holding the test. +/// 1-based line of the test in the C# file. +/// Name of the test (class and method): one link is kept per test. +public sealed record SourceLinkEntry(string SourceFilePath, int Line, string? TestName = null); + +/// +/// Keeps, at the top of a markdown test file, a link to the C# test that reads it, so that one click goes from the +/// cases to the code: +/// +/// # Md1 +/// +/// > C# : [MarkdownTestAttributeTests.cs:49](../MarkdownTestAttributeTests.cs#L49) <!-- mdat:MarkdownTestAttributeTests.cs --> +/// +/// The line is inserted after the title when it is missing and corrected when the line of the C# test moves. The rest +/// of the file is left byte for byte as it was (encoding, BOM, line endings) and nothing is written when nothing changes. +/// +public static class MarkdownSourceLink +{ + private static readonly ConcurrentDictionary Locks = new(StringComparer.OrdinalIgnoreCase); + private static readonly UTF8Encoding Utf8 = new(false); + private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder().UseYamlFrontMatter().Build(); + + /// + /// Inserts or corrects the link to the C# file in the markdown file. + /// Never throws: a file that cannot be read or written (read-only, locked, other encoding, C# file not found at + /// that path, ...) is left as it is. + /// + /// The markdown test file. + /// Full path of the C# file holding the test. + /// 1-based line of the test in the C# file. + /// + /// Name of the C# test. A markdown file shared by several tests keeps one line per test (and per C# file), so that the + /// lines do not take each other's place. + /// + /// True when the markdown file was changed. + public static bool Update(string markdownPath, string? sourceFilePath, int sourceLine, string? testName = null) + { + if (string.IsNullOrWhiteSpace(sourceFilePath) || sourceLine <= 0) + { + return false; + } + + return Update(markdownPath, new[] { new SourceLinkEntry(sourceFilePath, sourceLine, testName) }); + } + + /// + /// Inserts or corrects the links of all the tests that read the markdown file, in one single write. Doing it for all the + /// tests at once keeps the lines of the cases right for every test of a shared file: no link added later by another test + /// moves them. + /// + /// True when the markdown file was changed. + public static bool Update(string markdownPath, IEnumerable entries) + { + if (string.IsNullOrWhiteSpace(markdownPath)) + { + return false; + } + + try + { + var markdown = Path.GetFullPath(markdownPath); + + if (!File.Exists(markdown)) + { + return false; + } + + var existing = entries + .Where(e => !string.IsNullOrWhiteSpace(e.SourceFilePath) && e.Line > 0) + .Select(e => e with { SourceFilePath = Path.GetFullPath(e.SourceFilePath) }) + .Where(e => File.Exists(e.SourceFilePath)) + .ToList(); + + if (existing.Count == 0) + { + return false; + } + + lock (Locks.GetOrAdd(markdown, _ => new object())) + { + return UpdateFile(markdown, existing); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + return false; + } + } + + /// The line written in the markdown file, e.g. > C# : [Tests.cs:49](../Tests.cs#L49) <!-- mdat:Tests.cs -->. + public static string BuildLine(string markdownPath, string sourceFilePath, int sourceLine, string? testName = null) + { + var relative = RelativeLink(Path.GetFullPath(markdownPath), Path.GetFullPath(sourceFilePath)); + + return relative is null + ? throw new ArgumentException("The C# file cannot be reached with a relative path from the markdown file.", nameof(sourceFilePath)) + : $"> C# : [{Path.GetFileName(sourceFilePath)}:{sourceLine}]({relative}#L{sourceLine}) {Marker(sourceFilePath, testName)}"; + } + + private static string Marker(string sourceFilePath, string? testName) => + $""; + + /// + /// Runs while no other thread reads or rewrites the same markdown file through this class. + /// Reading a file that another test is rewriting would otherwise fail from time to time. + /// + public static T Synchronized(string markdownPath, Func action) + { + lock (Locks.GetOrAdd(Path.GetFullPath(markdownPath), _ => new object())) + { + return action(); + } + } + + private static bool UpdateFile(string markdown, List entries) + { + // A read-only file is left alone. This is checked on purpose: the file is replaced by renaming a new one over it, which + // fails on Windows but silently succeeds on Linux, where only the permissions of the folder count. + if ((File.GetAttributes(markdown) & FileAttributes.ReadOnly) != 0) + { + return false; + } + + var bytes = File.ReadAllBytes(markdown); + + // UTF-16 files are not handled: left untouched + if (bytes.Length >= 2 && ((bytes[0] == 0xFF && bytes[1] == 0xFE) || (bytes[0] == 0xFE && bytes[1] == 0xFF))) + { + return false; + } + + var hasBom = bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF; + var bomLength = hasBom ? 3 : 0; + var text = Utf8.GetString(bytes, bomLength, bytes.Length - bomLength); + var newline = text.Contains("\r\n") ? "\r\n" : text.Contains('\n') ? "\n" : Environment.NewLine; + + var updated = text; + + // A new link goes right under the title, above the ones already there: inserting them from the last to the first + // leaves them in order (by file, then line), whatever the order the tests were found in. + foreach (var entry in entries.OrderByDescending(e => Path.GetFileName(e.SourceFilePath), StringComparer.Ordinal) + .ThenByDescending(e => e.Line) + .ThenByDescending(e => e.TestName, StringComparer.Ordinal)) + { + var relative = RelativeLink(markdown, entry.SourceFilePath); + + // another drive: no relative link is possible, and an absolute one would not work for the other developers + if (relative is null) + { + continue; + } + + var marker = Marker(entry.SourceFilePath, entry.TestName); + var link = $"> C# : [{Path.GetFileName(entry.SourceFilePath)}:{entry.Line}]({relative}#L{entry.Line}) {marker}"; + + updated = ReplaceOrInsert(updated, link, marker, newline); + } + + if (string.Equals(updated, text, StringComparison.Ordinal)) + { + return false; + } + + Write(markdown, hasBom, updated); + return true; + } + + private static string ReplaceOrInsert(string text, string link, string marker, string newline) + { + var markerIndex = text.IndexOf(marker, StringComparison.Ordinal); + + if (markerIndex >= 0) + { + // the line that holds the marker is replaced as a whole, up to its end (without the line ending) + var lineStart = text.LastIndexOf('\n', markerIndex) + 1; + var lineEnd = text.IndexOf('\n', markerIndex); + + if (lineEnd < 0) + { + lineEnd = text.Length; + } + else if (lineEnd > lineStart && text[lineEnd - 1] == '\r') + { + lineEnd--; + } + + return text[..lineStart] + link + text[lineEnd..]; + } + + var document = Markdown.Parse(text, Pipeline); + + var title = document.OfType().FirstOrDefault(h => h.Level == 1); + + if (title is not null) + { + return InsertAfterLine(text, title.Span.End, link, newline); + } + + var frontMatter = document.OfType().FirstOrDefault(); + + if (frontMatter is not null) + { + return InsertAfterLine(text, frontMatter.Span.End, link, newline); + } + + return link + newline + newline + text; + } + + /// + /// Inserts the link below the line that holds the end of a block. Anything that follows the block on its line (spaces, + /// closing hashes) stays on that line, and the new lines use the line ending of that line. + /// + private static string InsertAfterLine(string text, int blockEnd, string link, string fallbackNewline) + { + var newlineIndex = text.IndexOf('\n', Math.Min(blockEnd, text.Length)); + var lineEnd = newlineIndex < 0 ? text.Length : newlineIndex; + var newline = fallbackNewline; + + if (newlineIndex >= 0) + { + if (newlineIndex > 0 && text[newlineIndex - 1] == '\r') + { + lineEnd = newlineIndex - 1; + newline = "\r\n"; + } + else + { + newline = "\n"; + } + } + + return text[..lineEnd] + newline + newline + link + text[lineEnd..]; + } + + /// Path of from the folder of , written as a markdown link target. + private static string? RelativeLink(string markdown, string source) + { + var relative = Path.GetRelativePath(Path.GetDirectoryName(markdown)!, source); + + if (Path.IsPathRooted(relative)) + { + return null; + } + + var segments = relative.Split(new[] { '\\', '/' }, StringSplitOptions.RemoveEmptyEntries) + .Select(s => s is ".." or "." ? s : Uri.EscapeDataString(s)); + + return string.Join("/", segments); + } + + private static void Write(string path, bool withBom, string text) + { + var temporary = path + ".mdat-tmp"; + + try + { + using (var stream = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None)) + { + if (withBom) + { + stream.Write(new byte[] { 0xEF, 0xBB, 0xBF }); + } + + stream.Write(Utf8.GetBytes(text)); + } + + File.Move(temporary, path, overwrite: true); + } + finally + { + if (File.Exists(temporary)) + { + File.Delete(temporary); + } + } + } +} diff --git a/src/MDAT.Core/MarkdownTestSource.cs b/src/MDAT.Core/MarkdownTestSource.cs new file mode 100644 index 0000000..d87bf86 --- /dev/null +++ b/src/MDAT.Core/MarkdownTestSource.cs @@ -0,0 +1,480 @@ +using LoxSmoke.DocXml; +using Markdig; +using Markdig.Extensions.Yaml; +using Markdig.Syntax; +using MDAT.Resolver; +using System.ComponentModel; +using System.Globalization; +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; +using YamlDotNet.Core; +using YamlDotNet.Serialization; + +namespace MDAT +{ + /// + /// One test case extracted from a markdown file: the arguments to give to the test method, its display name + /// and where it is written. + /// + public sealed class MarkdownTestCase + { + public MarkdownTestCase(object?[] arguments, string displayName, string? filePath = null, int line = 0, string? skipReason = null, + IReadOnlyList? categories = null, IReadOnlyList>? properties = null) + { + Arguments = arguments; + DisplayName = displayName; + FilePath = filePath; + Line = line; + SkipReason = skipReason; + Categories = categories ?? Array.Empty(); + Properties = properties ?? Array.Empty>(); + } + + /// + /// The categories of the case: category=Smoke,Fast in the line of its code block, added to the + /// tags: category: of the front matter of the file. + /// + public IReadOnlyList Categories { get; } + + /// + /// The other tags of the case, as name and value: priority=High in the line of its code block, or + /// tags: priority: in the front matter of the file. A property of the case replaces the one of the file with the same name. + /// + public IReadOnlyList> Properties { get; } + + /// When set, the case is reported as ignored and never run: it was marked skipped in the markdown file. + public string? SkipReason { get; } + + public object?[] Arguments { get; } + + public string DisplayName { get; } + + /// Full path of the file the case is written in, when known. + public string? FilePath { get; } + + /// 1-based line of the case in : its title in a markdown file. 0 when unknown. + public int Line { get; } + } + + /// + /// Reads test cases from a markdown file. Independent of any test framework. + /// + public sealed class MarkdownTestSource + { + private string _filePath; + + public string ParsedPath { get; private set; } + + public MarkdownTestSource(string filePath) + { + _filePath = filePath; + ParsedPath = _filePath; + } + + /// + /// Loads the test cases for . When the markdown file does not exist, it is + /// generated from the method signature and no case is returned. + /// + public IReadOnlyList GetCases(MethodInfo testMethod) + { + ResolvePath(testMethod); + + if (!File.Exists(ParsedPath)) + { + CreateMardownFile(testMethod); + return Array.Empty(); + } + + var to = LoadMarkdownFile(testMethod); + + return to; + } + + /// + /// Works out the markdown file of ({method} becomes the name of the method) without + /// reading it, and returns its path (also available as ). + /// + public string ResolvePath(MethodInfo testMethod) + { + if (testMethod == null) { throw new ArgumentNullException(nameof(testMethod)); } + + _filePath = _filePath.Replace("{method}", testMethod.Name.ToKebabCase().ToLower()); + ParsedPath = Extensions.GetCurrentPath(_filePath, false); + + return ParsedPath; + } + + private List LoadMarkdownFile(MethodInfo testMethod) + { + var fileData = File.ReadAllText(ParsedPath); + + // with the front matter extension, the "---" block at the top of the file is not read as a title + var mdFile = Markdown.Parse(fileData, MarkdownPipeline); + var fileTags = ReadFileTags(mdFile); + + string?[] headings = new string?[] { null, null, null, null, null, null }; + int[] headingLines = new int[6]; + var fullPath = Path.GetFullPath(ParsedPath); + + List to = new(); + + // When a case is marked "selected", the others are not run but stay in the list, reported as ignored + var anySelected = mdFile.OfType().Any(block => IsCase(block) && HasArgument(block, "selected") && !HasArgument(block, "skipped")); + + foreach (var info in mdFile) + { + if (info is HeadingBlock add) + { + SetHeadings(headings, add); + headingLines[add.Level - 1] = add.Line + 1; + } + else if (info is FencedCodeBlock fbc && IsCase(fbc)) + { + var doc = string.Join("\n", fbc.Lines); + + if (string.IsNullOrWhiteSpace(doc)) + continue; + + // A case that is ignored is listed with its reason. Its YAML is not read (it may be unfinished or not be + // the one being worked on): the test method is never called, the arguments only have to match its parameters. + var ignoredBecause = HasArgument(fbc, "skipped") ? SkippedReason + : anySelected && !HasArgument(fbc, "selected") ? NotSelectedReason + : null; + + // the tags of the file, then the ones of the case (the case wins for a property of the same name) + var (categories, properties) = MergeTags(fileTags, ReadCaseTags(fbc.Arguments)); + + if (ignoredBecause is not null) + { + to.Add(new MarkdownTestCase(DefaultArguments(testMethod), DisplayName(testMethod, headings), fullPath, TitleLine(headings, headingLines, fbc.Line + 1), ignoredBecause, categories, properties)); + continue; + } + + var values = ExtractTest(testMethod, doc, Path.GetDirectoryName(ParsedPath)!); + + if (values is null) continue; + + to.Add(new MarkdownTestCase(values, DisplayName(testMethod, headings), fullPath, TitleLine(headings, headingLines, fbc.Line + 1), null, categories, properties)); + } + } + + return to; + } + + private static readonly MarkdownPipeline MarkdownPipeline = new MarkdownPipelineBuilder().UseYamlFrontMatter().Build(); + + private const string CategoryTag = "category"; + + // name=value or name="a value with spaces", in the line of the code block (after "yaml") + private static readonly Regex CaseTagPattern = new(@"(?[A-Za-z_][A-Za-z0-9_.\-]*)=(?:""(?[^""]*)""|(?\S*))", RegexOptions.Compiled | RegexOptions.CultureInvariant); + + /// + /// The tags of the front matter of the file, which apply to all its cases: + /// + /// --- + /// tags: + /// category: Billing, Smoke + /// priority: High + /// --- + /// + /// category (one name, several separated by commas, or a list) gives categories, any other name a property + /// (a list gives one property per item). A front matter that cannot be read is left alone: it never stops the cases + /// from being read. + /// + private static List> ReadFileTags(MarkdownDocument document) + { + var tags = new List>(); + + var frontMatter = document.OfType().FirstOrDefault(); + + if (frontMatter is null) + { + return tags; + } + + try + { + var root = new DeserializerBuilder().Build().Deserialize>(frontMatter.Lines.ToString()); + + if (root is not null + && root.FirstOrDefault(entry => string.Equals(entry.Key, "tags", StringComparison.OrdinalIgnoreCase)).Value is IDictionary declared) + { + foreach (var tag in declared) + { + var name = Convert.ToString(tag.Key, CultureInfo.InvariantCulture) ?? ""; + var values = tag.Value is IEnumerable list ? list : new[] { tag.Value }; + + foreach (var value in values) + { + tags.Add(new KeyValuePair(name, Convert.ToString(value, CultureInfo.InvariantCulture) ?? "")); + } + } + } + } + catch (YamlException) + { + // not a YAML we can read: no tag + } + + return tags; + } + + /// category=Smoke,Fast priority=High in the line of the code block, next to skipped and selected. + private static List> ReadCaseTags(string? arguments) + { + var tags = new List>(); + + foreach (Match match in CaseTagPattern.Matches(arguments ?? "")) + { + var value = match.Groups["quoted"].Success ? match.Groups["quoted"].Value : match.Groups["plain"].Value; + + tags.Add(new KeyValuePair(match.Groups["name"].Value, value)); + } + + return tags; + } + + private static (IReadOnlyList Categories, IReadOnlyList> Properties) MergeTags( + List> fromFile, List> fromCase) + { + var categories = new List(); + var properties = new List>(); + + // a property the case sets replaces the ones of the file with the same name (the categories add up) + var setByTheCase = new HashSet(fromCase.Where(t => !IsCategory(t)).Select(t => t.Key), StringComparer.Ordinal); + + foreach (var tag in fromFile.Where(t => IsCategory(t) || !setByTheCase.Contains(t.Key)).Concat(fromCase)) + { + if (IsCategory(tag)) + { + foreach (var name in (tag.Value ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (!categories.Contains(name, StringComparer.Ordinal)) + { + categories.Add(name); + } + } + } + else + { + properties.Add(tag); + } + } + + return (categories, properties); + } + + private static bool IsCategory(KeyValuePair tag) => string.Equals(tag.Key, CategoryTag, StringComparison.OrdinalIgnoreCase); + + private static bool IsCase(FencedCodeBlock block) => + block.ClosingFencedCharCount == 6 && (block.Info?.ToLower() == "yaml" || block.Info?.ToLower() == "yml"); + + private static bool HasArgument(FencedCodeBlock block, string argument) => + block.Arguments is { } && block.Arguments.Contains(argument, StringComparison.InvariantCultureIgnoreCase); + + private string DisplayName(MethodInfo testMethod, string?[] headings) + { + // Method_Title_Sub title@file.md: the file name comes last, as it is the same for every case of the method. + // A title that only repeats the name of the method (the generated files start with "# MethodName") adds nothing. + var methodKey = Normalize(testMethod.Name); + var titles = headings.Where(h => !string.IsNullOrWhiteSpace(h) && Normalize(h) != methodKey); + + var name = string.Join("_", new[] { testMethod.Name }.Concat(titles!)); + + return $"{name}@{Path.GetFileName(ParsedPath)}"; + } + + /// + /// Why a case marked skipped is ignored, in the language of the interface of the computer (French or English). + /// + public static string SkippedReason => + InFrench ? "Marqué comme ignoré (skipped) dans le fichier Markdown" : "Marked as skipped in the markdown file"; + + /// Why a case is ignored when another case of the file is marked selected (same languages). + public static string NotSelectedReason => + InFrench ? "Un autre cas est marqué selected dans le fichier Markdown" : "Another case is marked selected in the markdown file"; + + private static bool InFrench => + string.Equals(CultureInfo.CurrentUICulture.TwoLetterISOLanguageName, "fr", StringComparison.OrdinalIgnoreCase); + + private static object?[] DefaultArguments(MethodInfo testMethod) => + testMethod.GetParameters() + .Select(p => p.ParameterType.IsValueType ? Activator.CreateInstance(p.ParameterType) : null) + .ToArray(); + + /// Letters and digits only, in lower case: "Md Extraction-test" and "Md_Extraction_test" are the same title. + private static string Normalize(string? text) => new string((text ?? "").Where(char.IsLetterOrDigit).ToArray()).ToLowerInvariant(); + + /// Line of the deepest title above the case (the one that ends its display name), or the line of its code block. + private static int TitleLine(string?[] headings, int[] headingLines, int codeBlockLine) + { + for (var level = headings.Length - 1; level >= 0; level--) + { + if (!string.IsNullOrWhiteSpace(headings[level])) + { + return headingLines[level]; + } + } + + return codeBlockLine; + } + + private static void SetHeadings(string?[] heading, HeadingBlock add) + { + heading[add.Level - 1] = add.Inline?.FirstOrDefault()?.ToString(); + + for (int i = add.Level; i < 6; i++) + { + heading[i] = null; + } + } + + private static object[]? ExtractTest(MethodInfo testMethod, string doc, string directoryName) + { + var resolver = new MDATYamlTypeResolver(testMethod); + + IDeserializer deserializer = NewDeserializer(resolver, directoryName); + + object[]? values; + try + { + var ggs = deserializer.Deserialize>(doc); + + if (ggs is null) return null; + values = ggs.Values.ToArray(); + + } + catch (YamlException ex) + { + var invalidName = testMethod.GetParameters()[resolver.Position - 1]; + throw new MdatTestDefinitionException($"Method '{testMethod.Name}' exception on '{invalidName.Name}' parameter.'\n{ex.Message}\n{ex.InnerException?.Message}", ex); + } + + return values; + } + + private void CreateMardownFile(MethodInfo testMethod) + { + MethodComments? methodComments = null; + + string directoryPath = testMethod.DeclaringType!.Assembly.Location; + string xmlFilePath = directoryPath.Replace(".dll", ".xml"); + + if (File.Exists(xmlFilePath)) + { + DocXmlReader reader = new DocXmlReader(xmlFilePath); + methodComments = reader.GetMethodComments(testMethod); + } + + var code = ""; + + foreach (var item in testMethod.GetParameters()) + { + var paramsDetails = methodComments?.Parameters.Where(e => e.Name == item.Name).FirstOrDefault(); + + var strDetails = paramsDetails is { } + && paramsDetails.Value.Text is { } + ? $"# {paramsDetails.Value.Text}\n" + : ""; + + code += $"{strDetails}{item.Name}:\n{DescribeTypeOfObject(item.ParameterType, " ")}"; + } + + var summary = methodComments is { } + && !string.IsNullOrWhiteSpace(methodComments.Summary) + ? $"\n\n> {methodComments?.Summary.ReplaceLineEndings("\\\n")}" + : ""; + + File.WriteAllText(ParsedPath, $"# {testMethod.Name}{summary}\n\n## Case 1\n\nDescription\n\n``````yaml\n{code}``````", Encoding.UTF8); + } + + static string? DescribeTypeOfObject(Type type, string indent, int pos = 0) + { + if (pos == 10) + return string.Empty; // Loop protection + else + pos++; + + string? obj = null; + + if (type.IsClass && !type.FullName!.StartsWith("System.")) + { + PropertyInfo[] propertyInfos = type.GetProperties(); + foreach (PropertyInfo pi in propertyInfos) + { + var def = GetDefaultValueForProperty(pi); + + var innerObj = DescribeTypeOfObject(pi.PropertyType, indent + " ", pos); + + obj += $"{indent}{pi.Name}: {(string.IsNullOrEmpty(innerObj) ? GetDefaultValue(def) : "\n" + innerObj)}"; + } + } + else if (type.Name.StartsWith("IEnumerable")) + { + PropertyInfo[] propertyInfos = type.GetGenericArguments()[0].GetProperties(); + + var premier = true; + + foreach (PropertyInfo pi in propertyInfos) + { + var def = GetDefaultValueForProperty(pi); + + var innerObj = DescribeTypeOfObject(pi.PropertyType, indent + " ", pos); + + obj += $"{indent}{(premier ? "- " : " ")}{pi.Name}: {(string.IsNullOrEmpty(innerObj) ? GetDefaultValue(def) : "\n" + innerObj)}"; + premier = false; + } + } + + + return obj; + } + + private static string GetDefaultValue(object? def) + { + return def is { } ? new Serializer().Serialize(def).ReplaceLineEndings("\n") : "null\n"; + } + + public static object? GetDefaultValueForProperty(PropertyInfo property) + { + var defaultAttr = property.GetCustomAttribute(typeof(DefaultValueAttribute)); + if (defaultAttr != null) + return (defaultAttr as DefaultValueAttribute)?.Value; + + var propertyType = property.PropertyType; + return propertyType.IsValueType ? Activator.CreateInstance(propertyType) : null; + } + + private static IDeserializer NewDeserializer(INodeTypeResolver resolver, string directoryName) + { + DeserializerBuilder deserializer = new DeserializerBuilder() + .WithTypeConverter(new ByteArayConverter(), e => e.OnBottom()) + .WithTypeConverter(new SystemTextJsonYamlTypeConverter(false, true)) + .WithTypeInspector(x => new YamlDotNet.System.Text.Json.SystemTextJsonTypeInspector(x)) + .WithTypeConverter(new StringValuesConverter()) + .WithNodeTypeResolver(resolver) + .WithNodeDeserializer(new KeyValuePairNodeDeserializer()) + .IgnoreUnmatchedProperties() + .WithAttemptingUnquotedStringTypeDeserialization() + .WithTagMapping(MdatConstants.IncludeTag, typeof(IncludeRef)); + + foreach (var typeConverter in MdatConfig.ListeTypeConverter) + { + _ = typeConverter.Where is { } ? deserializer.WithTypeConverter(typeConverter.TypeConverter!, typeConverter.Where) : deserializer.WithTypeConverter(typeConverter.TypeConverter!); + } + + var includeNodeDeserializerOptions = new YamlIncludeNodeDeserializerOptions + { + DirectoryName = directoryName, + //Builder = deserializer + }; + + var includeNodeDeserializer = new YamlIncludeNodeDeserializer(includeNodeDeserializerOptions); + + deserializer.WithNodeDeserializer(includeNodeDeserializer, s => s.OnTop()); + + return deserializer.Build(); + } + } +} \ No newline at end of file diff --git a/src/MDAT/MdatConfig.cs b/src/MDAT.Core/MdatConfig.cs similarity index 100% rename from src/MDAT/MdatConfig.cs rename to src/MDAT.Core/MdatConfig.cs diff --git a/src/MDAT.Core/MdatTestDefinitionException.cs b/src/MDAT.Core/MdatTestDefinitionException.cs new file mode 100644 index 0000000..e9145e9 --- /dev/null +++ b/src/MDAT.Core/MdatTestDefinitionException.cs @@ -0,0 +1,12 @@ +namespace MDAT; + +/// +/// Thrown when a test case file cannot be turned into arguments for the test method (invalid YAML, wrong parameter, ...). +/// +public class MdatTestDefinitionException : Exception +{ + public MdatTestDefinitionException(string message, Exception? innerException = null) + : base(message, innerException) + { + } +} diff --git a/src/MDAT/ObjectOrException.cs b/src/MDAT.Core/ObjectOrException.cs similarity index 100% rename from src/MDAT/ObjectOrException.cs rename to src/MDAT.Core/ObjectOrException.cs diff --git a/src/MDAT/Resolver/ByteArayConverter.cs b/src/MDAT.Core/Resolver/ByteArayConverter.cs similarity index 100% rename from src/MDAT/Resolver/ByteArayConverter.cs rename to src/MDAT.Core/Resolver/ByteArayConverter.cs diff --git a/src/MDAT/Resolver/KeyValuePairNodeDeserializer.cs b/src/MDAT.Core/Resolver/KeyValuePairNodeDeserializer.cs similarity index 100% rename from src/MDAT/Resolver/KeyValuePairNodeDeserializer.cs rename to src/MDAT.Core/Resolver/KeyValuePairNodeDeserializer.cs diff --git a/src/MDAT/Resolver/MDATYamlTypeResolver.cs b/src/MDAT.Core/Resolver/MDATYamlTypeResolver.cs similarity index 100% rename from src/MDAT/Resolver/MDATYamlTypeResolver.cs rename to src/MDAT.Core/Resolver/MDATYamlTypeResolver.cs diff --git a/src/MDAT/Resolver/MdatConstants.cs b/src/MDAT.Core/Resolver/MdatConstants.cs similarity index 100% rename from src/MDAT/Resolver/MdatConstants.cs rename to src/MDAT.Core/Resolver/MdatConstants.cs diff --git a/src/MDAT/Resolver/StreamToBase64Converter.cs b/src/MDAT.Core/Resolver/StreamToBase64Converter.cs similarity index 100% rename from src/MDAT/Resolver/StreamToBase64Converter.cs rename to src/MDAT.Core/Resolver/StreamToBase64Converter.cs diff --git a/src/MDAT/Resolver/StringValuesConverter.cs b/src/MDAT.Core/Resolver/StringValuesConverter.cs similarity index 100% rename from src/MDAT/Resolver/StringValuesConverter.cs rename to src/MDAT.Core/Resolver/StringValuesConverter.cs diff --git a/src/MDAT/Resolver/SystemTextJsonFullConverter.cs b/src/MDAT.Core/Resolver/SystemTextJsonFullConverter.cs similarity index 50% rename from src/MDAT/Resolver/SystemTextJsonFullConverter.cs rename to src/MDAT.Core/Resolver/SystemTextJsonFullConverter.cs index ba829ec..a4d937e 100644 --- a/src/MDAT/Resolver/SystemTextJsonFullConverter.cs +++ b/src/MDAT.Core/Resolver/SystemTextJsonFullConverter.cs @@ -27,14 +27,52 @@ public override void WriteJson(JsonWriter writer, object? value, Newtonsoft.Json JsonArray arr => arr.ToJsonString(), JsonValue val => val.ToJsonString(), JsonNode node => node.ToJsonString(), - JsonDocument doc => doc.RootElement.GetRawText(), - JsonElement elem => elem.ToString(), + JsonDocument doc => ElementToJson(doc.RootElement), + JsonElement elem => ElementToJson(elem), _ => throw new JsonSerializationException($"Type System.Text.Json non supporté : {value.GetType()}") }; writer.WriteRawValue(json); } + /// + /// JSON text of an element. A string holding a JSON object or array is written as that object or array, + /// as mdAT 1.4 did: the document of a service often carries its payload as JSON text. + /// Any other string stays a string. + /// + private static string ElementToJson(JsonElement element) + { + if (element.ValueKind == JsonValueKind.String && TryGetEmbeddedJson(element.GetString(), out var embedded)) + { + return embedded; + } + + return element.GetRawText(); + } + + private static bool TryGetEmbeddedJson(string? text, out string json) + { + json = ""; + + var trimmed = text?.Trim(); + + if (string.IsNullOrEmpty(trimmed) || (trimmed[0] != '{' && trimmed[0] != '[')) + { + return false; + } + + try + { + using var document = JsonDocument.Parse(trimmed); + json = document.RootElement.GetRawText(); + return true; + } + catch (System.Text.Json.JsonException) + { + return false; + } + } + public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, Newtonsoft.Json.JsonSerializer serializer) { throw new JsonSerializationException("Invalid json document."); diff --git a/src/MDAT/Resolver/SystemTextJsonYamlTypeConverter.cs b/src/MDAT.Core/Resolver/SystemTextJsonYamlTypeConverter.cs similarity index 100% rename from src/MDAT/Resolver/SystemTextJsonYamlTypeConverter.cs rename to src/MDAT.Core/Resolver/SystemTextJsonYamlTypeConverter.cs diff --git a/src/MDAT/Resolver/YamlIncludeNodeDeserializer.cs b/src/MDAT.Core/Resolver/YamlIncludeNodeDeserializer.cs similarity index 100% rename from src/MDAT/Resolver/YamlIncludeNodeDeserializer.cs rename to src/MDAT.Core/Resolver/YamlIncludeNodeDeserializer.cs diff --git a/src/MDAT/Resolver/YamlIncludeNodeDeserializerOptions.cs b/src/MDAT.Core/Resolver/YamlIncludeNodeDeserializerOptions.cs similarity index 100% rename from src/MDAT/Resolver/YamlIncludeNodeDeserializerOptions.cs rename to src/MDAT.Core/Resolver/YamlIncludeNodeDeserializerOptions.cs diff --git a/src/MDAT/ReturnsOrThrows.cs b/src/MDAT.Core/ReturnsOrThrows.cs similarity index 100% rename from src/MDAT/ReturnsOrThrows.cs rename to src/MDAT.Core/ReturnsOrThrows.cs diff --git a/src/MDAT/TestException.cs b/src/MDAT.Core/TestException.cs similarity index 100% rename from src/MDAT/TestException.cs rename to src/MDAT.Core/TestException.cs diff --git a/src/MDAT/Utils.cs b/src/MDAT.Core/Utils.cs similarity index 89% rename from src/MDAT/Utils.cs rename to src/MDAT.Core/Utils.cs index 5d48d97..499ba80 100644 --- a/src/MDAT/Utils.cs +++ b/src/MDAT.Core/Utils.cs @@ -2,7 +2,6 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Newtonsoft.Json.Serialization; -using Quibble.Xunit; using System.Reflection; using System.Text; using System.Text.RegularExpressions; @@ -42,6 +41,26 @@ public static async Task Assert(dynamic? obj, Expected expected) } } + /// + /// Writes the file of generateExpectedData. Test projects built for several frameworks run side by side and write + /// the same file: a file that another process is writing is tried again a few times instead of failing the test. + /// + private static async Task WriteGeneratedFile(string path, string content) + { + for (var attempt = 1; ; attempt++) + { + try + { + await File.WriteAllTextAsync(path, content); + return; + } + catch (IOException) when (attempt < 5) + { + await Task.Delay(50 * attempt); + } + } + } + private static async Task MatchCheck(dynamic obj, Expected expected, VerifyStep item) { object? finalExpectedData = item.data; @@ -61,11 +80,11 @@ private static async Task MatchCheck(dynamic obj, Expected expected, VerifyStep .Build(); var yml = builder.Serialize(obj); - await File.WriteAllTextAsync(path, yml); + await WriteGeneratedFile(path, yml); } else { - await File.WriteAllTextAsync(path, data); + await WriteGeneratedFile(path, data); } } @@ -108,7 +127,7 @@ private static async Task MatchCheck(dynamic obj, Expected expected, VerifyStep JsonAssert.EqualOverrideDefault(finalExpectedData?.ToString(), finalData, - new JsonDiffConfig(item.allowAdditionalProperties)); + new JsonDiffConfig(item.allowAdditionalProperties, item.ignoreArrayOrder)); } public static JsonSerializerSettings JsonSerializeSettings() diff --git a/src/MDAT/Verify.cs b/src/MDAT.Core/Verify.cs similarity index 54% rename from src/MDAT/Verify.cs rename to src/MDAT.Core/Verify.cs index 646b533..5580df4 100644 --- a/src/MDAT/Verify.cs +++ b/src/MDAT.Core/Verify.cs @@ -1,6 +1,5 @@  using Newtonsoft.Json; -using Quibble.Xunit; using System.Runtime.CompilerServices; namespace MDAT; @@ -38,13 +37,45 @@ public async static Task Assert(Func> functionAMocker, Expected ex { var strData = JsonConvert.SerializeObject(data, Extensions.JsonSerializeSettings()); - Console.WriteLine($"Actual raw for \"{expected?.name ?? functionAMocker.Method.ReturnParameter.ParameterType.GetGenericArguments()[0].Name}\":"); + Console.WriteLine($"Actual raw for \"{expected?.name ?? TypeTitle(typeof(T))}\":"); Console.WriteLine(strData); } return default!; } + /// + /// Readable name of a type for the title of the raw output: List<JsonNode> rather than List`1, + /// Dictionary<String, Byte[]> rather than Dictionary`2. + /// + internal static string TypeTitle(Type type) + { + if (type.IsArray) + { + return TypeTitle(type.GetElementType()!) + "[" + new string(',', type.GetArrayRank() - 1) + "]"; + } + + var underlying = Nullable.GetUnderlyingType(type); + if (underlying is not null) + { + return TypeTitle(underlying) + "?"; + } + + // the compiler names an anonymous type <>f__AnonymousType0`2 + if (type.Name.StartsWith("<>f__AnonymousType", StringComparison.Ordinal)) + { + return "anonymous object"; + } + + if (!type.IsGenericType) + { + return type.Name; + } + + var name = type.Name[..type.Name.IndexOf('`')]; + + return $"{name}<{string.Join(", ", type.GetGenericArguments().Select(TypeTitle))}>"; + } } \ No newline at end of file diff --git a/src/MDAT/VerifyStep.cs b/src/MDAT.Core/VerifyStep.cs similarity index 69% rename from src/MDAT/VerifyStep.cs rename to src/MDAT.Core/VerifyStep.cs index ea07fec..ddfd384 100644 --- a/src/MDAT/VerifyStep.cs +++ b/src/MDAT.Core/VerifyStep.cs @@ -10,6 +10,9 @@ public class VerifyStep [DefaultValue("$")] public string jsonPath { get; set; } = "$"; public bool allowAdditionalProperties { get; set; } + + /// Arrays are equal when they hold the same values, whatever their order. + public bool ignoreArrayOrder { get; set; } public object? data { get; set; } } } \ No newline at end of file diff --git a/src/MDAT.Core/YamlFileTestSource.cs b/src/MDAT.Core/YamlFileTestSource.cs new file mode 100644 index 0000000..7de4f1a --- /dev/null +++ b/src/MDAT.Core/YamlFileTestSource.cs @@ -0,0 +1,92 @@ +using MDAT.Resolver; +using System.Reflection; +using System.Text.RegularExpressions; +using YamlDotNet.Serialization; + +namespace MDAT; + +/// +/// Reads test cases from a multi-document YAML file (documents separated by ---). Independent of any test framework. +/// +public sealed class YamlFileTestSource +{ + private readonly string _filePath; + + public YamlFileTestSource(string filePath) + { + _filePath = filePath; + } + + public IReadOnlyList GetCases(MethodInfo testMethod) + { + if (testMethod == null) { throw new ArgumentNullException(nameof(testMethod)); } + + var path = _filePath.Replace('\\', '/').Replace("~/", "../../../"); + + // Get the absolute path to the YAML file + path = Path.IsPathRooted(path) + ? path + : Path.GetRelativePath(Directory.GetCurrentDirectory(), path); + + if (!File.Exists(path)) + { + throw new FileNotFoundException($"Could not find file at path: {path}", path); + } + + var fileData = File.ReadAllText(path); + + var docs = Regex.Split(fileData, @"^---", RegexOptions.Multiline); + + var cases = new List(); + var fullPath = Path.GetFullPath(path); + + // Each "---" separator is 3 characters long and was removed by the split: this is where a document starts + var documentStart = 0; + + foreach (var doc in docs) + { + var separatorStart = documentStart == 0 ? 0 : documentStart - 3; + documentStart += doc.Length + 3; + + if (string.IsNullOrWhiteSpace(doc)) continue; + + IDeserializer deserializer = NewDeserializer(testMethod); + + var values = deserializer.Deserialize>("---\n" + doc); + + if (values is null) continue; + + var arguments = values.Values.ToArray(); + + cases.Add(new MarkdownTestCase(arguments, $"{testMethod.Name}_#{cases.Count + 1}@{Path.GetFileName(path)}", fullPath, LineOf(fileData, separatorStart))); + } + + return cases; + } + + /// 1-based line of the character at . + private static int LineOf(string text, int index) + { + var line = 1; + + for (var i = 0; i < index && i < text.Length; i++) + { + if (text[i] == '\n') + { + line++; + } + } + + return line; + } + + private static IDeserializer NewDeserializer(MethodInfo testMethod) + { + return new DeserializerBuilder() + .WithNodeTypeResolver(new MDATYamlTypeResolver(testMethod)) + .WithTypeConverter(new ByteArayConverter()) + .WithAttemptingUnquotedStringTypeDeserialization() + .IgnoreUnmatchedProperties() + .Build(); + } +} diff --git a/src/MDAT.TUnit/LegacyAttributeNames.cs b/src/MDAT.TUnit/LegacyAttributeNames.cs new file mode 100644 index 0000000..b63e44b --- /dev/null +++ b/src/MDAT.TUnit/LegacyAttributeNames.cs @@ -0,0 +1,28 @@ +using System.Runtime.CompilerServices; + +namespace MDAT; + +/// +/// The name used by mdAT 1.x: exactly the same as . +/// Both names are supported, so test files written for mdAT 1.x do not have to change. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public sealed class MarkdownTestAttribute : MarkdownDataSourceAttribute +{ + public MarkdownTestAttribute(string filePath, [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLine = 0) + : base(filePath, sourceFilePath, sourceLine) + { + } +} + +/// +/// The name used by mdAT 1.x: exactly the same as . +/// Both names are supported, so test files written for mdAT 1.x do not have to change. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public sealed class YamlFileDataAttribute : YamlFileDataSourceAttribute +{ + public YamlFileDataAttribute(string filePath) : base(filePath) + { + } +} diff --git a/src/MDAT.TUnit/MDAT.TUnit.csproj b/src/MDAT.TUnit/MDAT.TUnit.csproj new file mode 100644 index 0000000..d7d10ec --- /dev/null +++ b/src/MDAT.TUnit/MDAT.TUnit.csproj @@ -0,0 +1,46 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + MDAT + MDAT.TUnit + False + MDAT.TUnit + false + anisite + MESS + https://github.com/MTESSDev/mdAT + https://github.com/MTESSDev/mdAT + 2.0.0 + Markdown Auto-Tests for TUnit (.net 8+): markdown test cases as TUnit data sources. + markdown;testing;yaml;tunit;test-automation + mdAT 2.0: TUnit data sources replacing the MSTest ones, net8.0/net9.0/net10.0. In the markdown files: a test case is named Method_Title@file.md; the test explorer points each case to its title in the markdown file (or to the start of its document in a YAML file); each markdown test file keeps a link to its C# test; a case marked skipped is listed and reported as ignored, and when a case is marked selected the other cases of the file stay in the list, ignored; a case can have categories and properties (category=Smoke,Fast priority=High in the line of its code block, tags: in the front matter of the file); a JsonDocument or JsonElement whose root is a string holding a JSON object or array is written as that object or array again (mdAT 1.4 behaviour); the file of generateExpectedData is written again when another process is using it. See the migration section of the README. + git + true + snupkg + README.md + LICENSE + + + + + True + \ + + + True + \ + + + + + + + + + + + + diff --git a/src/MDAT.TUnit/MarkdownDataSourceAttribute.cs b/src/MDAT.TUnit/MarkdownDataSourceAttribute.cs new file mode 100644 index 0000000..c9d67a0 --- /dev/null +++ b/src/MDAT.TUnit/MarkdownDataSourceAttribute.cs @@ -0,0 +1,93 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using TUnit.Core; +using TUnit.Core.Interfaces; + +namespace MDAT; + +/// +/// TUnit data source reading test cases from a markdown file, e.g. [MarkdownDataSource("~/Tests/{method}.md")]. +/// Rows are produced in-process as factories, so arguments are never serialized by the test framework. +/// The markdown file keeps a link to the C# test that reads it (see ). +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public class MarkdownDataSourceAttribute : Attribute, IDataSourceAttribute, ITestDiscoveryEventReceiver +{ + private readonly string _filePath; + private readonly string _sourceFilePath; + private readonly int _sourceLine; + + /// The markdown file, e.g. ~/Tests/{method}.md. + /// Filled by the compiler: the C# file this attribute is written in. + /// Filled by the compiler: the line of this attribute. + public MarkdownDataSourceAttribute(string filePath, [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLine = 0) + { + _filePath = filePath; + _sourceFilePath = sourceFilePath; + _sourceLine = sourceLine; + } + + public int Order => 0; + + public bool SkipIfEmpty { get; set; } + + public bool DeferEnumeration { get; set; } + + internal string FilePathTemplate => _filePath; + + internal string DeclaredSourceFilePath => _sourceFilePath; + + internal int DeclaredSourceLine => _sourceLine; + + /// Points the test explorer to the case in its file (see ). + public ValueTask OnTestDiscovered(DiscoveredTestContext context) + { + TestCaseLocations.Apply(context); + return default; + } + + public async IAsyncEnumerable>> GetDataRowsAsync(DataGeneratorMetadata dataGeneratorMetadata) + { + await Task.CompletedTask; + + var method = TestMethodResolver.Resolve(dataGeneratorMetadata.TestInformation); + + var source = new MarkdownTestSource(_filePath); + var markdownPath = source.ResolvePath(method); + + var (sourceFilePath, sourceLine) = DeclaredLocation(method); + var own = new SourceLinkEntry(sourceFilePath, sourceLine, $"{method.DeclaringType?.Name}.{method.Name}"); + + // The links of all the tests that read this file are written at once: the lines of the cases are then right for every + // test of a shared file, none of them is moved afterwards by the link another test adds. + var entries = SourceLinkPlan.For(method.DeclaringType!.Assembly, Path.GetFullPath(markdownPath), own); + + // Several tests may read the same markdown file while the links are being written: one at a time + var testCases = MarkdownSourceLink.Synchronized(markdownPath, () => + { + var cases = source.GetCases(method); + + // Links were inserted or corrected in the markdown file: the lines of the cases have moved, so they are read again + return MarkdownSourceLink.Update(markdownPath, entries) + ? new MarkdownTestSource(_filePath).GetCases(method) + : cases; + }); + + foreach (var testCase in testCases) + { + yield return TestCaseLocations.ToRow(testCase, dataGeneratorMetadata.TestInformation.Class.Type); + } + } + + /// + /// Where the attribute is written. TUnit builds its own instance of the attribute in generated code, where the compiler + /// fills the caller information with the generated file: the one read from the metadata of the method is the right one. + /// + private (string File, int Line) DeclaredLocation(MethodInfo method) + { + var declared = method.GetCustomAttributes(inherit: false) + .FirstOrDefault(a => a._filePath == _filePath); + + return declared is null ? (_sourceFilePath, _sourceLine) : (declared._sourceFilePath, declared._sourceLine); + } +} diff --git a/src/MDAT.TUnit/SourceLinkPlan.cs b/src/MDAT.TUnit/SourceLinkPlan.cs new file mode 100644 index 0000000..99216cf --- /dev/null +++ b/src/MDAT.TUnit/SourceLinkPlan.cs @@ -0,0 +1,86 @@ +using System.Collections.Concurrent; +using System.Reflection; + +namespace MDAT; + +/// +/// All the tests of an assembly that read each markdown file, found once by reading the attributes of the assembly. +/// It lets the first test that reaches a markdown file write the links of every test that shares it in one go. +/// +internal static class SourceLinkPlan +{ + private static readonly ConcurrentDictionary>>> ByAssembly = new(); + + private const BindingFlags AllMethods = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; + + /// The tests that read , including , the one being discovered. + public static IReadOnlyList For(Assembly assembly, string markdownFullPath, SourceLinkEntry own) + { + var plan = ByAssembly.GetOrAdd(assembly, a => new Lazy>>(() => Build(a))).Value; + + var entries = plan.TryGetValue(markdownFullPath, out var found) ? found.ToList() : new List(); + + // the scan can miss a test (a type that cannot be loaded, ...): the test being discovered always has its link + if (!entries.Any(e => e.TestName == own.TestName && e.SourceFilePath == own.SourceFilePath)) + { + entries.Add(own); + } + + return entries; + } + + private static Dictionary> Build(Assembly assembly) + { + var plan = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var type in TypesOf(assembly)) + { + foreach (var method in type.GetMethods(AllMethods)) + { + IEnumerable attributes; + + try + { + attributes = method.GetCustomAttributes(inherit: false).ToList(); + } + catch (Exception ex) when (ex is TypeLoadException or FileNotFoundException or BadImageFormatException) + { + continue; + } + + foreach (var attribute in attributes) + { + try + { + var path = Path.GetFullPath(new MarkdownTestSource(attribute.FilePathTemplate).ResolvePath(method)); + + if (!plan.TryGetValue(path, out var entries)) + { + plan[path] = entries = new List(); + } + + entries.Add(new SourceLinkEntry(attribute.DeclaredSourceFilePath, attribute.DeclaredSourceLine, $"{type.Name}.{method.Name}")); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or IOException) + { + // a path that cannot be resolved is left to the data source of that test + } + } + } + } + + return plan; + } + + private static IEnumerable TypesOf(Assembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + return ex.Types.Where(t => t is not null)!; + } + } +} diff --git a/src/MDAT.TUnit/TestCaseLocations.cs b/src/MDAT.TUnit/TestCaseLocations.cs new file mode 100644 index 0000000..5954dc0 --- /dev/null +++ b/src/MDAT.TUnit/TestCaseLocations.cs @@ -0,0 +1,67 @@ +using System.Collections.Concurrent; +using TUnit.Core; + +namespace MDAT; + +/// +/// Remembers where each test case is written, to point the test explorer to it: double clicking a case then opens its +/// title in the markdown file instead of the C# method shared by all the cases. +/// +internal static class TestCaseLocations +{ + // The display name no longer holds the path of the file: the test class keeps two classes apart + private static readonly ConcurrentDictionary ByDisplayName = new(StringComparer.Ordinal); + + // The properties (priority=High) cannot go on the data row like the categories: they are given when the test is discovered + private static readonly ConcurrentDictionary>> PropertiesByDisplayName = new(StringComparer.Ordinal); + + private static string Key(Type? testClass, string displayName) => $"{testClass?.FullName}|{displayName}"; + + public static Func> ToRow(MarkdownTestCase testCase, Type testClass) + { + if (!string.IsNullOrEmpty(testCase.FilePath) && testCase.Line > 0) + { + ByDisplayName[Key(testClass, testCase.DisplayName)] = (testCase.FilePath, testCase.Line); + } + + if (testCase.Properties.Count > 0) + { + // Two cases with the same name cannot be told apart here: when their properties differ, none gets any, so that + // a case never receives the properties of another one. + PropertiesByDisplayName.AddOrUpdate( + Key(testClass, testCase.DisplayName), + _ => testCase.Properties, + (_, known) => known.SequenceEqual(testCase.Properties) ? known : Array.Empty>()); + } + + // a case marked "skipped" in the markdown file is reported as ignored by the test explorer and never run; + // its categories (category=Smoke,Fast, or tags: category: in the front matter) are the ones of the test + var row = new TestDataRow( + testCase.Arguments, + DisplayName: testCase.DisplayName, + Skip: testCase.SkipReason, + Categories: testCase.Categories.Count > 0 ? testCase.Categories.ToArray() : null); + + return () => Task.FromResult(new object?[] { row }); + } + + /// Gives the discovered test the file and the line of its case, and its properties, when it comes from a data source of mdAT. + public static void Apply(DiscoveredTestContext context) + { + var key = Key(context.TestDetails.ClassType, context.GetDisplayName()); + + if (ByDisplayName.TryGetValue(key, out var location)) + { + context.TestDetails.TestFilePath = location.File; + context.TestDetails.TestLineNumber = location.Line; + } + + if (PropertiesByDisplayName.TryGetValue(key, out var properties)) + { + foreach (var property in properties) + { + context.AddProperty(property.Key, property.Value); + } + } + } +} diff --git a/src/MDAT.TUnit/TestMethodResolver.cs b/src/MDAT.TUnit/TestMethodResolver.cs new file mode 100644 index 0000000..ee5497a --- /dev/null +++ b/src/MDAT.TUnit/TestMethodResolver.cs @@ -0,0 +1,20 @@ +using System.Reflection; +using TUnit.Core; + +namespace MDAT; + +internal static class TestMethodResolver +{ + public static MethodInfo Resolve(MethodMetadata metadata) + { + var parameterTypes = metadata.Parameters.Select(p => p.Type).ToArray(); + + return metadata.Class.Type.GetMethod( + metadata.Name, + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static, + binder: null, + parameterTypes, + modifiers: null) + ?? throw new InvalidOperationException($"Could not resolve test method '{metadata.Class.Type.FullName}.{metadata.Name}'."); + } +} diff --git a/src/MDAT.TUnit/YamlFileDataSourceAttribute.cs b/src/MDAT.TUnit/YamlFileDataSourceAttribute.cs new file mode 100644 index 0000000..7dcca44 --- /dev/null +++ b/src/MDAT.TUnit/YamlFileDataSourceAttribute.cs @@ -0,0 +1,43 @@ +using TUnit.Core; +using TUnit.Core.Interfaces; + +namespace MDAT; + +/// +/// TUnit data source reading test cases from a multi-document YAML file (documents separated by ---). +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public class YamlFileDataSourceAttribute : Attribute, IDataSourceAttribute, ITestDiscoveryEventReceiver +{ + private readonly string _filePath; + + public YamlFileDataSourceAttribute(string filePath) + { + _filePath = filePath; + } + + public int Order => 0; + + public bool SkipIfEmpty { get; set; } + + public bool DeferEnumeration { get; set; } + + /// Points the test explorer to the case in its file (see ). + public ValueTask OnTestDiscovered(DiscoveredTestContext context) + { + TestCaseLocations.Apply(context); + return default; + } + + public async IAsyncEnumerable>> GetDataRowsAsync(DataGeneratorMetadata dataGeneratorMetadata) + { + await Task.CompletedTask; + + var method = TestMethodResolver.Resolve(dataGeneratorMetadata.TestInformation); + + foreach (var testCase in new YamlFileTestSource(_filePath).GetCases(method)) + { + yield return TestCaseLocations.ToRow(testCase, dataGeneratorMetadata.TestInformation.Class.Type); + } + } +} diff --git a/src/MDATTests/.config/dotnet-tools.json b/src/MDAT.Tests/.config/dotnet-tools.json similarity index 100% rename from src/MDATTests/.config/dotnet-tools.json rename to src/MDAT.Tests/.config/dotnet-tools.json diff --git a/src/MDAT.Tests/AdapterTests.cs b/src/MDAT.Tests/AdapterTests.cs new file mode 100644 index 0000000..1944157 --- /dev/null +++ b/src/MDAT.Tests/AdapterTests.cs @@ -0,0 +1,78 @@ +namespace MDAT.Tests; + +/// +/// Tests of the TUnit data sources: every case of a markdown file is its own test and the arguments reach +/// the test method as built from the YAML, whatever their type. +/// +public class AdapterTests +{ + public class Poco + { + public string? A { get; set; } + + public int B { get; private set; } + } + + public class WithDictionary + { + public Dictionary? D { get; set; } + } + + [Test] + [MarkdownDataSource("~/Tests/adapter-simple.md")] + public async Task Simple_values(int a, string b) + { + await Assert.That(b).IsNotNull(); + await Assert.That(a).IsGreaterThan(0); + } + + [Test] + [MarkdownDataSource("~/Tests/adapter-dateonly.md")] + public async Task DateOnly_and_TimeOnly_arrive_intact(DateOnly date, TimeOnly time) + { + await Assert.That(date.Year).IsEqualTo(2024); + await Assert.That(time.Hour).IsGreaterThan(0); + } + + [Test] + [MarkdownDataSource("~/Tests/adapter-dictionary.md")] + public async Task Dictionary_of_objects_arrives_intact(WithDictionary value) + { + await Assert.That(value.D).IsNotNull(); + await Assert.That(value.D!["k"]).IsTypeOf>(); + } + + [Test] + [MarkdownDataSource("~/Tests/adapter-expected.md")] + public async Task Expected_with_verification_steps(Dictionary input, Expected expected) + { + await Assert.That(input.Count).IsEqualTo(1); + await Assert.That(expected.verify!.Count()).IsEqualTo(1); + } + + [Test] + [MarkdownDataSource("~/Tests/adapter-ignore-array-order.md")] + public async Task Array_order_is_ignored_when_the_test_case_asks_for_it(List values, List items, Expected expected) + { + _ = await Verify.Assert(() => Task.FromResult(new { values, items }), expected); + } +} + +/// The names used by mdAT 1.x still work, without any warning, and read the same files. +public class LegacyAttributeNamesTests +{ + [Test] + [MarkdownTest("~/Tests/adapter-simple.md")] + public async Task MarkdownTest_is_an_alias_of_MarkdownDataSource(int a, string b) + { + await Assert.That(b).IsNotNull(); + await Assert.That(a).IsGreaterThan(0); + } + + [Test] + [YamlFileData("~/Tests/test.yaml")] + public async Task YamlFileData_is_an_alias_of_YamlFileDataSource(int val1, int val2, string expected) + { + _ = await Verify.Assert(() => Task.FromResult(val1 + val2), expected); + } +} diff --git a/src/MDATTests/AssertTests.cs b/src/MDAT.Tests/AssertTests.cs similarity index 94% rename from src/MDATTests/AssertTests.cs rename to src/MDAT.Tests/AssertTests.cs index 7d7dd09..991b925 100644 --- a/src/MDATTests/AssertTests.cs +++ b/src/MDAT.Tests/AssertTests.cs @@ -1,7 +1,6 @@ using MDATTests; using MDATTests.Models; using Microsoft.AspNetCore.Mvc; -using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; using System.Diagnostics.CodeAnalysis; using System.Linq.Expressions; @@ -12,20 +11,20 @@ namespace MDAT.Tests /// /// Ignored comment /// - [TestClass] + public class AssertTests { /// /// Run without expected obj /// - [TestMethod] + [Test] public async Task Test_no_expected() { object value = await Verify.Assert(() => Task.FromResult((object)null!), (Expected)null!); } - [TestMethod] + [Test] public async Task Test_numeric() { object value = await Verify.Assert(() => @@ -35,7 +34,7 @@ public async Task Test_numeric() /// /// Run without expected obj /// - [TestMethod] + [Test] public async Task Test_expected_data_is_string() { object value = await Verify.Assert(() => @@ -45,7 +44,7 @@ public async Task Test_expected_data_is_string() /// /// Test Stream output /// - [TestMethod] + [Test] public async Task Test_stream_output_as_base64() { var memory = new MemoryStream(new byte[] { 0x01, 0x02, 0x03, 0x04 }); @@ -57,7 +56,7 @@ public async Task Test_stream_output_as_base64() /// /// Test Stream output /// - [TestMethod] + [Test] public async Task Test_stream_output_as_base64_result() { var mockTest = new Mock(); diff --git a/src/MDAT.Tests/CaseNameTests.cs b/src/MDAT.Tests/CaseNameTests.cs new file mode 100644 index 0000000..9c6b5d9 --- /dev/null +++ b/src/MDAT.Tests/CaseNameTests.cs @@ -0,0 +1,80 @@ +using System.Reflection; +using System.Text.Json.Nodes; + +namespace MDAT.Tests; + +/// The name of a test case is built from the method and the titles above the case, without repeating the method. +public class CaseNameTests +{ + // Same signature as the tests of the markdown files written below + public void Some_method_name(string x) { } + + private static MethodInfo SomeMethod => typeof(CaseNameTests).GetMethod(nameof(Some_method_name))!; + + private static string[] Names(string markdown) + { + var folder = Directory.CreateTempSubdirectory("mdat-names-"); + + try + { + var path = Path.Combine(folder.FullName, "cases.md"); + File.WriteAllText(path, markdown); + + return new MarkdownTestSource(path).GetCases(SomeMethod).Select(c => c.DisplayName).ToArray(); + } + finally + { + folder.Delete(recursive: true); + } + } + + private static string Cases(string title) => + $"{title}\n\n## Case 1\n\n``````yaml\nx: a\n``````\n\n## Case 2\n\n### Detail\n\n``````yaml\nx: b\n``````\n"; + + [Test] + public async Task A_title_equal_to_the_method_name_is_not_repeated() + { + var names = Names(Cases("# Some_method_name")); + + await Assert.That(names).IsEquivalentTo(new[] { "Some_method_name_Case 1@cases.md", "Some_method_name_Case 2_Detail@cases.md" }); + } + + [Test] + [Arguments("# Some method name")] + [Arguments("# SOME-METHOD-NAME")] + [Arguments("# some_method_name")] + public async Task The_comparison_ignores_case_spaces_and_separators(string title) + { + var names = Names(Cases(title)); + + await Assert.That(names[0]).IsEqualTo("Some_method_name_Case 1@cases.md"); + } + + [Test] + public async Task A_title_that_is_not_the_method_name_is_kept() + { + var names = Names(Cases("# Addition rules")); + + await Assert.That(names[0]).IsEqualTo("Some_method_name_Addition rules_Case 1@cases.md"); + await Assert.That(names[1]).IsEqualTo("Some_method_name_Addition rules_Case 2_Detail@cases.md"); + } + + [Test] + public async Task A_file_with_only_the_method_title_gives_the_method_and_the_file() + { + var names = Names("# Some_method_name\n\n``````yaml\nx: a\n``````\n"); + + await Assert.That(names.Single()).IsEqualTo("Some_method_name@cases.md"); + } + + [Test] + public async Task The_real_files_of_the_project_follow_the_same_rule() + { + var method = typeof(NewtonsoftConvertersTests).GetMethod(nameof(NewtonsoftConvertersTests.Test_JsonNode))!; + + var names = new MarkdownTestSource("~\\Tests\\test-json-node.md").GetCases(method).Select(c => c.DisplayName).ToArray(); + + await Assert.That(names.First()).IsEqualTo("Test_JsonNode_Case 1@test-json-node.md"); + await Assert.That(names.First()).DoesNotContain("Test_JsonNode_Test_JsonNode"); + } +} diff --git a/src/MDAT.Tests/CategoryTests.cs b/src/MDAT.Tests/CategoryTests.cs new file mode 100644 index 0000000..d1ec9e6 --- /dev/null +++ b/src/MDAT.Tests/CategoryTests.cs @@ -0,0 +1,194 @@ +using System.Reflection; + +namespace MDAT.Tests; + +/// +/// The tags of a case: category=Smoke,Fast priority=High in the line of its code block, added to tags: in the +/// front matter of the file. They are the categories and the properties of the test in the test explorer. +/// +public class CategoryTests +{ + /// What TUnit reports for each case of test-categories.md. + [Test] + [MarkdownDataSource("~/Tests/test-categories.md")] + public async Task Md_Categories(string name, string expectedCategories, string expectedProperties) + { + // the cases of the file marked skipped are ignored: if one of them reaches this method, it is a mistake + await Assert.That(new[] { "ignored", "ignored too", "unfinished" }.Contains(name)).IsFalse(); + + var details = TestContext.Current!.Metadata.TestDetails; + + var categories = details.Categories.OrderBy(c => c, StringComparer.Ordinal); + var properties = details.CustomProperties.SelectMany(p => p.Value.Select(value => $"{p.Key}={value}")).OrderBy(p => p, StringComparer.Ordinal); + + await Assert.That(string.Join(",", categories)).IsEqualTo(expectedCategories); + await Assert.That(string.Join(";", properties)).IsEqualTo(expectedProperties); + } + + /// + /// test-categories-selected.md has a selected case: it is the only one that runs, the others are ignored + /// (if one of them ran, its name would not be "focus"). + /// + [Test] + [MarkdownDataSource("~/Tests/test-categories-selected.md")] + public async Task Md_Selected(string name, string expectedCategories) + { + var categories = TestContext.Current!.Metadata.TestDetails.Categories.OrderBy(c => c, StringComparer.Ordinal); + + await Assert.That(name).IsEqualTo("focus"); + await Assert.That(string.Join(",", categories)).IsEqualTo(expectedCategories); + } + + [Test] + public async Task The_ignored_cases_of_the_real_files_are_listed_with_their_tags() + { + var all = new MarkdownTestSource("~/Tests/test-categories.md").GetCases(typeof(CategoryTests).GetMethod(nameof(Md_Categories))!); + var ignored = all.Where(c => c.SkipReason is not null).ToList(); + + await Assert.That(all.Count).IsEqualTo(6); + await Assert.That(ignored.Count).IsEqualTo(3); + await Assert.That(ignored.All(c => c.SkipReason == MarkdownTestSource.SkippedReason)).IsTrue(); + + // an ignored case keeps the tags of the file and its own, wherever they are written in the line + await Assert.That(ignored[0].Categories).IsEquivalentTo(new[] { "Facturation", "Lent" }); + await Assert.That(ignored[1].Categories).IsEquivalentTo(new[] { "Facturation", "Lent" }); + await Assert.That(ignored[1].Properties.Select(p => $"{p.Key}={p.Value}")).IsEquivalentTo(new[] { "domaine=Taxes", "priorite=Basse" }); + await Assert.That(ignored[2].Categories).IsEquivalentTo(new[] { "Facturation", "Brouillon" }); + } + + [Test] + public async Task In_the_real_file_with_a_selected_case_only_that_case_runs() + { + var all = new MarkdownTestSource("~/Tests/test-categories-selected.md").GetCases(typeof(CategoryTests).GetMethod(nameof(Md_Selected))!); + + await Assert.That(all.Count).IsEqualTo(4); + await Assert.That(all.Count(c => c.SkipReason is null)).IsEqualTo(1); + + await Assert.That(all[0].SkipReason).IsEqualTo(MarkdownTestSource.NotSelectedReason); + await Assert.That(all[1].SkipReason).IsNull(); + await Assert.That(all[2].SkipReason).IsEqualTo(MarkdownTestSource.SkippedReason); + await Assert.That(all[3].SkipReason).IsEqualTo(MarkdownTestSource.NotSelectedReason); + + // the ignored cases keep their categories (those of the file and their own) + await Assert.That(all[0].Categories).IsEquivalentTo(new[] { "Facturation", "Autre" }); + await Assert.That(all[2].Categories).IsEquivalentTo(new[] { "Facturation", "Jamais" }); + } + + // Same signature as the tests of the markdown files written below + public void Some_method(string x) { } + + private static MethodInfo SomeMethod => typeof(CategoryTests).GetMethod(nameof(Some_method))!; + + private static IReadOnlyList Read(string markdown) + { + var folder = Directory.CreateTempSubdirectory("mdat-categories-"); + + try + { + var path = Path.Combine(folder.FullName, "cases.md"); + File.WriteAllText(path, markdown); + + return new MarkdownTestSource(path).GetCases(SomeMethod); + } + finally + { + folder.Delete(recursive: true); + } + } + + private static string Case(string info = "") => $"## Case\n\n``````yaml {info}\nx: a\n``````\n\n"; + + [Test] + public async Task The_categories_of_the_file_and_of_the_case_add_up_without_repeating() + { + var cases = Read("---\ntags:\n category: Billing, Smoke\n---\n# Title\n\n" + Case("category=Smoke,Fast")); + + await Assert.That(cases.Count).IsEqualTo(1); + await Assert.That(cases[0].Categories).IsEquivalentTo(new[] { "Billing", "Smoke", "Fast" }); + } + + [Test] + public async Task The_categories_of_the_front_matter_can_be_a_list() + { + var cases = Read("---\ntags:\n category:\n - Billing\n - Slow\n---\n# Title\n\n" + Case()); + + await Assert.That(cases[0].Categories).IsEquivalentTo(new[] { "Billing", "Slow" }); + } + + [Test] + public async Task A_file_without_tags_gives_cases_without_categories() + { + await Assert.That(Read("# Title\n\n" + Case())[0].Categories.Count).IsEqualTo(0); + await Assert.That(Read("---\ntitle: nothing here\n---\n# Title\n\n" + Case())[0].Categories.Count).IsEqualTo(0); + } + + private static string[] Properties(MarkdownTestCase testCase) => testCase.Properties.Select(p => $"{p.Key}={p.Value}").ToArray(); + + [Test] + public async Task A_name_other_than_category_is_a_property_and_not_a_category() + { + var cases = Read("---\ntags:\n area: Taxes\n---\n# Title\n\n" + Case("priority=High flag")); + + await Assert.That(cases[0].Categories.Count).IsEqualTo(0); + await Assert.That(Properties(cases[0])).IsEquivalentTo(new[] { "area=Taxes", "priority=High" }); + } + + [Test] + public async Task A_property_of_the_case_replaces_the_one_of_the_file_with_the_same_name() + { + var cases = Read("---\ntags:\n priority: High\n area: Taxes\n---\n# Title\n\n" + Case("priority=Low")); + + await Assert.That(Properties(cases[0])).IsEquivalentTo(new[] { "area=Taxes", "priority=Low" }); + } + + [Test] + public async Task A_list_in_the_front_matter_gives_one_property_per_item_and_a_value_can_be_quoted() + { + var cases = Read("---\ntags:\n env: [dev, test]\n---\n# Title\n\n" + Case("owner=\"Billing team\"")); + + await Assert.That(Properties(cases[0])).IsEquivalentTo(new[] { "env=dev", "env=test", "owner=Billing team" }); + } + + [Test] + public async Task An_ignored_case_keeps_its_properties_too() + { + var cases = Read("# Title\n\n" + Case("skipped priority=High")); + + await Assert.That(cases[0].SkipReason).IsNotNull(); + await Assert.That(Properties(cases[0])).IsEquivalentTo(new[] { "priority=High" }); + } + + [Test] + public async Task A_value_with_spaces_can_be_quoted_and_the_name_is_not_case_sensitive() + { + var cases = Read("# Title\n\n" + Case("Category=\"Very slow, Heavy\"")); + + await Assert.That(cases[0].Categories).IsEquivalentTo(new[] { "Very slow", "Heavy" }); + } + + [Test] + public async Task A_front_matter_that_cannot_be_read_does_not_stop_the_cases() + { + var cases = Read("---\ntags: [unclosed\n - : :\n---\n# Title\n\n" + Case("category=Smoke")); + + await Assert.That(cases.Count).IsEqualTo(1); + await Assert.That(cases[0].Categories).IsEquivalentTo(new[] { "Smoke" }); + } + + [Test] + public async Task The_front_matter_is_not_taken_for_a_title_of_the_case() + { + var cases = Read("---\ntags:\n category: Billing\n---\n# Some_method\n\n" + Case()); + + await Assert.That(cases[0].DisplayName).IsEqualTo("Some_method_Case@cases.md"); + } + + [Test] + public async Task An_ignored_case_keeps_its_categories_so_that_it_can_still_be_filtered() + { + var cases = Read("# Title\n\n" + Case("skipped category=Smoke")); + + await Assert.That(cases[0].SkipReason).IsNotNull(); + await Assert.That(cases[0].Categories).IsEquivalentTo(new[] { "Smoke" }); + } +} diff --git a/src/MDAT.Tests/CustomResolverTests.cs b/src/MDAT.Tests/CustomResolverTests.cs new file mode 100644 index 0000000..8b7fe7a --- /dev/null +++ b/src/MDAT.Tests/CustomResolverTests.cs @@ -0,0 +1,37 @@ +using MDAT; +using MDATTests; +using MDATTests.Resolver; +using Microsoft.Extensions.Primitives; + +namespace MDAT.Tests; + +public class CustomResolverTests +{ + /// + /// Test AddYamlTypeConverter. + /// is global: the converter is registered for the duration of the test only, so the + /// other tests (which read their cases during discovery, before any test runs) are not affected. + /// + [Test] + [NotInParallel] + public async Task Test_AddYamlTypeConverter() + { + var converter = new StringValuesTestYamlTypeConverter(); + MdatConfig.AddYamlTypeConverter(converter); + + try + { + var method = Utils.GetMethodInfo(() => ConverterTarget); + var testCase = new MarkdownTestSource("~\\Tests\\test-add-yaml-type-converter.md").GetCases(method).Single(); + + _ = await Verify.Assert(() => Task.FromResult((StringValues)testCase.Arguments[0]!), (Expected)testCase.Arguments[1]!); + } + finally + { + MdatConfig.ListeTypeConverter.RemoveAll(c => ReferenceEquals(c.TypeConverter, converter)); + } + } + + /// Signature read by the markdown file of . + public void ConverterTarget(StringValues input, Expected expected) { } +} diff --git a/src/MDATTests/GlobalSuppressions.cs b/src/MDAT.Tests/GlobalSuppressions.cs similarity index 100% rename from src/MDATTests/GlobalSuppressions.cs rename to src/MDAT.Tests/GlobalSuppressions.cs diff --git a/src/MDATTests/IFormulaireWebFRW1DO.cs b/src/MDAT.Tests/IFormulaireWebFRW1DO.cs similarity index 100% rename from src/MDATTests/IFormulaireWebFRW1DO.cs rename to src/MDAT.Tests/IFormulaireWebFRW1DO.cs diff --git a/src/MDAT.Tests/JsonAssertTests.cs b/src/MDAT.Tests/JsonAssertTests.cs new file mode 100644 index 0000000..3ee75f0 --- /dev/null +++ b/src/MDAT.Tests/JsonAssertTests.cs @@ -0,0 +1,184 @@ +namespace MDAT.Tests; + +/// Tests of the JSON comparison engine (). +public class JsonAssertTests +{ + private static JsonAssertException? Diff(string expected, string actual, bool allowAdditionalProperties = false, bool ignoreArrayOrder = false) + { + try + { + JsonAssert.EqualOverrideDefault(expected, actual, new JsonDiffConfig(allowAdditionalProperties, ignoreArrayOrder)); + return null; + } + catch (JsonAssertException ex) + { + return ex; + } + } + + [Test] + public async Task Equal_documents_do_not_throw() + { + await Assert.That(Diff("""{"a":[1,2,{"b":null}],"c":"x"}""", """{ "c": "x", "a": [1, 2, { "b": null }] }""")).IsNull(); + } + + [Test] + [Arguments("1", "1.0")] + [Arguments("1e2", "100")] + [Arguments("100", "1E2")] + [Arguments("-0", "0")] + [Arguments("0.10", "0.1")] + public async Task Numbers_are_equal_whatever_their_spelling(string expected, string actual) + { + await Assert.That(Diff(expected, actual)).IsNull(); + } + + [Test] + public async Task Large_numbers_are_compared_exactly() + { + var diff = Diff("12345678901234567890", "12345678901234567891"); + + await Assert.That(diff).IsNotNull(); + await Assert.That(diff!.UserMessage).IsEqualTo("Number value mismatch at $.\nExpected 12345678901234567890 but was 12345678901234567891."); + } + + [Test] + public async Task Messages_describe_the_difference_and_where_it_is() + { + var diff = Diff("""{"a":{"b":[1,2,3]}}""", """{"a":{"b":[1,9,3]}}"""); + + await Assert.That(diff!.UserMessage).IsEqualTo("Number value mismatch at $.a.b[1].\nExpected 2 but was 9."); + } + + [Test] + public async Task Missing_and_additional_properties_are_listed() + { + var diff = Diff("""{"a":1,"b":2}""", """{"b":2,"c":3}"""); + + await Assert.That(diff!.UserMessage).IsEqualTo("Object mismatch at $.\nMissing property:\n - 'a' (number)\nAdditional property:\n + 'c' (number)"); + } + + [Test] + public async Task Additional_properties_can_be_allowed() + { + await Assert.That(Diff("""{"a":1}""", """{"a":1,"b":[1]}""", allowAdditionalProperties: true)).IsNull(); + await Assert.That(Diff("""{"a":1,"b":2}""", """{"a":1}""", allowAdditionalProperties: true)).IsNotNull(); + } + + [Test] + public async Task Property_names_with_quotes_are_escaped_in_paths() + { + var diff = Diff("""{"it's":1}""", """{"it's":2}"""); + + await Assert.That(diff!.UserMessage).Contains("$['it\\'s']"); + } + + [Test] + public async Task Duplicate_properties_are_rejected_instead_of_silently_ignored() + { + ArgumentException? error = null; + + try + { + Diff("""{"a":1,"a":2}""", """{"a":1}"""); + } + catch (ArgumentException ex) + { + error = ex; + } + + await Assert.That(error).IsNotNull(); + await Assert.That(error!.InnerException!.Message).Contains("Duplicate property 'a'"); + } + + [Test] + public async Task Invalid_json_is_reported_with_the_name_of_the_faulty_side() + { + ArgumentException? error = null; + + try + { + JsonAssert.Equal("[1,", "[1]"); + } + catch (ArgumentException ex) + { + error = ex; + } + + await Assert.That(error!.ParamName).IsEqualTo("rightJsonString"); + } + + [Test] + public async Task Every_repeated_item_of_an_array_is_reported() + { + // Quibble drops the extra "null" from its message and may even report an empty "Array mismatch" + var diff = Diff("[true,null,3]", "[true,null,null,3]"); + + await Assert.That(diff!.UserMessage).IsEqualTo("Array mismatch at $.\nAdditional item:\n + [2]: null"); + } + + [Test] + public async Task Array_items_are_paired_by_value_not_by_spelling() + { + // 1 and 1.0 are the same value: only the extra item is a difference + var diff = Diff("[1,2]", "[1.0,2,3]"); + + await Assert.That(diff!.UserMessage).IsEqualTo("Array mismatch at $.\nAdditional item:\n + [2]: the number 3"); + } + + [Test] + public async Task Array_items_that_replace_each_other_are_compared_in_depth() + { + var diff = Diff("""[{"id":1,"v":"a"},{"id":2,"v":"b"}]""", """[{"id":1,"v":"a"},{"id":2,"v":"c"}]"""); + + await Assert.That(diff!.UserMessage).IsEqualTo("String value mismatch at $[1].v.\nExpected b but was c."); + } + + [Test] + public async Task Array_order_matters_by_default() + { + await Assert.That(Diff("[1,2,3]", "[3,2,1]")).IsNotNull(); + } + + [Test] + public async Task Array_order_can_be_ignored() + { + await Assert.That(Diff("[1,2,3]", "[3,1,2]", ignoreArrayOrder: true)).IsNull(); + await Assert.That(Diff("""{"a":["x",{"b":[2,1]}]}""", """{"a":[{"b":[1,2]},"x"]}""", ignoreArrayOrder: true)).IsNull(); + } + + [Test] + public async Task Ignoring_array_order_still_counts_duplicates() + { + var diff = Diff("[1,1,2]", "[1,2,2]", ignoreArrayOrder: true); + + await Assert.That(diff!.UserMessage).IsEqualTo("Array mismatch at $.\nMissing item:\n - [1]: the number 1\nAdditional item:\n + [2]: the number 2"); + } + + [Test] + public async Task Ignoring_array_order_reports_the_values_that_differ() + { + var diff = Diff("[1,2,3]", "[3,9,1]", ignoreArrayOrder: true); + + await Assert.That(diff!.UserMessage).IsEqualTo("Array mismatch at $.\nMissing item:\n - [1]: the number 2\nAdditional item:\n + [1]: the number 9"); + } + + [Test] + public async Task Ignoring_array_order_finds_the_best_pairing_with_allowed_additional_properties() + { + // {"a":1} fits both actual items, {"a":1,"b":2} fits only one: a greedy pairing would fail here + var diff = Diff("""[{"a":1},{"a":1,"b":2}]""", """[{"a":1,"b":2},{"a":1,"c":3}]""", allowAdditionalProperties: true, ignoreArrayOrder: true); + + await Assert.That(diff).IsNull(); + } + + [Test] + public async Task Exception_message_contains_the_compared_documents() + { + var diff = Diff("1", "2"); + + await Assert.That(diff!.Message).Contains("Expected: 1"); + await Assert.That(diff.Message).Contains("Actual: 2"); + await Assert.That(diff.DiffMessages.Count).IsEqualTo(1); + } +} diff --git a/src/MDAT.Tests/JsonDocumentRegressionTests.cs b/src/MDAT.Tests/JsonDocumentRegressionTests.cs new file mode 100644 index 0000000..a92d8e3 --- /dev/null +++ b/src/MDAT.Tests/JsonDocumentRegressionTests.cs @@ -0,0 +1,49 @@ +using System.Text.Json; + +namespace MDAT.Tests; + +/// +/// Regression found while migrating a real project from mdAT 1.4.1: a call recorded by a mock carries a +/// whose root is very often a string holding JSON text. mdAT 1.4 wrote it as an object, +/// so the test cases expect a YAML object; mdAT 1.5.0 to 2.0.0-beta.1 wrote it as a string, which forced the expected +/// value to be a JSON string (about 28 test cases of the migrated project failed). +/// +public class JsonDocumentRegressionTests +{ + public class AppelDocument + { + public string? Gabarit { get; set; } + + public JsonDocument? Donnees { get; set; } + + public Dictionary? Options { get; set; } + + public Dictionary? ProprietesDocument { get; set; } + } + + /// + /// The document data is given as JSON text. With the text becomes the + /// string root of the document (the usual form in the real project), otherwise it is the root itself. + /// + [Test] + [MarkdownDataSource("~/Tests/{method}.md")] + public async Task Json_document_with_a_json_string_root(string donnees, bool racineEstUneChaine, Expected expected) + { + var document = racineEstUneChaine + ? JsonDocument.Parse(JsonSerializer.Serialize(donnees)) + : JsonDocument.Parse(donnees); + + var appels = new[] + { + new AppelDocument + { + Gabarit = "UnGabarit", + Donnees = document, + Options = new Dictionary { ["Format"] = "pdf" }, + ProprietesDocument = new Dictionary { ["Titre"] = "Un titre" }, + }, + }; + + _ = await Verify.Assert(() => Task.FromResult(appels), expected); + } +} diff --git a/src/MDAT.Tests/JsonMessageTests.cs b/src/MDAT.Tests/JsonMessageTests.cs new file mode 100644 index 0000000..45292e5 --- /dev/null +++ b/src/MDAT.Tests/JsonMessageTests.cs @@ -0,0 +1,102 @@ +namespace MDAT.Tests; + +/// +/// How the differences are written: every kind of value has its own description, and the items of an array are expanded one +/// level. The expected texts are the ones of the engine mdAT used before 2.0 (Quibble.Xunit), so a test written for 1.x reads +/// the same messages. +/// +public class JsonMessageTests +{ + private static string Messages(string expected, string actual) + { + try + { + JsonAssert.Equal(expected, actual); + return "(equal)"; + } + catch (JsonAssertException ex) + { + return ex.UserMessage; + } + } + + [Test] + [Arguments("null", "0", "Type mismatch at $.\nExpected null but was the number 0.")] + [Arguments("true", "\"true\"", "Type mismatch at $.\nExpected the boolean true but was the string true.")] + [Arguments("false", "1", "Type mismatch at $.\nExpected the boolean false but was the number 1.")] + [Arguments("\"abc\"", "5", "Type mismatch at $.\nExpected the string abc but was the number 5.")] + [Arguments("5", """{"a":1}""", "Type mismatch at $.\nExpected the number 5 but was an object.")] + [Arguments("[]", "{}", "Type mismatch at $.\nExpected an empty array but was an object.")] + [Arguments("[1]", "{}", "Type mismatch at $.\nExpected an array with 1 item but was an object.")] + [Arguments("[1,2]", "\"\"", "Type mismatch at $.\nExpected an array with 2 items but was the string .")] + [Arguments("{}", "[1]", "Type mismatch at $.\nExpected an object but was an array with 1 item.")] + [Arguments("true", "false", "Boolean value mismatch at $.\nExpected true but was false.")] + [Arguments("false", "true", "Boolean value mismatch at $.\nExpected false but was true.")] + public async Task Every_kind_of_value_has_its_own_description(string expected, string actual, string message) + { + await Assert.That(Messages(expected, actual)).IsEqualTo(message); + } + + [Test] + public async Task Scalar_items_of_an_array_are_described_one_per_line() + { + var message = Messages("[]", """["str",true,false,null,1.50]"""); + + await Assert.That(message).IsEqualTo( + "Array mismatch at $.\nAdditional items:\n + [0]: the string str\n + [1]: the boolean true\n + [2]: the boolean false\n + [3]: null\n + [4]: the number 1.50"); + } + + [Test] + public async Task Array_and_object_items_are_expanded_one_level() + { + var inArray = Messages("[]", """[[true,null,"s",1,{},[],[1],{"a":1},[1,2],{"a":1,"b":2}]]"""); + + await Assert.That(inArray).IsEqualTo( + "Array mismatch at $.\nAdditional item:\n + [0]: [\n true\n null\n 's'\n 1\n {}\n []\n [ 1 item ]\n { 1 property }\n [ 2 items ]\n { 2 properties }\n]"); + + var inObject = Messages("[]", """[{"t":true,"f":false,"n":null,"s":"s","i":1,"o":{},"a":[],"a1":[1],"o1":{"a":1},"a2":[1,2],"o2":{"a":1,"b":2}}]"""); + + await Assert.That(inObject).IsEqualTo( + "Array mismatch at $.\nAdditional item:\n + [0]: {\n 't': true\n 'f': false\n 'n': null\n 's': 's'\n 'i': 1\n 'o': {}\n 'a': []\n 'a1': [ 1 item ]\n 'o1': { 1 property }\n 'a2': [ 2 items ]\n 'o2': { 2 properties }\n}"); + + var empty = Messages("[]", "[[],{}]"); + + await Assert.That(empty).IsEqualTo("Array mismatch at $.\nAdditional items:\n + [0]: [\n]\n + [1]: {\n}"); + } + + [Test] + public async Task Properties_are_listed_with_their_type() + { + var message = Messages("{}", """{"a":1,"b":"s","c":true,"d":false,"e":null,"f":[],"g":{}}"""); + + await Assert.That(message).IsEqualTo( + "Object mismatch at $.\nAdditional properties:\n + 'a' (number)\n + 'b' (string)\n + 'c' (bool)\n + 'd' (bool)\n + 'e' (null)\n + 'f' (array)\n + 'g' (object)"); + + var missing = Messages("""{"a":1,"b":[1]}""", "{}"); + + await Assert.That(missing).IsEqualTo("Object mismatch at $.\nMissing properties:\n - 'a' (number)\n - 'b' (array)"); + } + + [Test] + public async Task Paths_use_dots_for_simple_names_and_brackets_for_the_others() + { + JsonAssertException? error = null; + + try + { + JsonAssert.Equal("""{"a b":1,"1x":1,"under_score":1,"":1}""", """{"a b":2,"1x":2,"under_score":2,"":2}"""); + } + catch (JsonAssertException ex) + { + error = ex; + } + + await Assert.That(error).IsNotNull(); + await Assert.That(error!.DiffMessages.Count).IsEqualTo(4); + await Assert.That(error.DiffMessages[0]).StartsWith("Number value mismatch at $['a b']."); + await Assert.That(error.DiffMessages[1]).StartsWith("Number value mismatch at $.1x."); + await Assert.That(error.DiffMessages[2]).StartsWith("Number value mismatch at $['under_score']."); + await Assert.That(error.DiffMessages[3]).StartsWith("Number value mismatch at $['']."); + await Assert.That(error.UserMessage).StartsWith("Found 4 differences.\n# 1: "); + } +} diff --git a/src/MDAT.Tests/MDAT.Tests.csproj b/src/MDAT.Tests/MDAT.Tests.csproj new file mode 100644 index 0000000..e8e51fc --- /dev/null +++ b/src/MDAT.Tests/MDAT.Tests.csproj @@ -0,0 +1,34 @@ + + + + Exe + net8.0;net9.0;net10.0 + enable + enable + false + True + $(NoWarn);CS1591;CS8618 + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MDAT.Tests/MarkdownSourceLinkTests.cs b/src/MDAT.Tests/MarkdownSourceLinkTests.cs new file mode 100644 index 0000000..55d4de0 --- /dev/null +++ b/src/MDAT.Tests/MarkdownSourceLinkTests.cs @@ -0,0 +1,396 @@ +using System.Text; + +namespace MDAT.Tests; + +/// The link to the C# test kept at the top of a markdown test file. +public class MarkdownSourceLinkTests +{ + private const string Title = "# Probe\n\n> Summary of the test\n\n## Case 1\n\nText\n"; + + /// A temporary folder with the markdown file in Tests and the C# file next to Tests. + private sealed class Sandbox : IDisposable + { + private readonly DirectoryInfo _root = Directory.CreateTempSubdirectory("mdat-link-"); + + public Sandbox(string markdownContent = Title, string markdownName = "probe.md", string sourceName = "Code Tests.cs", bool withSource = true, byte[]? markdownBytes = null) + { + Directory.CreateDirectory(Path.Combine(_root.FullName, "Tests")); + + Markdown = Path.Combine(_root.FullName, "Tests", markdownName); + Source = Path.Combine(_root.FullName, sourceName); + + File.WriteAllBytes(Markdown, markdownBytes ?? new UTF8Encoding(false).GetBytes(markdownContent)); + + if (withSource) + { + File.WriteAllText(Source, "// test code"); + } + } + + public string Markdown { get; } + + public string Source { get; } + + public string Text => File.ReadAllText(Markdown); + + public byte[] Bytes => File.ReadAllBytes(Markdown); + + public void Dispose() + { + foreach (var file in _root.EnumerateFiles("*", SearchOption.AllDirectories)) + { + file.Attributes = FileAttributes.Normal; + } + + _root.Delete(recursive: true); + } + } + + [Test] + public async Task The_link_is_inserted_after_the_title() + { + using var sandbox = new Sandbox(); + + var changed = MarkdownSourceLink.Update(sandbox.Markdown, sandbox.Source, 12, "Probe"); + + await Assert.That(changed).IsTrue(); + await Assert.That(sandbox.Text).IsEqualTo( + "# Probe\n\n> C# : [Code Tests.cs:12](../Code%20Tests.cs#L12) \n\n> Summary of the test\n\n## Case 1\n\nText\n"); + } + + [Test] + public async Task The_line_number_is_corrected_when_the_test_moves() + { + using var sandbox = new Sandbox(); + MarkdownSourceLink.Update(sandbox.Markdown, sandbox.Source, 12, "Probe"); + + var changed = MarkdownSourceLink.Update(sandbox.Markdown, sandbox.Source, 20, "Probe"); + + await Assert.That(changed).IsTrue(); + await Assert.That(sandbox.Text).IsEqualTo( + "# Probe\n\n> C# : [Code Tests.cs:20](../Code%20Tests.cs#L20) \n\n> Summary of the test\n\n## Case 1\n\nText\n"); + } + + [Test] + public async Task The_file_is_not_written_again_when_the_link_has_not_changed() + { + using var sandbox = new Sandbox(); + MarkdownSourceLink.Update(sandbox.Markdown, sandbox.Source, 12, "Probe"); + + // an old date shows a rewrite, even on a file system with a coarse clock + var oldDate = new DateTime(2001, 1, 1, 0, 0, 0, DateTimeKind.Utc); + File.SetLastWriteTimeUtc(sandbox.Markdown, oldDate); + var before = sandbox.Bytes; + + var changed = MarkdownSourceLink.Update(sandbox.Markdown, sandbox.Source, 12, "Probe"); + + await Assert.That(changed).IsFalse(); + await Assert.That(File.GetLastWriteTimeUtc(sandbox.Markdown)).IsEqualTo(oldDate); + await Assert.That(sandbox.Bytes.SequenceEqual(before)).IsTrue(); + } + + [Test] + public async Task A_link_that_is_missing_makes_exactly_one_write_and_the_next_calls_none() + { + using var sandbox = new Sandbox(); + var oldDate = new DateTime(2001, 1, 1, 0, 0, 0, DateTimeKind.Utc); + File.SetLastWriteTimeUtc(sandbox.Markdown, oldDate); + + var first = MarkdownSourceLink.Update(sandbox.Markdown, sandbox.Source, 12, "Probe"); + var writtenAt = File.GetLastWriteTimeUtc(sandbox.Markdown); + File.SetLastWriteTimeUtc(sandbox.Markdown, oldDate); + var second = MarkdownSourceLink.Update(sandbox.Markdown, sandbox.Source, 12, "Probe"); + var third = MarkdownSourceLink.Update(sandbox.Markdown, sandbox.Source, 12, "Probe"); + + await Assert.That(first).IsTrue(); + await Assert.That(writtenAt).IsNotEqualTo(oldDate); + await Assert.That(second).IsFalse(); + await Assert.That(third).IsFalse(); + await Assert.That(File.GetLastWriteTimeUtc(sandbox.Markdown)).IsEqualTo(oldDate); + } + + [Test] + public async Task Bom_and_windows_line_endings_are_kept() + { + var content = new UTF8Encoding(false).GetBytes("# Probe\r\n\r\n## Case 1\r\n\r\nText\r\n"); + var bytes = new byte[] { 0xEF, 0xBB, 0xBF }.Concat(content).ToArray(); + + using var sandbox = new Sandbox(markdownBytes: bytes); + MarkdownSourceLink.Update(sandbox.Markdown, sandbox.Source, 5, "Probe"); + + var result = sandbox.Bytes; + + await Assert.That(result.Take(3).SequenceEqual(new byte[] { 0xEF, 0xBB, 0xBF })).IsTrue(); + + var text = new UTF8Encoding(false).GetString(result, 3, result.Length - 3); + + await Assert.That(text).IsEqualTo( + "# Probe\r\n\r\n> C# : [Code Tests.cs:5](../Code%20Tests.cs#L5) \r\n\r\n## Case 1\r\n\r\nText\r\n"); + } + + [Test] + public async Task Only_the_link_line_changes_when_the_line_moves_and_the_rest_is_left_as_it_was() + { + const string edited = "# Probe\n\n> C# : [Code Tests.cs:1](../Code%20Tests.cs#L1) \n\nAnalyst wrote this\n\n## Case 1\n"; + + using var sandbox = new Sandbox(edited); + + MarkdownSourceLink.Update(sandbox.Markdown, sandbox.Source, 77, "Probe"); + + await Assert.That(sandbox.Text).IsEqualTo( + "# Probe\n\n> C# : [Code Tests.cs:77](../Code%20Tests.cs#L77) \n\nAnalyst wrote this\n\n## Case 1\n"); + } + + [Test] + public async Task What_follows_the_title_on_its_line_stays_on_that_line() + { + using var sandbox = new Sandbox("# Probe \n\n## Case 1\n\nText\n"); + + MarkdownSourceLink.Update(sandbox.Markdown, sandbox.Source, 3, "Probe"); + + await Assert.That(sandbox.Text).IsEqualTo( + "# Probe \n\n> C# : [Code Tests.cs:3](../Code%20Tests.cs#L3) \n\n## Case 1\n\nText\n"); + } + + [Test] + public async Task Closing_hashes_of_the_title_are_not_separated_from_it() + { + using var sandbox = new Sandbox("# Probe #\n\n## Case 1\n"); + + MarkdownSourceLink.Update(sandbox.Markdown, sandbox.Source, 3, "Probe"); + + await Assert.That(sandbox.Text).StartsWith("# Probe #\n\n> C# : "); + await Assert.That(sandbox.Text).EndsWith("-->\n\n## Case 1\n"); + } + + [Test] + public async Task The_new_lines_use_the_line_ending_of_the_title_even_in_a_mixed_file() + { + // a file written with LF, with one stray CRLF further down: the inserted lines follow the title, not the stray one + using var sandbox = new Sandbox("# Probe\n\n## Case 1\r\n\nText\n"); + + MarkdownSourceLink.Update(sandbox.Markdown, sandbox.Source, 3, "Probe"); + + await Assert.That(sandbox.Text).IsEqualTo( + "# Probe\n\n> C# : [Code Tests.cs:3](../Code%20Tests.cs#L3) \n\n## Case 1\r\n\nText\n"); + } + + [Test] + public async Task A_file_that_does_not_end_with_a_line_ending_keeps_that_way() + { + using var sandbox = new Sandbox("# Probe"); + + MarkdownSourceLink.Update(sandbox.Markdown, sandbox.Source, 3, "Probe"); + + await Assert.That(sandbox.Text).IsEqualTo( + "# Probe" + Environment.NewLine + Environment.NewLine + "> C# : [Code Tests.cs:3](../Code%20Tests.cs#L3) "); + } + + [Test] + public async Task The_links_of_all_the_tests_of_a_file_are_written_in_one_go_and_in_order() + { + using var sandbox = new Sandbox(); + + var entries = new[] + { + new SourceLinkEntry(sandbox.Source, 40, "Second"), + new SourceLinkEntry(sandbox.Source, 10, "First"), + new SourceLinkEntry(sandbox.Source, 25, "Middle"), + }; + + var changed = MarkdownSourceLink.Update(sandbox.Markdown, entries); + + await Assert.That(changed).IsTrue(); + + // whatever the order the tests were found in, the lines come out by increasing line of the C# file + await Assert.That(sandbox.Text).IsEqualTo( + "# Probe\n\n" + + "> C# : [Code Tests.cs:10](../Code%20Tests.cs#L10) \n\n" + + "> C# : [Code Tests.cs:25](../Code%20Tests.cs#L25) \n\n" + + "> C# : [Code Tests.cs:40](../Code%20Tests.cs#L40) \n\n" + + "> Summary of the test\n\n## Case 1\n\nText\n"); + } + + [Test] + public async Task Writing_all_the_links_at_once_does_not_write_again_when_nothing_changed() + { + using var sandbox = new Sandbox(); + + var entries = new[] + { + new SourceLinkEntry(sandbox.Source, 10, "First"), + new SourceLinkEntry(sandbox.Source, 40, "Second"), + }; + + MarkdownSourceLink.Update(sandbox.Markdown, entries); + + var oldDate = new DateTime(2001, 1, 1, 0, 0, 0, DateTimeKind.Utc); + File.SetLastWriteTimeUtc(sandbox.Markdown, oldDate); + + // the same links, found in another order + var again = MarkdownSourceLink.Update(sandbox.Markdown, Enumerable.Reverse(entries)); + + await Assert.That(again).IsFalse(); + await Assert.That(File.GetLastWriteTimeUtc(sandbox.Markdown)).IsEqualTo(oldDate); + } + + [Test] + public async Task Only_the_link_that_moved_is_corrected_in_a_file_with_several_links() + { + using var sandbox = new Sandbox(); + + MarkdownSourceLink.Update(sandbox.Markdown, new[] + { + new SourceLinkEntry(sandbox.Source, 10, "First"), + new SourceLinkEntry(sandbox.Source, 40, "Second"), + }); + + MarkdownSourceLink.Update(sandbox.Markdown, new[] + { + new SourceLinkEntry(sandbox.Source, 10, "First"), + new SourceLinkEntry(sandbox.Source, 44, "Second"), + }); + + await Assert.That(sandbox.Text).IsEqualTo( + "# Probe\n\n" + + "> C# : [Code Tests.cs:10](../Code%20Tests.cs#L10) \n\n" + + "> C# : [Code Tests.cs:44](../Code%20Tests.cs#L44) \n\n" + + "> Summary of the test\n\n## Case 1\n\nText\n"); + } + + [Test] + public async Task A_utf16_file_is_left_untouched() + { + // UTF-16 files are not handled: rewriting them as UTF-8 would change their encoding + var bytes = Encoding.Unicode.GetPreamble().Concat(Encoding.Unicode.GetBytes("# Probe\n\n## Case 1\n")).ToArray(); + + using var sandbox = new Sandbox(markdownBytes: bytes); + + var changed = MarkdownSourceLink.Update(sandbox.Markdown, sandbox.Source, 5, "Probe"); + + await Assert.That(changed).IsFalse(); + await Assert.That(sandbox.Bytes.SequenceEqual(bytes)).IsTrue(); + } + + [Test] + public async Task The_line_can_be_built_without_writing_it() + { + using var sandbox = new Sandbox(); + + await Assert.That(MarkdownSourceLink.BuildLine(sandbox.Markdown, sandbox.Source, 7, "Probe")) + .IsEqualTo("> C# : [Code Tests.cs:7](../Code%20Tests.cs#L7) "); + + // without the name of a test, the marker holds the C# file only + await Assert.That(MarkdownSourceLink.BuildLine(sandbox.Markdown, sandbox.Source, 7)) + .IsEqualTo("> C# : [Code Tests.cs:7](../Code%20Tests.cs#L7) "); + + await Assert.That(sandbox.Text).IsEqualTo(Title); + } + + [Test] + public async Task Nothing_is_written_without_a_test_or_a_markdown_path_and_synchronized_gives_back_the_result() + { + using var sandbox = new Sandbox(); + + await Assert.That(MarkdownSourceLink.Update(sandbox.Markdown, Array.Empty())).IsFalse(); + await Assert.That(MarkdownSourceLink.Update("", new[] { new SourceLinkEntry(sandbox.Source, 3, "Probe") })).IsFalse(); + await Assert.That(MarkdownSourceLink.Update(sandbox.Markdown, new[] { new SourceLinkEntry("", 3, "Probe") })).IsFalse(); + await Assert.That(sandbox.Text).IsEqualTo(Title); + + await Assert.That(MarkdownSourceLink.Synchronized(sandbox.Markdown, () => 42)).IsEqualTo(42); + } + + [Test] + public async Task Two_tests_sharing_a_file_each_keep_their_own_line() + { + using var sandbox = new Sandbox(); + + MarkdownSourceLink.Update(sandbox.Markdown, sandbox.Source, 10, "First"); + MarkdownSourceLink.Update(sandbox.Markdown, sandbox.Source, 40, "Second"); + + var afterBoth = sandbox.Text; + + // calling again in any order changes nothing: the lines do not take each other's place + var again = new[] + { + MarkdownSourceLink.Update(sandbox.Markdown, sandbox.Source, 40, "Second"), + MarkdownSourceLink.Update(sandbox.Markdown, sandbox.Source, 10, "First"), + }; + + await Assert.That(again.Any(c => c)).IsFalse(); + await Assert.That(sandbox.Text).IsEqualTo(afterBoth); + await Assert.That(afterBoth).Contains("[Code Tests.cs:10]"); + await Assert.That(afterBoth).Contains("[Code Tests.cs:40]"); + } + + [Test] + public async Task A_file_without_title_gets_the_link_at_the_top() + { + using var sandbox = new Sandbox("Only text\n\n## Case 1\n"); + + MarkdownSourceLink.Update(sandbox.Markdown, sandbox.Source, 3, "Probe"); + + await Assert.That(sandbox.Text).IsEqualTo( + "> C# : [Code Tests.cs:3](../Code%20Tests.cs#L3) \n\nOnly text\n\n## Case 1\n"); + } + + [Test] + public async Task Nothing_is_written_when_the_c_sharp_file_does_not_exist() + { + using var sandbox = new Sandbox(withSource: false); + var before = sandbox.Text; + + var changed = MarkdownSourceLink.Update(sandbox.Markdown, sandbox.Source, 3, "Probe"); + + await Assert.That(changed).IsFalse(); + await Assert.That(sandbox.Text).IsEqualTo(before); + } + + [Test] + public async Task Nothing_is_written_without_a_line_or_a_file() + { + using var sandbox = new Sandbox(); + var before = sandbox.Text; + + await Assert.That(MarkdownSourceLink.Update(sandbox.Markdown, sandbox.Source, 0, "Probe")).IsFalse(); + await Assert.That(MarkdownSourceLink.Update(sandbox.Markdown, "", 3, "Probe")).IsFalse(); + await Assert.That(MarkdownSourceLink.Update(sandbox.Markdown, null, 3, "Probe")).IsFalse(); + await Assert.That(MarkdownSourceLink.Update(Path.Combine(Path.GetDirectoryName(sandbox.Markdown)!, "missing.md"), sandbox.Source, 3, "Probe")).IsFalse(); + await Assert.That(sandbox.Text).IsEqualTo(before); + } + + [Test] + public async Task A_read_only_file_is_left_alone_without_any_error() + { + using var sandbox = new Sandbox(); + File.SetAttributes(sandbox.Markdown, FileAttributes.ReadOnly); + var before = sandbox.Text; + + var changed = MarkdownSourceLink.Update(sandbox.Markdown, sandbox.Source, 3, "Probe"); + + await Assert.That(changed).IsFalse(); + await Assert.That(sandbox.Text).IsEqualTo(before); + await Assert.That(Directory.GetFiles(Path.GetDirectoryName(sandbox.Markdown)!, "*.mdat-tmp").Length).IsEqualTo(0); + } + + [Test] + public async Task Parallel_updates_of_the_same_file_do_not_corrupt_it() + { + using var sandbox = new Sandbox(); + + Parallel.For(0, 24, i => MarkdownSourceLink.Update(sandbox.Markdown, sandbox.Source, 100 + i, "Test" + i)); + + var text = sandbox.Text; + + await Assert.That(text).StartsWith("# Probe\n\n"); + await Assert.That(text).Contains("## Case 1"); + + for (var i = 0; i < 24; i++) + { + var marker = ""; + + // exactly once each + await Assert.That(text.Split(marker).Length - 1).IsEqualTo(1); + } + } +} diff --git a/src/MDATTests/MarkdownTestAttributeTests.cs b/src/MDAT.Tests/MarkdownTestAttributeTests.cs similarity index 77% rename from src/MDATTests/MarkdownTestAttributeTests.cs rename to src/MDAT.Tests/MarkdownTestAttributeTests.cs index 9de0eff..febbfb4 100644 --- a/src/MDATTests/MarkdownTestAttributeTests.cs +++ b/src/MDAT.Tests/MarkdownTestAttributeTests.cs @@ -10,7 +10,7 @@ namespace MDAT.Tests /// /// Ignored comment /// - [TestClass] + public class MarkdownTestAttributeTests { @@ -20,22 +20,16 @@ public class Params public Dictionary Test { get; set; } } - static IEnumerable ReusableTestDataProperty + public static IEnumerable> ReusableTestData() { - get - { - return new[] - { - new object[] { new Params() { Test = new Dictionary { { "test", new Dictionary { { "test", 1 } } } } } }, - }; - } + yield return () => new Params() { Test = new Dictionary { { "test", new Dictionary { { "test", 1 } } } } }; } /// /// Simple test, addition 2 numbers, compare expected result /// - [TestMethod] - [DynamicData("ReusableTestDataProperty")] + [Test] + [MethodDataSource(nameof(ReusableTestData))] public async Task Dynam(Params val1) { _ = await Verify.Assert(() => Task.FromResult(val1), new Expected() { verify = new VerifyStep[] { new VerifyStep { data = "{\"Test\":{\"test\":{\"test\":1}}}", type = "match", jsonPath = "$" } } }); @@ -44,8 +38,8 @@ public async Task Dynam(Params val1) /// /// Simple test, addition 2 numbers, compare expected result /// - [TestMethod] - [MarkdownTest("~/Tests/test.md")] + [Test] + [MarkdownDataSource("~/Tests/test.md")] public async Task Md1(int val1, int val2, string expected) { _ = await Verify.Assert(() => Task.FromResult(Utils.Calculer(val1, val2)), expected); @@ -54,8 +48,8 @@ public async Task Md1(int val1, int val2, string expected) /// /// Test JustME :) /// - [TestMethod] - [MarkdownTest("~/Tests/test-justme.md")] + [Test] + [MarkdownDataSource("~/Tests/test-justme.md")] public async Task Md_Justme(int val1, int val2, string expected) { _ = await Verify.Assert(() => Task.FromResult(Utils.Calculer(val1, val2)), expected); @@ -65,8 +59,8 @@ public async Task Md_Justme(int val1, int val2, string expected) /// /// Test skip /// - [TestMethod] - [MarkdownTest("~/Tests/test-skip.md")] + [Test] + [MarkdownDataSource("~/Tests/test-skip.md")] public async Task Md_Skip(int val1, int val2, string expected) { _ = await Verify.Assert(() => Task.FromResult(Utils.Calculer(val1, val2)), expected); @@ -81,8 +75,8 @@ private async Task Md2(ObjectOrException> formL /// /// Multi level object /// - [TestMethod] - [MarkdownTest("~/Tests/{method}.md")] + [Test] + [MarkdownDataSource("~/Tests/{method}.md")] public async Task Multi_level(FormulaireWebFRW1DO form, Expected expected) { _ = await Verify.Assert(() => Task.FromResult(form), expected); @@ -91,8 +85,8 @@ public async Task Multi_level(FormulaireWebFRW1DO form, Expected expected) /// /// External file include /// - [TestMethod] - [MarkdownTest("~/Tests/{method}.md")] + [Test] + [MarkdownDataSource("~/Tests/{method}.md")] public async Task External_file(FormulaireWebFRW1DO form, Expected expected) { _ = await Verify.Assert(() => Task.FromResult(form), expected); @@ -101,8 +95,8 @@ public async Task External_file(FormulaireWebFRW1DO form, Expected expected) /// /// Test Exception /// - [TestMethod] - [MarkdownTest("~/Tests/{method}.md")] + [Test] + [MarkdownDataSource("~/Tests/{method}.md")] public async Task Exception_test(FormulaireWebFRW1DO form, Expected expected) { _ = await Verify.Assert(() => Task.FromResult(ThrowE()), expected); @@ -116,8 +110,8 @@ private bool ThrowE() /// /// External file include 2 /// - [TestMethod] - [MarkdownTest("~/Tests/{method}.md")] + [Test] + [MarkdownDataSource("~/Tests/{method}.md")] public async Task External_file2(string form, Expected expected) { _ = await Verify.Assert(() => Task.FromResult(form), expected); @@ -126,8 +120,8 @@ public async Task External_file2(string form, Expected expected) /// /// External file include base64 /// - [TestMethod] - [MarkdownTest("~/Tests/{method}.md")] + [Test] + [MarkdownDataSource("~/Tests/{method}.md")] public async Task External_file_base64(byte[] form, Expected expected) { _ = await Verify.Assert(() => Task.FromResult(form), expected); @@ -136,8 +130,8 @@ public async Task External_file_base64(byte[] form, Expected expected) /// /// Nullable byte[] /// - [TestMethod] - [MarkdownTest("~/Tests/{method}.md")] + [Test] + [MarkdownDataSource("~/Tests/{method}.md")] public async Task Nullable_byte_array(byte[]? form, Expected expected) { _ = await Verify.Assert(() => Task.FromResult(form), expected); @@ -146,8 +140,8 @@ public async Task Nullable_byte_array(byte[]? form, Expected expected) /// /// External file include base64 string /// - [TestMethod] - [MarkdownTest("~/Tests/{method}.md")] + [Test] + [MarkdownDataSource("~/Tests/{method}.md")] public async Task External_file_base64_String(string form, Expected expected) { _ = await Verify.Assert(() => Task.FromResult(form), expected); @@ -156,8 +150,8 @@ public async Task External_file_base64_String(string form, Expected expected) /// /// External file include byte[] dictionary /// - [TestMethod] - [MarkdownTest("~/Tests/{method}.md")] + [Test] + [MarkdownDataSource("~/Tests/{method}.md")] public async Task External_file_byte_dictionary(Dictionary form, Expected expected) { _ = await Verify.Assert(() => Task.FromResult(form), expected); @@ -166,8 +160,8 @@ public async Task External_file_byte_dictionary(Dictionary form, /// /// External file include string dictionary /// - [TestMethod] - [MarkdownTest("~/Tests/{method}.md")] + [Test] + [MarkdownDataSource("~/Tests/{method}.md")] public async Task External_file_string_dictionary(Dictionary form, Expected expected) { _ = await Verify.Assert(() => Task.FromResult(form), expected); @@ -176,8 +170,8 @@ public async Task External_file_string_dictionary(Dictionary for /// /// Dictionary_object_object /// - [TestMethod] - [MarkdownTest("~/Tests/{method}.md")] + [Test] + [MarkdownDataSource("~/Tests/{method}.md")] public async Task Dictionary_object_object(Dictionary dict, Expected expected) { _ = await Verify.Assert(() => Task.FromResult(dict), expected); @@ -186,8 +180,8 @@ public async Task Dictionary_object_object(Dictionary dict, Expe /// /// Dictionary_object_object in Dictionary_object_object /// - [TestMethod] - [MarkdownTest("~/Tests/{method}.md")] + [Test] + [MarkdownDataSource("~/Tests/{method}.md")] public async Task Dictionary_object_object_in_Dictionary_object_object(Dictionary dict, Expected expected) { _ = await Verify.Assert(() => Task.FromResult(dict), expected); @@ -196,8 +190,8 @@ public async Task Dictionary_object_object_in_Dictionary_object_object(Dictionar /// /// Exception test /// - [TestMethod] - [MarkdownTest("~/Tests/{method}.md")] + [Test] + [MarkdownDataSource("~/Tests/{method}.md")] public async Task Crash_Test(int val1, int val2, Expected expected) { _ = await Verify.Assert(() => Task.FromResult(Utils.CrashExceptionCheck(val1, val2)), expected); @@ -220,16 +214,16 @@ public void MdWithoutSummary(FormulaireWebFRW1DO db, Expected expected) { } /// Test XML comments extraction and file generation /// This test whole document integrity /// - [TestMethod] - [MarkdownTest("~\\Tests\\{method}.md")] + [Test] + [MarkdownDataSource("~\\Tests\\{method}.md")] public async Task Md_Extraction_test(Expected expected) { var guid = Guid.NewGuid(); - var test = new MarkdownTestAttribute($"~\\Tests\\Generated\\Generated-md-Test-{guid}.md"); + var test = new MarkdownTestSource($"~\\Tests\\Generated\\Generated-md-Test-{guid}.md"); var method = Utils.GetMethodInfo(() => MdWithSummary); - test.GetData(method); + test.GetCases(method); object value = await Verify.Assert(() => Task.FromResult(File.ReadAllText(test.ParsedPath)), expected); @@ -238,30 +232,30 @@ public async Task Md_Extraction_test(Expected expected) /// /// Test tests naming values /// - [TestMethod] - [MarkdownTest("~\\Tests\\{method}.md")] + [Test] + [MarkdownDataSource("~\\Tests\\{method}.md")] public async Task Test_Tests_Naming(Expected expected) { - var test = new MarkdownTestAttribute($"~\\Tests\\test.md"); + var test = new MarkdownTestSource($"~\\Tests\\test.md"); var method = Utils.GetMethodInfo(() => Md1); - var tests = test.GetData(method); + var tests = test.GetCases(method); object value = await Verify.Assert(() => - Task.FromResult(test.GetDisplayName(method, tests.LastOrDefault())!), expected); + Task.FromResult(tests.Last().DisplayName), expected); } /// /// Test object deserialization /// - [TestMethod] - [MarkdownTest("~\\Tests\\{method}.md")] + [Test] + [MarkdownDataSource("~\\Tests\\{method}.md")] public async Task Test_object_deserialization(Expected expected) { - var test = new MarkdownTestAttribute($"~\\Tests\\test-output-test-object-deserialization.md"); + var test = new MarkdownTestSource($"~\\Tests\\test-output-test-object-deserialization.md"); var method = Utils.GetMethodInfo(() => Md2); - var tests = test.GetData(method); + var tests = test.GetCases(method); object value = await Verify.Assert(() => Task.FromResult(File.ReadAllText(test.ParsedPath)), expected); @@ -271,14 +265,14 @@ public async Task Test_object_deserialization(Expected expected) /// Test XML comments extraction and file generation /// This test whole document integrity /// - [TestMethod] + [Test] public async Task Md_no_method_test() { var guid = Guid.NewGuid(); - var test = new MarkdownTestAttribute($"~\\Tests\\Generated\\Generated-md-Test-{guid}.md"); + var test = new MarkdownTestSource($"~\\Tests\\Generated\\Generated-md-Test-{guid}.md"); object value = await Verify.Assert(() => - Task.FromResult(test.GetData(null!)), new Expected() + Task.FromResult(test.GetCases(null!)), new Expected() { verify = new VerifyStep[] { new VerifyStep { data = "{ " + "\"ClassName\":\"System.ArgumentNullException\", " + @@ -292,16 +286,16 @@ public async Task Md_no_method_test() /// Test XML comments extraction and file generation /// This test whole document integrity /// - [TestMethod] - [MarkdownTest("~\\Tests\\{method}.md")] + [Test] + [MarkdownDataSource("~\\Tests\\{method}.md")] public async Task Md_Extraction_test_nosummary(Expected expected) { var guid = Guid.NewGuid(); - var test = new MarkdownTestAttribute($"~\\Tests\\Generated\\Generated-md-Test-nosummary-{guid}.md"); + var test = new MarkdownTestSource($"~\\Tests\\Generated\\Generated-md-Test-nosummary-{guid}.md"); var method = Utils.GetMethodInfo(() => MdWithoutSummary); - test.GetData(method); + test.GetCases(method); object value = await Verify.Assert(() => Task.FromResult(File.ReadAllText(test.ParsedPath)), expected); @@ -310,8 +304,8 @@ public async Task Md_Extraction_test_nosummary(Expected expected) /// /// Test output expected /// - [TestMethod] - [MarkdownTest("~/Tests/{method}.md")] + [Test] + [MarkdownDataSource("~/Tests/{method}.md")] public async Task Output_expected(Dictionary form, byte[] bytes, Expected expected) { _ = await Verify.Assert(() => Task.FromResult(new { form, bytes }), expected); @@ -321,8 +315,8 @@ public async Task Output_expected(Dictionary form, byte[] bytes, /// /// Test jsonPath array count /// - [TestMethod] - [MarkdownTest("~/Tests/{method}.md")] + [Test] + [MarkdownDataSource("~/Tests/{method}.md")] public async Task JsonPath(Expected expected) { _ = await Verify.Assert(() => Task.FromResult(new { test = new string[] { "val1", "val2" } }), expected); @@ -331,8 +325,8 @@ public async Task JsonPath(Expected expected) /// /// Test jsonPath array count /// - [TestMethod] - [MarkdownTest("~/Tests/{method}.md")] + [Test] + [MarkdownDataSource("~/Tests/{method}.md")] public async Task TestObjectOrException( ObjectOrException formulaireWebFRW1DO, Expected expected) @@ -341,14 +335,14 @@ public async Task TestObjectOrException( mock.Setup(e => e.ReturnVal()).ReturnsOrThrows(formulaireWebFRW1DO); - _ = await Verify.Assert(() => Task.FromResult(new Test().ReturnTest(mock.Object)), expected); + _ = await Verify.Assert(() => Task.FromResult(new Sample().ReturnTest(mock.Object)), expected); } /// /// Test jsonPath array count /// - [TestMethod] - [MarkdownTest("~/Tests/{method}.md")] + [Test] + [MarkdownDataSource("~/Tests/{method}.md")] public async Task TestObjectOrExceptionAsync( ObjectOrException formulaireWebFRW1DO, Expected expected) @@ -357,14 +351,14 @@ public async Task TestObjectOrExceptionAsync( _ = mock.Setup(e => e.ReturnValAsync()).ReturnsOrThrowsAsync(formulaireWebFRW1DO); - _ = await Verify.Assert(async () => await new Test().ReturnTestAsync(mock.Object), expected); + _ = await Verify.Assert(async () => await new Sample().ReturnTestAsync(mock.Object), expected); } /// /// Test object deserialization /// - [TestMethod] - [MarkdownTest("~\\Tests\\{method}.md")] + [Test] + [MarkdownDataSource("~\\Tests\\{method}.md")] public async Task Test_keypairvalues(List> input, Expected expected) { @@ -375,8 +369,8 @@ public async Task Test_keypairvalues(List> input, E /// /// Test StringValues /// - [TestMethod] - [MarkdownTest("~\\Tests\\{method}.md")] + [Test] + [MarkdownDataSource("~\\Tests\\{method}.md")] public async Task Test_StringValues(StringValues input, Expected expected) { _ = await Verify.Assert(() => @@ -386,7 +380,7 @@ public async Task Test_StringValues(StringValues input, Expected expected) /// /// Test class /// - public class Test + public class Sample { /// /// Return Test diff --git a/src/MDATTests/Models/FormulaireWebFRW1DO.cs b/src/MDAT.Tests/Models/FormulaireWebFRW1DO.cs similarity index 100% rename from src/MDATTests/Models/FormulaireWebFRW1DO.cs rename to src/MDAT.Tests/Models/FormulaireWebFRW1DO.cs diff --git a/src/MDATTests/Models/SubType.cs b/src/MDAT.Tests/Models/SubType.cs similarity index 100% rename from src/MDATTests/Models/SubType.cs rename to src/MDAT.Tests/Models/SubType.cs diff --git a/src/MDATTests/NewtonsoftConvertersTests.cs b/src/MDAT.Tests/NewtonsoftConvertersTests.cs similarity index 82% rename from src/MDATTests/NewtonsoftConvertersTests.cs rename to src/MDAT.Tests/NewtonsoftConvertersTests.cs index 3f5e50f..0d3fb68 100644 --- a/src/MDATTests/NewtonsoftConvertersTests.cs +++ b/src/MDAT.Tests/NewtonsoftConvertersTests.cs @@ -3,15 +3,15 @@ namespace MDAT.Tests; -[TestClass] + public class NewtonsoftConvertersTests { /// /// Test JsonDocument Load /// This test whole document integrity /// - [TestMethod] - [MarkdownTest("~\\Tests\\md-json-document-test.md")] + [Test] + [MarkdownDataSource("~\\Tests\\md-json-document-test.md")] public async Task Md_JsonDocument_test(JsonDocument jsonDocument, Test1 test1, Expected expected, Expected expected2) { object value = await Verify.Assert(() => @@ -25,9 +25,9 @@ public async Task Md_JsonDocument_test(JsonDocument jsonDocument, Test1 test1, E /// /// Test SystemTextJsonFullConverter - JsonNode /// - [TestMethod] - [DoNotParallelize] - [MarkdownTest("~\\Tests\\{method}.md")] + [Test] + [NotInParallel] + [MarkdownDataSource("~\\Tests\\{method}.md")] public async Task Test_JsonNode(List nodes, Expected expected) { _ = await Verify.Assert(() => @@ -37,9 +37,9 @@ public async Task Test_JsonNode(List nodes, Expected expected) /// /// Test SystemTextJsonFullConverter - JsonValue /// - [TestMethod] - [DoNotParallelize] - [MarkdownTest("~\\Tests\\{method}.md")] + [Test] + [NotInParallel] + [MarkdownDataSource("~\\Tests\\{method}.md")] public async Task Test_JsonValue(List values, Expected expected) { _ = await Verify.Assert(() => @@ -49,9 +49,9 @@ public async Task Test_JsonValue(List values, Expected expected) /// /// Test SystemTextJsonFullConverter - JsonArray /// - [TestMethod] - [DoNotParallelize] - [MarkdownTest("~\\Tests\\{method}.md")] + [Test] + [NotInParallel] + [MarkdownDataSource("~\\Tests\\{method}.md")] public async Task Test_JsonArray(JsonArray array, Expected expected) { _ = await Verify.Assert(() => @@ -61,9 +61,9 @@ public async Task Test_JsonArray(JsonArray array, Expected expected) /// /// Test SystemTextJsonFullConverter - JsonElement /// - [TestMethod] - [DoNotParallelize] - [MarkdownTest("~\\Tests\\{method}.md")] + [Test] + [NotInParallel] + [MarkdownDataSource("~\\Tests\\{method}.md")] public async Task Test_JsonElement(JsonDocument doc, Expected expected) { JsonElement elem = doc.RootElement; // un objet complet diff --git a/src/MDATTests/Resolver/StringValuesTestYamlTypeConverter .cs b/src/MDAT.Tests/Resolver/StringValuesTestYamlTypeConverter .cs similarity index 100% rename from src/MDATTests/Resolver/StringValuesTestYamlTypeConverter .cs rename to src/MDAT.Tests/Resolver/StringValuesTestYamlTypeConverter .cs diff --git a/src/MDAT.Tests/SharedNameTests.cs b/src/MDAT.Tests/SharedNameTests.cs new file mode 100644 index 0000000..5d39c8a --- /dev/null +++ b/src/MDAT.Tests/SharedNameTests.cs @@ -0,0 +1,30 @@ +namespace MDAT.Tests; + +/// +/// Two classes with a test of the same name read the same markdown file ({method}.md): the file keeps one link per +/// class and method, so that the two lines do not take each other's place at every discovery. +/// +public class SharedNameFirstTests +{ + [Test] + [MarkdownDataSource("~/Tests/{method}.md")] + public async Task Shared_name_check(int a) + { + await Assert.That(a).IsGreaterThan(0); + } +} + +public class SharedNameSecondTests +{ + [Test] + [MarkdownDataSource("~/Tests/{method}.md")] + public async Task Shared_name_check(int a) + { + var markdown = await File.ReadAllTextAsync(TestContext.Current!.Metadata.TestDetails.Location.TestFilePath); + + // both classes have their own line in the file they share + await Assert.That(markdown).Contains(""); + await Assert.That(markdown).Contains(""); + await Assert.That(a).IsGreaterThan(0); + } +} diff --git a/src/MDAT.Tests/SkippedCaseTests.cs b/src/MDAT.Tests/SkippedCaseTests.cs new file mode 100644 index 0000000..580210e --- /dev/null +++ b/src/MDAT.Tests/SkippedCaseTests.cs @@ -0,0 +1,143 @@ +using System.Globalization; +using System.Reflection; + +namespace MDAT.Tests; + +/// A case marked skipped, or left out by a selected one, is listed and reported as ignored, never run. +public class SkippedCaseTests +{ + // Same signature as the tests of the markdown files written below + public void Some_method(int number, string text, DateOnly day, int? optional) { } + + private static MethodInfo SomeMethod => typeof(SkippedCaseTests).GetMethod(nameof(Some_method))!; + + private static IReadOnlyList Read(string markdown) + { + var folder = Directory.CreateTempSubdirectory("mdat-skipped-"); + + try + { + var path = Path.Combine(folder.FullName, "cases.md"); + File.WriteAllText(path, markdown); + + return new MarkdownTestSource(path).GetCases(SomeMethod); + } + finally + { + folder.Delete(recursive: true); + } + } + + private const string Cases = "# Cases\n\n" + + "## Runs\n\n``````yaml\nnumber: 1\ntext: a\nday: 2024-05-06\noptional: 2\n``````\n\n" + + "## Ignored\n\n``````yaml skipped\nnumber: 3\ntext: b\nday: 2024-05-07\noptional: 4\n``````\n\n" + + "## Unfinished\n\n``````yaml skipped\nthis: is not even: valid [yaml\n``````\n"; + + // a case that is not read (it is broken), one marked skipped, and the selected one that is last + private const string WithSelected = "# Cases\n\n" + + "## First\n\n``````yaml\nnumber: 1\ntext: a\nday: 2024-05-06\noptional: 2\n``````\n\n" + + "## Broken\n\n``````yaml\nthis: is not even: valid [yaml\n``````\n\n" + + "## Marked skipped\n\n``````yaml skipped\nnumber: 3\n``````\n\n" + + "## Only me\n\n``````yaml selected\nnumber: 9\ntext: z\nday: 2024-05-08\noptional: 1\n``````\n"; + + [Test] + public async Task A_skipped_case_is_listed_with_the_reason_it_is_ignored() + { + var cases = Read(Cases); + + await Assert.That(cases.Count).IsEqualTo(3); + await Assert.That(cases.Select(c => c.DisplayName)).IsEquivalentTo(new[] { "Some_method_Cases_Runs@cases.md", "Some_method_Cases_Ignored@cases.md", "Some_method_Cases_Unfinished@cases.md" }); + + await Assert.That(cases[0].SkipReason).IsNull(); + await Assert.That(cases[1].SkipReason).IsEqualTo(MarkdownTestSource.SkippedReason); + await Assert.That(cases[2].SkipReason).IsEqualTo(MarkdownTestSource.SkippedReason); + } + + [Test] + [Arguments("fr-CA", "Marqué comme ignoré (skipped) dans le fichier Markdown")] + [Arguments("fr-FR", "Marqué comme ignoré (skipped) dans le fichier Markdown")] + [Arguments("en-US", "Marked as skipped in the markdown file")] + [Arguments("de-DE", "Marked as skipped in the markdown file")] + public async Task The_reason_is_in_the_language_of_the_computer(string culture, string expected) + { + var previous = CultureInfo.CurrentUICulture; + CultureInfo.CurrentUICulture = new CultureInfo(culture); + + try + { + await Assert.That(Read(Cases)[1].SkipReason).IsEqualTo(expected); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Test] + public async Task The_arguments_of_a_skipped_case_match_the_parameters_of_the_method() + { + var skipped = Read(Cases)[1]; + + // the YAML of a skipped case is not read: default values, which the method never receives + await Assert.That(skipped.Arguments.Length).IsEqualTo(4); + await Assert.That(skipped.Arguments[0]).IsEqualTo(0); + await Assert.That(skipped.Arguments[1]).IsNull(); + await Assert.That(skipped.Arguments[2]).IsEqualTo(default(DateOnly)); + await Assert.That(skipped.Arguments[3]).IsNull(); + } + + [Test] + public async Task The_case_that_runs_keeps_its_values() + { + var cases = Read(Cases); + + await Assert.That(cases[0].Arguments[0]).IsEqualTo(1); + await Assert.That(cases[0].Arguments[1]).IsEqualTo("a"); + } + + [Test] + public async Task When_a_case_is_selected_the_others_stay_in_the_list_as_ignored() + { + var cases = Read(WithSelected); + + await Assert.That(cases.Count).IsEqualTo(4); + + // the others are not read (the broken one does not fail the file) and are ignored with the reason that fits + await Assert.That(cases[0].SkipReason).IsEqualTo(MarkdownTestSource.NotSelectedReason); + await Assert.That(cases[1].SkipReason).IsEqualTo(MarkdownTestSource.NotSelectedReason); + await Assert.That(cases[2].SkipReason).IsEqualTo(MarkdownTestSource.SkippedReason); + + // the selected one runs, with its values + await Assert.That(cases[3].SkipReason).IsNull(); + await Assert.That(cases[3].Arguments[0]).IsEqualTo(9); + await Assert.That(cases[3].Arguments[1]).IsEqualTo("z"); + } + + [Test] + public async Task A_case_both_selected_and_skipped_is_ignored_and_selects_nothing() + { + var cases = Read(Cases.Replace("``````yaml skipped\nnumber: 3", "``````yaml selected skipped\nnumber: 3")); + + // nothing else is selected: the case that runs still runs + await Assert.That(cases[0].SkipReason).IsNull(); + await Assert.That(cases[1].SkipReason).IsEqualTo(MarkdownTestSource.SkippedReason); + } + + [Test] + [Arguments("fr-CA", "Un autre cas est marqué selected dans le fichier Markdown")] + [Arguments("en-US", "Another case is marked selected in the markdown file")] + public async Task The_reason_of_a_case_that_is_not_selected_is_in_the_language_of_the_computer(string culture, string expected) + { + var previous = CultureInfo.CurrentUICulture; + CultureInfo.CurrentUICulture = new CultureInfo(culture); + + try + { + await Assert.That(Read(WithSelected)[0].SkipReason).IsEqualTo(expected); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } +} diff --git a/src/MDAT.Tests/SystemTextJsonSerializationTests.cs b/src/MDAT.Tests/SystemTextJsonSerializationTests.cs new file mode 100644 index 0000000..e2a2e01 --- /dev/null +++ b/src/MDAT.Tests/SystemTextJsonSerializationTests.cs @@ -0,0 +1,101 @@ +using Newtonsoft.Json; +using System.Text.Json; + +namespace MDAT.Tests; + +/// +/// How the System.Text.Json types are written in the actual JSON. A JsonDocument whose root is a string holding +/// a JSON object or array is written as that object or array (mdAT 1.4 behaviour). +/// +public class SystemTextJsonSerializationTests +{ + public class Row + { + public string? Name { get; set; } + + public JsonDocument? Donnees { get; set; } + } + + private static string Serialize(object value) => JsonConvert.SerializeObject(value, Extensions.JsonSerializeSettings()); + + [Test] + public async Task Document_with_a_string_root_holding_a_json_object_is_written_as_an_object() + { + var document = JsonDocument.Parse("\"{\\\"UneCle\\\": \\\"UneValeur\\\", \\\"UneAutreCle\\\": \\\"UneAutreValeur\\\"}\""); + + JsonAssert.Equal("""{"UneCle":"UneValeur","UneAutreCle":"UneAutreValeur"}""", Serialize(document)); + + await Assert.That(document.RootElement.ValueKind).IsEqualTo(JsonValueKind.String); + } + + [Test] + public async Task Document_with_a_string_root_holding_a_json_array_is_written_as_an_array() + { + var document = JsonDocument.Parse("\"[1, 2, {\\\"a\\\": null}]\""); + + JsonAssert.Equal("""[1,2,{"a":null}]""", Serialize(document)); + + await Assert.That(document.RootElement.ValueKind).IsEqualTo(JsonValueKind.String); + } + + [Test] + [Arguments("\"hello\"", "\"hello\"")] + [Arguments("\"\"", "\"\"")] + [Arguments("\"123\"", "\"123\"")] + [Arguments("\"{not json\"", "\"{not json\"")] + [Arguments("\"[1, 2\"", "\"[1, 2\"")] + public async Task Document_with_another_string_root_stays_a_string(string documentJson, string expectedJson) + { + JsonAssert.Equal(expectedJson, Serialize(JsonDocument.Parse(documentJson))); + + await Task.CompletedTask; + } + + [Test] + [Arguments("""{"a":[1,2]}""")] + [Arguments("""[1,"x",null]""")] + [Arguments("12.50")] + [Arguments("true")] + [Arguments("null")] + public async Task Document_with_another_root_is_written_unchanged(string json) + { + JsonAssert.Equal(json, Serialize(JsonDocument.Parse(json))); + + await Task.CompletedTask; + } + + [Test] + public async Task Element_follows_the_same_rule_as_document() + { + using var embedded = JsonDocument.Parse("\"{\\\"a\\\": 1}\""); + using var plain = JsonDocument.Parse("\"hello\""); + + JsonAssert.Equal("""{"a":1}""", Serialize(embedded.RootElement)); + // used to be written as the invalid JSON text: hello + JsonAssert.Equal("\"hello\"", Serialize(plain.RootElement)); + + await Task.CompletedTask; + } + + [Test] + public async Task Expected_object_matches_a_document_holding_the_object_as_text() + { + var rows = new[] + { + new Row { Name = "n", Donnees = JsonDocument.Parse("\"{\\\"UneCle\\\": \\\"UneValeur\\\"}\"") }, + new Row { Name = "m", Donnees = JsonDocument.Parse("""{"UneCle":"UneValeur"}""") }, + }; + + // the same expected object, whatever the form of the document + var expected = new Expected + { + verify = new[] + { + new VerifyStep { jsonPath = "$[0].Donnees", data = new Dictionary { ["UneCle"] = "UneValeur" } }, + new VerifyStep { jsonPath = "$[1].Donnees", data = new Dictionary { ["UneCle"] = "UneValeur" } }, + }, + }; + + _ = await Verify.Assert(() => Task.FromResult(rows), expected); + } +} diff --git a/src/MDAT.Tests/TestCaseLocationTests.cs b/src/MDAT.Tests/TestCaseLocationTests.cs new file mode 100644 index 0000000..49c4f2d --- /dev/null +++ b/src/MDAT.Tests/TestCaseLocationTests.cs @@ -0,0 +1,55 @@ +namespace MDAT.Tests; + +/// +/// Where the test explorer takes you when you open a case: its title in the markdown file (or the start of its +/// document in a YAML file), not the C# method shared by all the cases. +/// +public class TestCaseLocationTests +{ + [Test] + [MarkdownDataSource("~/Tests/adapter-locations.md")] + public async Task A_markdown_case_points_to_its_title(string heading) + { + var location = TestContext.Current!.Metadata.TestDetails.Location; + + await Assert.That(Path.GetFileName(location.TestFilePath)).IsEqualTo("adapter-locations.md"); + + var lines = await File.ReadAllLinesAsync(location.TestFilePath); + + // the line is 1-based and holds the title of the case + await Assert.That(lines[location.TestLineNumber - 1]).IsEqualTo(heading); + } + + [Test] + [YamlFileDataSource("~/Tests/adapter-locations.yaml")] + public async Task A_yaml_case_points_to_the_start_of_its_document(string heading) + { + var location = TestContext.Current!.Metadata.TestDetails.Location; + + await Assert.That(Path.GetFileName(location.TestFilePath)).IsEqualTo("adapter-locations.yaml"); + + var lines = await File.ReadAllLinesAsync(location.TestFilePath); + + await Assert.That(lines[location.TestLineNumber - 1]).IsEqualTo("---"); + // the document of the case follows its separator + await Assert.That(lines[location.TestLineNumber]).IsEqualTo("heading: " + heading); + } + + [Test] + [MarkdownDataSource("~/Tests/adapter-simple.md")] + public async Task The_cases_of_a_file_have_different_lines(int a, string b) + { + // The file is shared by three tests, each with its own link at the top: the link written for one of them must not + // move the lines found for another. Even on the very first run, the line of the case holds its title. + var location = TestContext.Current!.Metadata.TestDetails.Location; + + await Assert.That(location.TestLineNumber).IsGreaterThan(0); + await Assert.That(Path.GetFileName(location.TestFilePath)).IsEqualTo("adapter-simple.md"); + + var lines = await File.ReadAllLinesAsync(location.TestFilePath); + + await Assert.That(lines[location.TestLineNumber - 1]).StartsWith("## Case"); + await Assert.That(b).IsNotNull(); + await Assert.That(a).IsGreaterThan(0); + } +} diff --git a/src/MDATTests/Tests/Generated/output-expected.yml b/src/MDAT.Tests/Tests/Generated/output-expected.yml similarity index 100% rename from src/MDATTests/Tests/Generated/output-expected.yml rename to src/MDAT.Tests/Tests/Generated/output-expected.yml diff --git a/src/MDATTests/Tests/Generated/placeholder.txt b/src/MDAT.Tests/Tests/Generated/placeholder.txt similarity index 100% rename from src/MDATTests/Tests/Generated/placeholder.txt rename to src/MDAT.Tests/Tests/Generated/placeholder.txt diff --git a/src/MDATTests/Tests/Hello World.pdf b/src/MDAT.Tests/Tests/Hello World.pdf similarity index 100% rename from src/MDATTests/Tests/Hello World.pdf rename to src/MDAT.Tests/Tests/Hello World.pdf diff --git a/src/MDAT.Tests/Tests/adapter-dateonly.md b/src/MDAT.Tests/Tests/adapter-dateonly.md new file mode 100644 index 0000000..12b970f --- /dev/null +++ b/src/MDAT.Tests/Tests/adapter-dateonly.md @@ -0,0 +1,21 @@ +# T + +> C# : [AdapterTests.cs:30](../AdapterTests.cs#L30) + +## Case 1 + +first + +``````yaml +date: 2024-05-17 +time: "13:45:00" +`````` + +## Case 2 + +second + +``````yaml +date: 2024-06-01 +time: "08:00:00" +`````` diff --git a/src/MDAT.Tests/Tests/adapter-dictionary.md b/src/MDAT.Tests/Tests/adapter-dictionary.md new file mode 100644 index 0000000..1b1bf4f --- /dev/null +++ b/src/MDAT.Tests/Tests/adapter-dictionary.md @@ -0,0 +1,25 @@ +# T + +> C# : [AdapterTests.cs:38](../AdapterTests.cs#L38) + +## Case 1 + +first + +``````yaml +value: + D: + k: + z: 1 +`````` + +## Case 2 + +second + +``````yaml +value: + D: + k: + z: 2 +`````` diff --git a/src/MDAT.Tests/Tests/adapter-expected.md b/src/MDAT.Tests/Tests/adapter-expected.md new file mode 100644 index 0000000..fafdb99 --- /dev/null +++ b/src/MDAT.Tests/Tests/adapter-expected.md @@ -0,0 +1,31 @@ +# T + +> C# : [AdapterTests.cs:46](../AdapterTests.cs#L46) + +## Case 1 + +first + +``````yaml +input: + a: 5 +expected: + verify: + - type: match + jsonPath: $.a + data: [1, 2] +`````` + +## Case 2 + +second + +``````yaml +input: + a: 6 +expected: + verify: + - type: match + jsonPath: $.a + data: {x: 1} +`````` diff --git a/src/MDAT.Tests/Tests/adapter-ignore-array-order.md b/src/MDAT.Tests/Tests/adapter-ignore-array-order.md new file mode 100644 index 0000000..5d0719a --- /dev/null +++ b/src/MDAT.Tests/Tests/adapter-ignore-array-order.md @@ -0,0 +1,27 @@ +# Array_order_is_ignored_when_the_test_case_asks_for_it + +> C# : [AdapterTests.cs:54](../AdapterTests.cs#L54) + +## Case 1 + +Same values, other order: passes because `ignoreArrayOrder` is declared on the verification step. + +``````yaml +values: [3, 1, 2] +items: + - A: first + - A: second +expected: + verify: + - type: match + jsonPath: $.values + ignoreArrayOrder: true + data: [1, 2, 3] + - type: match + jsonPath: $.items + ignoreArrayOrder: true + allowAdditionalProperties: true + data: + - A: second + - A: first +`````` diff --git a/src/MDAT.Tests/Tests/adapter-locations.md b/src/MDAT.Tests/Tests/adapter-locations.md new file mode 100644 index 0000000..d4fda42 --- /dev/null +++ b/src/MDAT.Tests/Tests/adapter-locations.md @@ -0,0 +1,29 @@ +# Cases_point_to_their_title + +> C# : [TestCaseLocationTests.cs:10](../TestCaseLocationTests.cs#L10) + +Each case is located on its own title: the test explorer opens this file there, not the C# method. + +## First case + +``````yaml +heading: '## First case' +`````` + +## Second case + +Some text between the title and the case. + +### Nested title + +The deepest title above a case is the one that names it. + +``````yaml +heading: '### Nested title' +`````` + +## Third case + +``````yaml +heading: '## Third case' +`````` diff --git a/src/MDAT.Tests/Tests/adapter-locations.yaml b/src/MDAT.Tests/Tests/adapter-locations.yaml new file mode 100644 index 0000000..5fb79f0 --- /dev/null +++ b/src/MDAT.Tests/Tests/adapter-locations.yaml @@ -0,0 +1,7 @@ +--- +heading: first +--- +heading: second + +--- +heading: third diff --git a/src/MDAT.Tests/Tests/adapter-simple.md b/src/MDAT.Tests/Tests/adapter-simple.md new file mode 100644 index 0000000..eb95be2 --- /dev/null +++ b/src/MDAT.Tests/Tests/adapter-simple.md @@ -0,0 +1,25 @@ +# T + +> C# : [AdapterTests.cs:22](../AdapterTests.cs#L22) + +> C# : [AdapterTests.cs:65](../AdapterTests.cs#L65) + +> C# : [TestCaseLocationTests.cs:39](../TestCaseLocationTests.cs#L39) + +## Case 1 + +first + +``````yaml +a: 1 +b: x +`````` + +## Case 2 + +second + +``````yaml +a: 2 +b: y +`````` diff --git a/src/MDATTests/Tests/crash-test.md b/src/MDAT.Tests/Tests/crash-test.md similarity index 61% rename from src/MDATTests/Tests/crash-test.md rename to src/MDAT.Tests/Tests/crash-test.md index 5cfe77c..f1d5889 100644 --- a/src/MDATTests/Tests/crash-test.md +++ b/src/MDAT.Tests/Tests/crash-test.md @@ -1,5 +1,7 @@ # Crash_Test +> C# : [MarkdownTestAttributeTests.cs:194](../MarkdownTestAttributeTests.cs#L194) + > Exception test ## Case 1 diff --git a/src/MDATTests/Tests/dictionary-object-object-in-dictionary-object-object.md b/src/MDAT.Tests/Tests/dictionary-object-object-in-dictionary-object-object.md similarity index 64% rename from src/MDATTests/Tests/dictionary-object-object-in-dictionary-object-object.md rename to src/MDAT.Tests/Tests/dictionary-object-object-in-dictionary-object-object.md index 8fdcf49..06b70f7 100644 --- a/src/MDATTests/Tests/dictionary-object-object-in-dictionary-object-object.md +++ b/src/MDAT.Tests/Tests/dictionary-object-object-in-dictionary-object-object.md @@ -1,5 +1,7 @@ # Dictionary_object_object_in_Dictionary_object_object +> C# : [MarkdownTestAttributeTests.cs:184](../MarkdownTestAttributeTests.cs#L184) + > Dictionary_object_object in Dictionary_object_object ## Case 1 diff --git a/src/MDATTests/Tests/dictionary-object-object.md b/src/MDAT.Tests/Tests/dictionary-object-object.md similarity index 59% rename from src/MDATTests/Tests/dictionary-object-object.md rename to src/MDAT.Tests/Tests/dictionary-object-object.md index fc31167..d45be68 100644 --- a/src/MDATTests/Tests/dictionary-object-object.md +++ b/src/MDAT.Tests/Tests/dictionary-object-object.md @@ -1,5 +1,7 @@ # Dictionary_object_object +> C# : [MarkdownTestAttributeTests.cs:174](../MarkdownTestAttributeTests.cs#L174) + > Dictionary_object_object ## Case 1 diff --git a/src/MDATTests/Tests/exception-test.md b/src/MDAT.Tests/Tests/exception-test.md similarity index 84% rename from src/MDATTests/Tests/exception-test.md rename to src/MDAT.Tests/Tests/exception-test.md index 636f2c6..110e8c7 100644 --- a/src/MDATTests/Tests/exception-test.md +++ b/src/MDAT.Tests/Tests/exception-test.md @@ -1,5 +1,7 @@ # Exception_test +> C# : [MarkdownTestAttributeTests.cs:99](../MarkdownTestAttributeTests.cs#L99) + > Test Exception ## Case 1 diff --git a/src/MDATTests/Tests/ext_form.txt b/src/MDAT.Tests/Tests/ext_form.txt similarity index 100% rename from src/MDATTests/Tests/ext_form.txt rename to src/MDAT.Tests/Tests/ext_form.txt diff --git a/src/MDATTests/Tests/ext_form.yml b/src/MDAT.Tests/Tests/ext_form.yml similarity index 100% rename from src/MDATTests/Tests/ext_form.yml rename to src/MDAT.Tests/Tests/ext_form.yml diff --git a/src/MDATTests/Tests/external-file-base64-string.md b/src/MDAT.Tests/Tests/external-file-base64-string.md similarity index 57% rename from src/MDATTests/Tests/external-file-base64-string.md rename to src/MDAT.Tests/Tests/external-file-base64-string.md index ac15a0f..e1361a4 100644 --- a/src/MDATTests/Tests/external-file-base64-string.md +++ b/src/MDAT.Tests/Tests/external-file-base64-string.md @@ -1,5 +1,7 @@ # External_file_base64_String +> C# : [MarkdownTestAttributeTests.cs:144](../MarkdownTestAttributeTests.cs#L144) + > External file include base64 string ## Case 1 diff --git a/src/MDATTests/Tests/external-file-base64.md b/src/MDAT.Tests/Tests/external-file-base64.md similarity index 92% rename from src/MDATTests/Tests/external-file-base64.md rename to src/MDAT.Tests/Tests/external-file-base64.md index f503df2..c690cd2 100644 --- a/src/MDATTests/Tests/external-file-base64.md +++ b/src/MDAT.Tests/Tests/external-file-base64.md @@ -1,5 +1,7 @@ # External_file_base64 +> C# : [MarkdownTestAttributeTests.cs:124](../MarkdownTestAttributeTests.cs#L124) + > External file include base64 ## Case 1 diff --git a/src/MDATTests/Tests/external-file-byte-dictionary.md b/src/MDAT.Tests/Tests/external-file-byte-dictionary.md similarity index 91% rename from src/MDATTests/Tests/external-file-byte-dictionary.md rename to src/MDAT.Tests/Tests/external-file-byte-dictionary.md index e5061b8..b64c856 100644 --- a/src/MDATTests/Tests/external-file-byte-dictionary.md +++ b/src/MDAT.Tests/Tests/external-file-byte-dictionary.md @@ -1,5 +1,7 @@ # External_file_byte_dictionary +> C# : [MarkdownTestAttributeTests.cs:154](../MarkdownTestAttributeTests.cs#L154) + > External file include byte[] dictionary ## Case 1 diff --git a/src/MDATTests/Tests/external-file-string-dictionary.md b/src/MDAT.Tests/Tests/external-file-string-dictionary.md similarity index 91% rename from src/MDATTests/Tests/external-file-string-dictionary.md rename to src/MDAT.Tests/Tests/external-file-string-dictionary.md index 0a2f3f6..38c8cfa 100644 --- a/src/MDATTests/Tests/external-file-string-dictionary.md +++ b/src/MDAT.Tests/Tests/external-file-string-dictionary.md @@ -1,5 +1,7 @@ # External_file_string_dictionary +> C# : [MarkdownTestAttributeTests.cs:164](../MarkdownTestAttributeTests.cs#L164) + > External file include byte[] dictionary ## Case 1 diff --git a/src/MDATTests/Tests/external-file.md b/src/MDAT.Tests/Tests/external-file.md similarity index 74% rename from src/MDATTests/Tests/external-file.md rename to src/MDAT.Tests/Tests/external-file.md index 234b7b4..0170b80 100644 --- a/src/MDATTests/Tests/external-file.md +++ b/src/MDAT.Tests/Tests/external-file.md @@ -1,5 +1,7 @@ # External_file +> C# : [MarkdownTestAttributeTests.cs:89](../MarkdownTestAttributeTests.cs#L89) + > External file include ## Case 1 diff --git a/src/MDATTests/Tests/external-file2.md b/src/MDAT.Tests/Tests/external-file2.md similarity index 75% rename from src/MDATTests/Tests/external-file2.md rename to src/MDAT.Tests/Tests/external-file2.md index d3803c1..c9bc7ee 100644 --- a/src/MDATTests/Tests/external-file2.md +++ b/src/MDAT.Tests/Tests/external-file2.md @@ -1,5 +1,7 @@ # External_file2 +> C# : [MarkdownTestAttributeTests.cs:114](../MarkdownTestAttributeTests.cs#L114) + > External file include 2 ## Case 1 diff --git a/src/MDAT.Tests/Tests/json-document-with-a-json-string-root.md b/src/MDAT.Tests/Tests/json-document-with-a-json-string-root.md new file mode 100644 index 0000000..0def463 --- /dev/null +++ b/src/MDAT.Tests/Tests/json-document-with-a-json-string-root.md @@ -0,0 +1,149 @@ +# Json_document_with_a_json_string_root + +> C# : [JsonDocumentRegressionTests.cs:29](../JsonDocumentRegressionTests.cs#L29) + +> Regression: a JsonDocument whose root is a string holding JSON text is expected as a YAML object (mdAT 1.4 behaviour). + +## String root holding a JSON object + +The document root is the string `{"UneCle": ...}`: the expected value is the YAML object. + +``````yaml +donnees: '{"UneCle": "UneValeur", "UneAutreCle": "UneAutreValeur"}' +racineEstUneChaine: true +expected: + verify: + - type: match + jsonPath: $[0].Donnees + data: + UneCle: UneValeur + UneAutreCle: UneAutreValeur +`````` + +## Object root + +The other form of the same data: the root of the document is the object itself. Same expected value. + +``````yaml +donnees: '{"UneCle": "UneValeur", "UneAutreCle": "UneAutreValeur"}' +racineEstUneChaine: false +expected: + verify: + - type: match + jsonPath: $[0].Donnees + data: + UneCle: UneValeur + UneAutreCle: UneAutreValeur +`````` + +## String root holding a JSON array + +``````yaml +donnees: '[1, 2, {"UneCle": null}]' +racineEstUneChaine: true +expected: + verify: + - type: match + jsonPath: $[0].Donnees + data: + - 1 + - 2 + - UneCle: null +`````` + +## Whole recorded call + +The complete call, as the real test checks it: every property, `Donnees` being a YAML object. +This is the case that failed with mdAT 1.5.0 to 2.0.0-beta.1: when `jsonPath` targets `Donnees` directly, a string +holding JSON text was already read as JSON, but inside a larger expected object it stayed a string. + +``````yaml +donnees: '{"UneCle": "UneValeur", "UneAutreCle": "UneAutreValeur"}' +racineEstUneChaine: true +expected: + verify: + - type: match + jsonPath: $ + data: + - Gabarit: UnGabarit + Donnees: + UneCle: UneValeur + UneAutreCle: UneAutreValeur + Options: + Format: pdf + ProprietesDocument: + Titre: Un titre +`````` + +## Sub-property holding JSON text stays a string (string root) + +Only the root of the document is read as JSON. In the object, `Imbrique` is a string that happens to hold JSON text: +it stays a string, so it is expected as a string. + +``````yaml +donnees: '{"UneCle": "UneValeur", "Imbrique": "{\"x\": 1}"}' +racineEstUneChaine: true +expected: + verify: + - type: match + jsonPath: $ + data: + - Gabarit: UnGabarit + Donnees: + UneCle: UneValeur + Imbrique: '{"x": 1}' + Options: + Format: pdf + ProprietesDocument: + Titre: Un titre +`````` + +## Sub-property holding JSON text stays a string (object root) + +Same data when the root of the document is the object itself. + +``````yaml +donnees: '{"UneCle": "UneValeur", "Imbrique": "{\"x\": 1}"}' +racineEstUneChaine: false +expected: + verify: + - type: match + jsonPath: $ + data: + - Gabarit: UnGabarit + Donnees: + UneCle: UneValeur + Imbrique: '{"x": 1}' + Options: + Format: pdf + ProprietesDocument: + Titre: Un titre +`````` + +## String root that is not JSON stays a string + +A plain string, or a text that only looks like JSON, is not turned into an object. + +``````yaml +donnees: bonjour +racineEstUneChaine: true +expected: + verify: + - type: match + jsonPath: $[0].Donnees + data: bonjour +`````` + +## Old form still accepted: expected as a JSON string + +Written as JSON text, the expected object is still compared as an object. + +``````yaml +donnees: '{"UneCle": "UneValeur"}' +racineEstUneChaine: true +expected: + verify: + - type: match + jsonPath: $[0].Donnees + data: '{"UneCle": "UneValeur"}' +`````` diff --git a/src/MDATTests/Tests/json-path.md b/src/MDAT.Tests/Tests/json-path.md similarity index 67% rename from src/MDATTests/Tests/json-path.md rename to src/MDAT.Tests/Tests/json-path.md index d725aca..054ddc2 100644 --- a/src/MDATTests/Tests/json-path.md +++ b/src/MDAT.Tests/Tests/json-path.md @@ -1,5 +1,7 @@ # JsonPath +> C# : [MarkdownTestAttributeTests.cs:319](../MarkdownTestAttributeTests.cs#L319) + ## Case 1 Description diff --git a/src/MDATTests/Tests/md-extraction-test-nosummary.md b/src/MDAT.Tests/Tests/md-extraction-test-nosummary.md similarity index 84% rename from src/MDATTests/Tests/md-extraction-test-nosummary.md rename to src/MDAT.Tests/Tests/md-extraction-test-nosummary.md index d08a988..7eaee9d 100644 --- a/src/MDATTests/Tests/md-extraction-test-nosummary.md +++ b/src/MDAT.Tests/Tests/md-extraction-test-nosummary.md @@ -1,5 +1,7 @@ # Md_Extraction_test_nosummary +> C# : [MarkdownTestAttributeTests.cs:290](../MarkdownTestAttributeTests.cs#L290) + ## Case 1 Description @@ -51,6 +53,7 @@ expected: - type: match jsonPath: $ allowAdditionalProperties: false + ignoreArrayOrder: false data: null `````` `````` \ No newline at end of file diff --git a/src/MDATTests/Tests/md-extraction-test.md b/src/MDAT.Tests/Tests/md-extraction-test.md similarity index 86% rename from src/MDATTests/Tests/md-extraction-test.md rename to src/MDAT.Tests/Tests/md-extraction-test.md index 0cdf9e1..a363dbd 100644 --- a/src/MDATTests/Tests/md-extraction-test.md +++ b/src/MDAT.Tests/Tests/md-extraction-test.md @@ -1,5 +1,7 @@ # Md_Extraction_test +> C# : [MarkdownTestAttributeTests.cs:218](../MarkdownTestAttributeTests.cs#L218) + ## Case 1 Description @@ -56,6 +58,7 @@ expected: - type: match jsonPath: $ allowAdditionalProperties: false + ignoreArrayOrder: false data: null `````` `````` \ No newline at end of file diff --git a/src/MDATTests/Tests/md-json-document-test.md b/src/MDAT.Tests/Tests/md-json-document-test.md similarity index 76% rename from src/MDATTests/Tests/md-json-document-test.md rename to src/MDAT.Tests/Tests/md-json-document-test.md index 0c4ee53..3583ac6 100644 --- a/src/MDATTests/Tests/md-json-document-test.md +++ b/src/MDAT.Tests/Tests/md-json-document-test.md @@ -1,5 +1,7 @@ # Md_JsonDocument_test +> C# : [NewtonsoftConvertersTests.cs:14](../NewtonsoftConvertersTests.cs#L14) + > Test JsonDocument Load\ This test whole document integrity diff --git a/src/MDATTests/Tests/multi-level.md b/src/MDAT.Tests/Tests/multi-level.md similarity index 82% rename from src/MDATTests/Tests/multi-level.md rename to src/MDAT.Tests/Tests/multi-level.md index e1d783a..a330bff 100644 --- a/src/MDATTests/Tests/multi-level.md +++ b/src/MDAT.Tests/Tests/multi-level.md @@ -1,5 +1,7 @@ # Multi_level +> C# : [MarkdownTestAttributeTests.cs:79](../MarkdownTestAttributeTests.cs#L79) + > Multi level object ## Case 1 diff --git a/src/MDATTests/Tests/nullable-byte-array.md b/src/MDAT.Tests/Tests/nullable-byte-array.md similarity index 58% rename from src/MDATTests/Tests/nullable-byte-array.md rename to src/MDAT.Tests/Tests/nullable-byte-array.md index 6888476..e172c99 100644 --- a/src/MDATTests/Tests/nullable-byte-array.md +++ b/src/MDAT.Tests/Tests/nullable-byte-array.md @@ -1,5 +1,7 @@ # Nullable_byte_array +> C# : [MarkdownTestAttributeTests.cs:134](../MarkdownTestAttributeTests.cs#L134) + > Nullable byte[] ## Case 1 diff --git a/src/MDATTests/Tests/obtenir-scripts-js.md b/src/MDAT.Tests/Tests/obtenir-scripts-js.md similarity index 100% rename from src/MDATTests/Tests/obtenir-scripts-js.md rename to src/MDAT.Tests/Tests/obtenir-scripts-js.md diff --git a/src/MDATTests/Tests/output-expected.md b/src/MDAT.Tests/Tests/output-expected.md similarity index 66% rename from src/MDATTests/Tests/output-expected.md rename to src/MDAT.Tests/Tests/output-expected.md index 079c903..34d5cfb 100644 --- a/src/MDATTests/Tests/output-expected.md +++ b/src/MDAT.Tests/Tests/output-expected.md @@ -1,5 +1,7 @@ # Output_expected +> C# : [MarkdownTestAttributeTests.cs:308](../MarkdownTestAttributeTests.cs#L308) + > Test output expected ## Case 1 diff --git a/src/MDAT.Tests/Tests/shared-name-check.md b/src/MDAT.Tests/Tests/shared-name-check.md new file mode 100644 index 0000000..9cf2442 --- /dev/null +++ b/src/MDAT.Tests/Tests/shared-name-check.md @@ -0,0 +1,17 @@ +# Shared_name_check + +> C# : [SharedNameTests.cs:10](../SharedNameTests.cs#L10) + +> C# : [SharedNameTests.cs:20](../SharedNameTests.cs#L20) + +## Case 1 + +``````yaml +a: 1 +`````` + +## Case 2 + +``````yaml +a: 2 +`````` diff --git a/src/MDATTests/Tests/test-add-yaml-type-converter.md b/src/MDAT.Tests/Tests/test-add-yaml-type-converter.md similarity index 100% rename from src/MDATTests/Tests/test-add-yaml-type-converter.md rename to src/MDAT.Tests/Tests/test-add-yaml-type-converter.md diff --git a/src/MDAT.Tests/Tests/test-categories-selected.md b/src/MDAT.Tests/Tests/test-categories-selected.md new file mode 100644 index 0000000..02bb156 --- /dev/null +++ b/src/MDAT.Tests/Tests/test-categories-selected.md @@ -0,0 +1,40 @@ +--- +tags: + category: Facturation +--- +# Md_Selected + +> C# : [CategoryTests.cs:33](../CategoryTests.cs#L33) + +## Not selected + +Ignored: another case of the file is selected. + +``````yaml category=Autre +name: other +expectedCategories: Facturation,Autre +`````` + +## Selected + +The only case of the file that runs. + +``````yaml selected category=Focus +name: focus +expectedCategories: Facturation,Focus +`````` + +## Selected but skipped + +Ignored, and it selects nothing. + +``````yaml selected skipped category=Jamais +name: never +expectedCategories: Facturation,Jamais +`````` + +## Not selected either, not even finished + +``````yaml category=Brouillon +name: unfinished +`````` diff --git a/src/MDAT.Tests/Tests/test-categories.md b/src/MDAT.Tests/Tests/test-categories.md new file mode 100644 index 0000000..f7ff3a1 --- /dev/null +++ b/src/MDAT.Tests/Tests/test-categories.md @@ -0,0 +1,58 @@ +--- +title: Categories and properties of the cases +tags: + category: Facturation + priorite: Haute + domaine: Taxes +--- +# Md_Categories + +> C# : [CategoryTests.cs:13](../CategoryTests.cs#L13) + +## File only + +``````yaml +name: file +expectedCategories: Facturation +expectedProperties: domaine=Taxes;priorite=Haute +`````` + +## With a category + +``````yaml category=Smoke,Rapide +name: case +expectedCategories: Facturation,Rapide,Smoke +expectedProperties: domaine=Taxes;priorite=Haute +`````` + +## Quoted, and a property of the file replaced + +``````yaml category="Smoke" priorite=Basse +name: other +expectedCategories: Facturation,Smoke +expectedProperties: domaine=Taxes;priorite=Basse +`````` + +## Ignored, with its tags + +Ignored: the test method never receives this case. It still has the tags of the file and its own. + +``````yaml skipped category=Lent priorite=Basse +name: ignored +expectedCategories: Facturation,Lent +expectedProperties: domaine=Taxes;priorite=Basse +`````` + +## Ignored, tags first + +``````yaml category=Lent priorite=Basse skipped +name: ignored too +expectedCategories: Facturation,Lent +expectedProperties: domaine=Taxes;priorite=Basse +`````` + +## Ignored, not even finished + +``````yaml skipped category=Brouillon +name: unfinished +`````` diff --git a/src/MDATTests/Tests/test-json-array.md b/src/MDAT.Tests/Tests/test-json-array.md similarity index 67% rename from src/MDATTests/Tests/test-json-array.md rename to src/MDAT.Tests/Tests/test-json-array.md index e1c11d0..df9f5a5 100644 --- a/src/MDATTests/Tests/test-json-array.md +++ b/src/MDAT.Tests/Tests/test-json-array.md @@ -1,5 +1,7 @@ # Test_JsonArray +> C# : [NewtonsoftConvertersTests.cs:54](../NewtonsoftConvertersTests.cs#L54) + > Test SystemTextJsonFullConverter - JsonArray ## Case 1 diff --git a/src/MDATTests/Tests/test-json-element.md b/src/MDAT.Tests/Tests/test-json-element.md similarity index 72% rename from src/MDATTests/Tests/test-json-element.md rename to src/MDAT.Tests/Tests/test-json-element.md index dc4f4c1..3375abb 100644 --- a/src/MDATTests/Tests/test-json-element.md +++ b/src/MDAT.Tests/Tests/test-json-element.md @@ -1,5 +1,7 @@ # Test_JsonElement +> C# : [NewtonsoftConvertersTests.cs:66](../NewtonsoftConvertersTests.cs#L66) + > Test SystemTextJsonFullConverter - JsonElement ## Case 1 diff --git a/src/MDATTests/Tests/test-json-node.md b/src/MDAT.Tests/Tests/test-json-node.md similarity index 65% rename from src/MDATTests/Tests/test-json-node.md rename to src/MDAT.Tests/Tests/test-json-node.md index 529d1d3..b23a8f6 100644 --- a/src/MDATTests/Tests/test-json-node.md +++ b/src/MDAT.Tests/Tests/test-json-node.md @@ -1,5 +1,7 @@ # Test_JsonNode +> C# : [NewtonsoftConvertersTests.cs:30](../NewtonsoftConvertersTests.cs#L30) + > Test SystemTextJsonFullConverter ## Case 1 diff --git a/src/MDATTests/Tests/test-json-value.md b/src/MDAT.Tests/Tests/test-json-value.md similarity index 67% rename from src/MDATTests/Tests/test-json-value.md rename to src/MDAT.Tests/Tests/test-json-value.md index 696b0cc..9060d09 100644 --- a/src/MDATTests/Tests/test-json-value.md +++ b/src/MDAT.Tests/Tests/test-json-value.md @@ -1,5 +1,7 @@ # Test_JsonValue +> C# : [NewtonsoftConvertersTests.cs:42](../NewtonsoftConvertersTests.cs#L42) + > Test SystemTextJsonFullConverter - JsonValue ## Case 1 diff --git a/src/MDATTests/Tests/test-justme.md b/src/MDAT.Tests/Tests/test-justme.md similarity index 75% rename from src/MDATTests/Tests/test-justme.md rename to src/MDAT.Tests/Tests/test-justme.md index 2b5d180..a3004af 100644 --- a/src/MDATTests/Tests/test-justme.md +++ b/src/MDAT.Tests/Tests/test-justme.md @@ -1,4 +1,6 @@ # Demo tests + +> C# : [MarkdownTestAttributeTests.cs:52](../MarkdownTestAttributeTests.cs#L52) ## Cas 1 ``````yaml val1: 1 diff --git a/src/MDATTests/Tests/test-keypairvalues.md b/src/MDAT.Tests/Tests/test-keypairvalues.md similarity index 65% rename from src/MDATTests/Tests/test-keypairvalues.md rename to src/MDAT.Tests/Tests/test-keypairvalues.md index 880ce2a..c81dbe8 100644 --- a/src/MDATTests/Tests/test-keypairvalues.md +++ b/src/MDAT.Tests/Tests/test-keypairvalues.md @@ -1,5 +1,7 @@ # Test_keypairvalues +> C# : [MarkdownTestAttributeTests.cs:361](../MarkdownTestAttributeTests.cs#L361) + > Test object deserialization ## Case with keyvaluepair<,> diff --git a/src/MDATTests/Tests/test-object-deserialization.md b/src/MDAT.Tests/Tests/test-object-deserialization.md similarity index 88% rename from src/MDATTests/Tests/test-object-deserialization.md rename to src/MDAT.Tests/Tests/test-object-deserialization.md index 6de845b..21bc58c 100644 --- a/src/MDATTests/Tests/test-object-deserialization.md +++ b/src/MDAT.Tests/Tests/test-object-deserialization.md @@ -1,5 +1,7 @@ # Test_object_deserialization +> C# : [MarkdownTestAttributeTests.cs:252](../MarkdownTestAttributeTests.cs#L252) + > Test object deserialization ## Case 1 diff --git a/src/MDATTests/Tests/test-object-or-exception-async.md b/src/MDAT.Tests/Tests/test-object-or-exception-async.md similarity index 69% rename from src/MDATTests/Tests/test-object-or-exception-async.md rename to src/MDAT.Tests/Tests/test-object-or-exception-async.md index 042e01d..9d9defb 100644 --- a/src/MDATTests/Tests/test-object-or-exception-async.md +++ b/src/MDAT.Tests/Tests/test-object-or-exception-async.md @@ -1,5 +1,7 @@ # TestObjectOrExceptionAsync +> C# : [MarkdownTestAttributeTests.cs:345](../MarkdownTestAttributeTests.cs#L345) + > Test jsonPath array count ## Case 1 diff --git a/src/MDATTests/Tests/test-object-or-exception.md b/src/MDAT.Tests/Tests/test-object-or-exception.md similarity index 70% rename from src/MDATTests/Tests/test-object-or-exception.md rename to src/MDAT.Tests/Tests/test-object-or-exception.md index 0f98819..c315b4d 100644 --- a/src/MDATTests/Tests/test-object-or-exception.md +++ b/src/MDAT.Tests/Tests/test-object-or-exception.md @@ -1,5 +1,7 @@ # TestObjectOrException +> C# : [MarkdownTestAttributeTests.cs:329](../MarkdownTestAttributeTests.cs#L329) + > Test jsonPath array count ## Case 1 diff --git a/src/MDATTests/Tests/test-output-test-object-deserialization.md b/src/MDAT.Tests/Tests/test-output-test-object-deserialization.md similarity index 100% rename from src/MDATTests/Tests/test-output-test-object-deserialization.md rename to src/MDAT.Tests/Tests/test-output-test-object-deserialization.md diff --git a/src/MDATTests/Tests/test-skip.md b/src/MDAT.Tests/Tests/test-skip.md similarity index 78% rename from src/MDATTests/Tests/test-skip.md rename to src/MDAT.Tests/Tests/test-skip.md index 70df70c..35e7563 100644 --- a/src/MDATTests/Tests/test-skip.md +++ b/src/MDAT.Tests/Tests/test-skip.md @@ -1,4 +1,6 @@ # Demo tests + +> C# : [MarkdownTestAttributeTests.cs:63](../MarkdownTestAttributeTests.cs#L63) ## Cas 1.0 ``````yaml skipped val1: 1 diff --git a/src/MDATTests/Tests/test-string-values.md b/src/MDAT.Tests/Tests/test-string-values.md similarity index 62% rename from src/MDATTests/Tests/test-string-values.md rename to src/MDAT.Tests/Tests/test-string-values.md index dab9b61..5e6fcca 100644 --- a/src/MDATTests/Tests/test-string-values.md +++ b/src/MDAT.Tests/Tests/test-string-values.md @@ -1,5 +1,7 @@ # Test_StringValues +> C# : [MarkdownTestAttributeTests.cs:373](../MarkdownTestAttributeTests.cs#L373) + > Test StringValues ## Case 1 diff --git a/src/MDAT.Tests/Tests/test-tests-naming.md b/src/MDAT.Tests/Tests/test-tests-naming.md new file mode 100644 index 0000000..02f355b --- /dev/null +++ b/src/MDAT.Tests/Tests/test-tests-naming.md @@ -0,0 +1,17 @@ +# Test_Tests_Naming + +> C# : [MarkdownTestAttributeTests.cs:236](../MarkdownTestAttributeTests.cs#L236) + +> Test tests naming values + +## Case 1 + +Description + +``````yaml +expected: + verify: + - type: match + allowAdditionalProperties: false + data: "Md1_Demo tests_Cas 1_Cas 1.1_Cas 1.2.2.1_Cas 1.2.2.2.1_Cas 1.2.3.4.5.6@test.md" +`````` \ No newline at end of file diff --git a/src/MDATTests/Tests/test.md b/src/MDAT.Tests/Tests/test.md similarity index 75% rename from src/MDATTests/Tests/test.md rename to src/MDAT.Tests/Tests/test.md index 8f81374..3d46d49 100644 --- a/src/MDATTests/Tests/test.md +++ b/src/MDAT.Tests/Tests/test.md @@ -1,4 +1,6 @@ # Demo tests + +> C# : [MarkdownTestAttributeTests.cs:42](../MarkdownTestAttributeTests.cs#L42) ## Cas 1 ``````yaml val1: 1 diff --git a/src/MDATTests/Tests/test.yaml b/src/MDAT.Tests/Tests/test.yaml similarity index 100% rename from src/MDATTests/Tests/test.yaml rename to src/MDAT.Tests/Tests/test.yaml diff --git a/src/MDATTests/Utils.cs b/src/MDAT.Tests/Utils.cs similarity index 100% rename from src/MDATTests/Utils.cs rename to src/MDAT.Tests/Utils.cs diff --git a/src/MDAT.Tests/UtilsHelpersTests.cs b/src/MDAT.Tests/UtilsHelpersTests.cs new file mode 100644 index 0000000..334bc0c --- /dev/null +++ b/src/MDAT.Tests/UtilsHelpersTests.cs @@ -0,0 +1,78 @@ +using Newtonsoft.Json; + +namespace MDAT.Tests; + +/// The small helpers of that the markdown files rely on. +public class UtilsHelpersTests +{ + [Test] + [Arguments("Md1", "md1")] + [Arguments("ab", "ab")] + [Arguments("A", "A")] + [Arguments("AbCd", "ab-cd")] + [Arguments("Test_JsonNode", "test-json-node")] + [Arguments("Some_method_name", "some-method-name")] + [Arguments("XMLHttp", "x-m-l-http")] + public async Task A_method_name_becomes_the_name_of_its_markdown_file(string methodName, string expected) + { + await Assert.That(methodName.ToKebabCase()).IsEqualTo(expected); + } + + [Test] + public async Task A_null_method_name_is_refused() + { + string? name = null; + ArgumentNullException? error = null; + + try + { + _ = name!.ToKebabCase(); + } + catch (ArgumentNullException ex) + { + error = ex; + } + + await Assert.That(error).IsNotNull(); + } + + [Test] + [Arguments("{}", true)] + [Arguments("[1,2]", true)] + [Arguments("12.5", true)] + [Arguments("\"text\"", true)] + [Arguments("not json", false)] + [Arguments("{\"a\":", false)] + public async Task Text_is_recognized_as_json_or_not(string text, bool isJson) + { + await Assert.That(text.ValidateJSON()).IsEqualTo(isJson); + } + + [Test] + public async Task Both_kinds_of_separator_become_the_one_of_the_platform() + { + var separator = Path.DirectorySeparatorChar; + + await Assert.That("Tests\\sub/file.md".ReplacePlatformCompatiblePath()).IsEqualTo($"Tests{separator}sub{separator}file.md"); + await Assert.That("file.md".ReplacePlatformCompatiblePath()).IsEqualTo("file.md"); + } + + [Test] + public async Task Nested_key_value_pairs_become_nested_dictionaries() + { + // what YamlDotNet gives for a mapping inside a mapping: arrays of key/value pairs + var parsed = new Dictionary + { + ["a"] = 1, + ["b"] = new object[] + { + new KeyValuePair("x", 2), + new KeyValuePair("y", new object[] { new KeyValuePair("z", 3) }), + }, + }; + + var fixedDictionary = parsed.FixDict(); + + await Assert.That(JsonConvert.SerializeObject(fixedDictionary)).IsEqualTo("""{"a":1,"b":{"x":2,"y":{"z":3}}}"""); + } +} diff --git a/src/MDAT.Tests/VerifyTitleTests.cs b/src/MDAT.Tests/VerifyTitleTests.cs new file mode 100644 index 0000000..519d2e6 --- /dev/null +++ b/src/MDAT.Tests/VerifyTitleTests.cs @@ -0,0 +1,66 @@ +using System.Text.Json.Nodes; + +namespace MDAT.Tests; + +/// The title written before the raw result of a test names the type the way it is written in C#. +public class VerifyTitleTests +{ + [Test] + [Arguments(typeof(int), "Int32")] + [Arguments(typeof(string), "String")] + [Arguments(typeof(byte[]), "Byte[]")] + [Arguments(typeof(int[,]), "Int32[,]")] + [Arguments(typeof(int?), "Int32?")] + [Arguments(typeof(List), "List")] + [Arguments(typeof(Dictionary), "Dictionary")] + [Arguments(typeof(Dictionary>), "Dictionary>")] + [Arguments(typeof(IEnumerable>), "IEnumerable>")] + public async Task Type_title_is_readable(Type type, string expected) + { + await Assert.That(Verify.TypeTitle(type)).IsEqualTo(expected); + } + + [Test] + public async Task Anonymous_type_title_does_not_show_the_compiler_name() + { + await Assert.That(Verify.TypeTitle(new { Name = "x", Count = 1 }.GetType())).IsEqualTo("anonymous object"); + } + + // The three tests below read what Verify.Assert really writes in the output of the test. + + [Test] + public async Task Output_of_a_generic_result_has_a_readable_title() + { + var expected = new Expected { verify = new[] { new VerifyStep { data = "[1]" } } }; + + _ = await Verify.Assert(() => Task.FromResult(new List { JsonValue.Create(1) }), expected); + + var output = TestContext.Current!.GetStandardOutput(); + + await Assert.That(output).Contains("Actual raw for \"List\":"); + await Assert.That(output).DoesNotContain("`1"); + } + + [Test] + public async Task Output_of_a_dictionary_result_has_a_readable_title() + { + var expected = new Expected { verify = new[] { new VerifyStep { data = """{"a":"AQI="}""" } } }; + + _ = await Verify.Assert(() => Task.FromResult(new Dictionary { ["a"] = new byte[] { 1, 2 } }), expected); + + var output = TestContext.Current!.GetStandardOutput(); + + await Assert.That(output).Contains("Actual raw for \"Dictionary\":"); + await Assert.That(output).DoesNotContain("`2"); + } + + [Test] + public async Task Name_given_in_the_expected_replaces_the_type_title() + { + var expected = new Expected { name = "Résultat du service", verify = new[] { new VerifyStep { data = "[1]" } } }; + + _ = await Verify.Assert(() => Task.FromResult(new List { 1 }), expected); + + await Assert.That(TestContext.Current!.GetStandardOutput()).Contains("Actual raw for \"Résultat du service\":"); + } +} diff --git a/src/MDAT.Tests/VisualStudioLinkParserTests.cs b/src/MDAT.Tests/VisualStudioLinkParserTests.cs new file mode 100644 index 0000000..3d67cf4 --- /dev/null +++ b/src/MDAT.Tests/VisualStudioLinkParserTests.cs @@ -0,0 +1,93 @@ +using MDAT.VisualStudio; + +namespace MDAT.Tests; + +/// +/// How the Visual Studio extension reads the link to the C# test. The parser is compiled in this project as it has no +/// dependency on Visual Studio (the extension itself cannot be built for .NET 8+). +/// +public class VisualStudioLinkParserTests +{ + private const string Line = "> C# : [Code Tests.cs:12](../Code%20Tests.cs#L12) "; + + [Test] + public async Task The_link_written_by_mdat_is_found_with_its_target_and_line() + { + var links = MdatLinkParser.FindAll(Line).ToList(); + + await Assert.That(links.Count).IsEqualTo(1); + await Assert.That(links[0].Target).IsEqualTo("../Code%20Tests.cs"); + await Assert.That(links[0].Line).IsEqualTo(12); + await Assert.That(Line.Substring(links[0].Start, links[0].Length)).IsEqualTo("[Code Tests.cs:12](../Code%20Tests.cs#L12)"); + } + + [Test] + public async Task Only_the_link_itself_is_clickable() + { + var found = MdatLinkParser.TryFind(Line, Line.IndexOf("Code Tests.cs:12", StringComparison.Ordinal), out var link); + + await Assert.That(found).IsTrue(); + await Assert.That(link.Line).IsEqualTo(12); + + // the beginning of the line and the comment are not part of the link + await Assert.That(MdatLinkParser.TryFind(Line, 0, out _)).IsFalse(); + await Assert.That(MdatLinkParser.TryFind(Line, Line.IndexOf("")] + [Arguments("[Site](https://example.com/page) ")] + [Arguments("")] + public async Task The_other_links_of_a_file_are_left_alone(string text) + { + await Assert.That(MdatLinkParser.FindAll(text).Any()).IsFalse(); + } + + [Test] + public async Task A_relative_link_is_resolved_from_the_folder_of_the_markdown_file() + { + var markdown = Path.Combine(Path.GetTempPath(), "project", "Tests", "probe.md"); + MdatLinkParser.TryFind(Line, 10, out var link); + + await Assert.That(MdatLinkParser.Resolve(markdown, link)) + .IsEqualTo(Path.Combine(Path.GetTempPath(), "project", "Code Tests.cs")); + } + + [Test] + public async Task The_file_forms_of_the_link_are_read_too() + { + var markdown = Path.Combine(Path.GetTempPath(), "project", "Tests", "probe.md"); + + var relative = "[A.cs:1](file:../A.cs#L1) "; + MdatLinkParser.TryFind(relative, 2, out var linkRelative); + await Assert.That(MdatLinkParser.Resolve(markdown, linkRelative)).IsEqualTo(Path.Combine(Path.GetTempPath(), "project", "A.cs")); + + var full = new Uri(Path.Combine(Path.GetTempPath(), "other folder", "B.cs")).AbsoluteUri; + var absolute = $"[B.cs:2]({full}#L2) "; + MdatLinkParser.TryFind(absolute, 2, out var linkAbsolute); + await Assert.That(MdatLinkParser.Resolve(markdown, linkAbsolute)).IsEqualTo(Path.Combine(Path.GetTempPath(), "other folder", "B.cs")); + } + + [Test] + public async Task Every_link_of_the_real_markdown_files_of_the_project_is_found_and_leads_to_an_existing_file() + { + var folder = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "Tests"); + var withLink = 0; + + foreach (var markdown in Directory.EnumerateFiles(Path.GetFullPath(folder), "*.md")) + { + foreach (var text in MarkdownFinder.ReadLines(markdown).Where(l => l.Contains("\n"); + File.WriteAllText(Path.Combine(root.FullName, "Tests", "short.md"), "# Short\n\n\n> C# : [Demo.cs:5](../Demo.cs#L5) \n"); + File.WriteAllText(Path.Combine(root.FullName, "obj", "ignored.md"), "\n"); + + var found = MarkdownFinder.Find(source, "DemoTests", "Add"); + + await Assert.That(found).IsNotNull(); + await Assert.That(Path.GetFileName(found!.Value.Path)).IsEqualTo("short.md"); + await Assert.That(found.Value.Line).IsEqualTo(4); + + await Assert.That(MarkdownFinder.Find(source, "DemoTests", "Hidden")).IsNull(); + await Assert.That(MarkdownFinder.Find(source, "DemoTests", "Missing")).IsNull(); + await Assert.That(MarkdownFinder.SearchRoot(source)).IsEqualTo(root.FullName); + } + finally + { + root.Delete(recursive: true); + } + } +} diff --git a/src/MDATTests/YamlFileDataAttributeTests.cs b/src/MDAT.Tests/YamlFileDataAttributeTests.cs similarity index 82% rename from src/MDATTests/YamlFileDataAttributeTests.cs rename to src/MDAT.Tests/YamlFileDataAttributeTests.cs index 15d9ed1..4c2b890 100644 --- a/src/MDATTests/YamlFileDataAttributeTests.cs +++ b/src/MDAT.Tests/YamlFileDataAttributeTests.cs @@ -1,4 +1,4 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; + using System.Diagnostics.CodeAnalysis; namespace MDAT.Tests @@ -7,8 +7,8 @@ namespace MDAT.Tests [ExcludeFromCodeCoverage] public class YamlFileDataAttributeTests { - [TestMethod] - [YamlFileData("~\\Tests\\test.yaml")] + [Test] + [YamlFileDataSource("~\\Tests\\test.yaml")] public async Task YamlFileDataAttributeTestAsync(int val1, int val2, string expected) { object value = await Verify.Assert(() => diff --git a/src/MDAT.VisualStudio/FileOpener.cs b/src/MDAT.VisualStudio/FileOpener.cs new file mode 100644 index 0000000..1ddd561 --- /dev/null +++ b/src/MDAT.VisualStudio/FileOpener.cs @@ -0,0 +1,42 @@ +using System; +using System.IO; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; + +namespace MDAT.VisualStudio +{ + /// Opens a file in Visual Studio on a line, and tells what went wrong in the status bar. + internal static class FileOpener + { + public static void Open(IServiceProvider services, string path, int line) + { + ThreadHelper.ThrowIfNotOnUIThread(); + + if (!File.Exists(path)) + { + Notify(services, $"mdAT: the file {path} does not exist."); + return; + } + + VsShellUtilities.OpenDocument(services, path, Guid.Empty, out _, out _, out var frame); + + var textView = frame is null ? null : VsShellUtilities.GetTextView(frame); + + if (textView is not null && line > 0) + { + textView.SetCaretPos(line - 1, 0); + textView.CenterLines(line - 1, 1); + } + } + + public static void Notify(IServiceProvider services, string message) + { + ThreadHelper.ThrowIfNotOnUIThread(); + + if (services.GetService(typeof(SVsStatusbar)) is IVsStatusbar statusBar) + { + statusBar.SetText(message); + } + } + } +} diff --git a/src/MDAT.VisualStudio/MDAT.VisualStudio.csproj b/src/MDAT.VisualStudio/MDAT.VisualStudio.csproj new file mode 100644 index 0000000..9571cdb --- /dev/null +++ b/src/MDAT.VisualStudio/MDAT.VisualStudio.csproj @@ -0,0 +1,46 @@ + + + + + + + net472 + latest + enable + MDAT.VisualStudio + MDAT.VisualStudio + false + true + false + false + true + false + false + false + $(NoWarn);VSTHRD010;VSTHRD109 + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MDAT.VisualStudio/MdatLinkParser.cs b/src/MDAT.VisualStudio/MdatLinkParser.cs new file mode 100644 index 0000000..6a21fac --- /dev/null +++ b/src/MDAT.VisualStudio/MdatLinkParser.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; + +namespace MDAT.VisualStudio +{ + /// A link to a C# test written by mdAT in a markdown file: [Tests.cs:49](../Tests.cs#L49). + internal readonly struct MdatLink + { + public MdatLink(int start, int length, string target, int line) + { + Start = start; + Length = length; + Target = target; + Line = line; + } + + /// Position of the link in its line. + public int Start { get; } + + public int Length { get; } + + /// Address of the C# file as written in the markdown file, without the line. + public string Target { get; } + + /// 1-based line of the test in the C# file. + public int Line { get; } + + public bool Contains(int column) => column >= Start && column < Start + Length; + } + + /// + /// Finds the links that mdAT keeps at the top of a markdown test file + /// (> C# : [Tests.cs:49](../Tests.cs#L49) <!-- mdat:Tests.cs#Class.Method -->). Only a link followed by the + /// mdat: comment is taken: the other links of the file are none of our business. + /// + internal static class MdatLinkParser + { + private const string Marker = " - net6.0;net7.0;net8.0;net9.0;net10.0 - enable - enable - True - false - anisite - MESS - https://github.com/MTESSDev/mdAT - https://github.com/MTESSDev/mdAT - 1.6 - Markdown Auto-Tests for .net 6+, based on MSTestsV2 DataSource. + net8.0 + false + False + $(NoWarn);NU5128 + MDAT + MDAT + MDAT + false + anisite + MESS + https://github.com/MTESSDev/mdAT + https://github.com/MTESSDev/mdAT + git + 2.0.0 + Markdown Auto-Tests for .net 8+ and TUnit: write your test cases in markdown files. This package brings MDAT.TUnit and MDAT.Core. + markdown;testing;yaml;tunit;test-automation + mdAT 2.0 replaces MSTest by TUnit and ships as MDAT.Core (framework independent) and MDAT.TUnit. Projects staying on MSTest can keep using 1.x. In the markdown files: a test case is named Method_Title@file.md; the test explorer points each case to its title in the markdown file (or to the start of its document in a YAML file); each markdown test file keeps a link to its C# test; a case marked skipped is listed and reported as ignored, and when a case is marked selected the other cases of the file stay in the list, ignored; a case can have categories and properties (category=Smoke,Fast priority=High in the line of its code block, tags: in the front matter of the file); a JsonDocument or JsonElement whose root is a string holding a JSON object or array is written as that object or array again (mdAT 1.4 behaviour); the file of generateExpectedData is written again when another process is using it. See the migration section of the README. + README.md + LICENSE - - obj\$(Configuration)\YamlHttpClient.xml - README.md - LICENSE - - - - - True - \ - - - True - \ - - - - - - - - - - - - - + + True + \ + + + True + \ + - - - - - + diff --git a/src/MDAT/MarkdownTest.cs b/src/MDAT/MarkdownTest.cs deleted file mode 100644 index 04581ad..0000000 --- a/src/MDAT/MarkdownTest.cs +++ /dev/null @@ -1,263 +0,0 @@ -using LoxSmoke.DocXml; -using Markdig; -using Markdig.Syntax; -using MDAT.Resolver; -using System.ComponentModel; -using System.Reflection; -using System.Text; -using YamlDotNet.Core; -using YamlDotNet.Serialization; - -namespace MDAT -{ - [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] - public class MarkdownTestAttribute : Attribute, ITestDataSource - { - private string _filePath; - - public string ParsedPath { get; set; } - - private Dictionary displayNames = new Dictionary(); - - public MarkdownTestAttribute(string filePath) - { - _filePath = filePath; - ParsedPath = _filePath; - } - - public IEnumerable GetData(MethodInfo testMethod) - { - if (testMethod == null) { throw new ArgumentNullException(nameof(testMethod)); } - - _filePath = _filePath.Replace("{method}", testMethod.Name.ToKebabCase().ToLower()); - ParsedPath = Extensions.GetCurrentPath(_filePath, false); - - if (!File.Exists(ParsedPath)) - { - CreateMardownFile(testMethod); - return Array.Empty(); - } - - List to = LoadMarkdownFile(testMethod); - - return to; - } - - private List LoadMarkdownFile(MethodInfo testMethod) - { - var fileData = File.ReadAllText(ParsedPath); - - var mdFile = Markdown.Parse(fileData); - - string?[] headings = new string?[] { null, null, null, null, null, null }; - - List to = new(); - List toSelected = new(); - - foreach (var info in mdFile) - { - if (info is HeadingBlock add) - { - SetHeadings(headings, add); - } - else if (info is FencedCodeBlock fbc - && fbc.ClosingFencedCharCount == 6 - && (fbc.Info?.ToLower() == "yaml" || fbc.Info?.ToLower() == "yml")) - { - var doc = string.Join("\n", fbc.Lines); - - if (string.IsNullOrWhiteSpace(doc)) - continue; - - if (fbc.Arguments is { } && fbc.Arguments.Contains("skipped", StringComparison.InvariantCultureIgnoreCase)) - continue; - - var values = ExtractTest(testMethod, doc, Path.GetDirectoryName(ParsedPath)!); - - displayNames.Add(values.GetHashCode(), "_" + string.Join("_", headings.Where(s => !string.IsNullOrWhiteSpace(s)))); - - if (fbc.Arguments is { } && fbc.Arguments.Contains("selected", StringComparison.InvariantCultureIgnoreCase)) - toSelected.Add(values); - else - to.Add(values); - } - } - - if (toSelected.Any()) - return toSelected; - else - return to; - } - - private static void SetHeadings(string?[] heading, HeadingBlock add) - { - heading[add.Level - 1] = add.Inline?.FirstOrDefault()?.ToString(); - - for (int i = add.Level; i < 6; i++) - { - heading[i] = null; - } - } - - private static object[] ExtractTest(MethodInfo testMethod, string doc, string directoryName) - { - var resolver = new MDATYamlTypeResolver(testMethod); - - IDeserializer deserializer = NewDeserializer(resolver, directoryName); - - object[]? values; - try - { - var ggs = deserializer.Deserialize>(doc); - - if (ggs is null) return default!; - values = ggs.Values.ToArray(); - - } - catch (YamlException ex) - { - var invalidName = testMethod.GetParameters()[resolver.Position - 1]; - throw new InternalTestFailureException($"Method '{testMethod.Name}' exception on '{invalidName.Name}' parameter.'\n{ex.Message}\n{ex.InnerException?.Message}", ex); - } - - return values; - } - - private void CreateMardownFile(MethodInfo testMethod) - { - MethodComments? methodComments = null; - - string directoryPath = testMethod.DeclaringType!.Assembly.Location; - string xmlFilePath = directoryPath.Replace(".dll", ".xml"); - - if (File.Exists(xmlFilePath)) - { - DocXmlReader reader = new DocXmlReader(xmlFilePath); - methodComments = reader.GetMethodComments(testMethod); - } - - var code = ""; - - foreach (var item in testMethod.GetParameters()) - { - var paramsDetails = methodComments?.Parameters.Where(e => e.Name == item.Name).FirstOrDefault(); - - var strDetails = paramsDetails is { } - && paramsDetails.Value.Text is { } - ? $"# {paramsDetails.Value.Text}\n" - : ""; - - code += $"{strDetails}{item.Name}:\n{DescribeTypeOfObject(item.ParameterType, " ")}"; - } - - var summary = methodComments is { } - && !string.IsNullOrWhiteSpace(methodComments.Summary) - ? $"\n\n> {methodComments?.Summary.ReplaceLineEndings("\\\n")}" - : ""; - - File.WriteAllText(ParsedPath, $"# {testMethod.Name}{summary}\n\n## Case 1\n\nDescription\n\n``````yaml\n{code}``````", Encoding.UTF8); - } - - static string? DescribeTypeOfObject(Type type, string indent, int pos = 0) - { - if (pos == 10) - return string.Empty; // Loop protection - else - pos++; - - string? obj = null; - - if (type.IsClass && !type.FullName!.StartsWith("System.")) - { - PropertyInfo[] propertyInfos = type.GetProperties(); - foreach (PropertyInfo pi in propertyInfos) - { - var def = GetDefaultValueForProperty(pi); - - var innerObj = DescribeTypeOfObject(pi.PropertyType, indent + " ", pos); - - obj += $"{indent}{pi.Name}: {(string.IsNullOrEmpty(innerObj) ? GetDefaultValue(def) : "\n" + innerObj)}"; - } - } - else if (type.Name.StartsWith("IEnumerable")) - { - PropertyInfo[] propertyInfos = type.GetGenericArguments()[0].GetProperties(); - - var premier = true; - - foreach (PropertyInfo pi in propertyInfos) - { - var def = GetDefaultValueForProperty(pi); - - var innerObj = DescribeTypeOfObject(pi.PropertyType, indent + " ", pos); - - obj += $"{indent}{(premier ? "- " : " ")}{pi.Name}: {(string.IsNullOrEmpty(innerObj) ? GetDefaultValue(def) : "\n" + innerObj)}"; - premier = false; - } - } - - - return obj; - } - - private static string GetDefaultValue(object? def) - { - return def is { } ? new Serializer().Serialize(def).ReplaceLineEndings("\n") : "null\n"; - } - - public static object? GetDefaultValueForProperty(PropertyInfo property) - { - var defaultAttr = property.GetCustomAttribute(typeof(DefaultValueAttribute)); - if (defaultAttr != null) - return (defaultAttr as DefaultValueAttribute)?.Value; - - var propertyType = property.PropertyType; - return propertyType.IsValueType ? Activator.CreateInstance(propertyType) : null; - } - - private static IDeserializer NewDeserializer(INodeTypeResolver resolver, string directoryName) - { - DeserializerBuilder deserializer = new DeserializerBuilder() - .WithTypeConverter(new ByteArayConverter(), e => e.OnBottom()) - .WithTypeConverter(new SystemTextJsonYamlTypeConverter(false, true)) - .WithTypeInspector(x => new YamlDotNet.System.Text.Json.SystemTextJsonTypeInspector(x)) - .WithTypeConverter(new StringValuesConverter()) - .WithNodeTypeResolver(resolver) - .WithNodeDeserializer(new KeyValuePairNodeDeserializer()) - .IgnoreUnmatchedProperties() - .WithAttemptingUnquotedStringTypeDeserialization() - .WithTagMapping(MdatConstants.IncludeTag, typeof(IncludeRef)); - - foreach (var typeConverter in MdatConfig.ListeTypeConverter) - { - _ = typeConverter.Where is { } ? deserializer.WithTypeConverter(typeConverter.TypeConverter!, typeConverter.Where) : deserializer.WithTypeConverter(typeConverter.TypeConverter!); - } - - var includeNodeDeserializerOptions = new YamlIncludeNodeDeserializerOptions - { - DirectoryName = directoryName, - //Builder = deserializer - }; - - var includeNodeDeserializer = new YamlIncludeNodeDeserializer(includeNodeDeserializerOptions); - - deserializer.WithNodeDeserializer(includeNodeDeserializer, s => s.OnTop()); - - return deserializer.Build(); - } - - public string? GetDisplayName(MethodInfo methodInfo, object?[]? data) - { - var parameters = methodInfo.GetParameters(); - - - if (data != null) - { - var name = displayNames[data.GetHashCode()]; - return $"{methodInfo.Name}@{_filePath}{name}"; - } - - return null; - } - } -} \ No newline at end of file diff --git a/src/MDAT/YamlFileDataAttribute.cs b/src/MDAT/YamlFileDataAttribute.cs deleted file mode 100644 index cd08bbc..0000000 --- a/src/MDAT/YamlFileDataAttribute.cs +++ /dev/null @@ -1,105 +0,0 @@ -using System.Reflection; -using System.Text.RegularExpressions; -using YamlDotNet.Serialization; -using MDAT.Resolver; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using System.Globalization; -using Newtonsoft.Json; -using System.Diagnostics.CodeAnalysis; - -namespace MDAT -{ - [ExcludeFromCodeCoverage] - [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] - public class YamlFileDataAttribute : Attribute, ITestDataSource - { - private readonly string _filePath; - private readonly string _propertyName; - - private Dictionary displayNames = new Dictionary(); - - public YamlFileDataAttribute(string filePath) - { - _filePath = filePath; - } - - /// - public IEnumerable GetData(MethodInfo testMethod) - { - if (testMethod == null) { throw new ArgumentNullException(nameof(testMethod)); } - - var path = _filePath.Replace("~\\", "..\\..\\..\\"); - - // Get the absolute path to the JSON file - path = Path.IsPathRooted(path) - ? path - : Path.GetRelativePath(Directory.GetCurrentDirectory(), path); - - if (!File.Exists(path)) - { - throw new Exception($"Could not find file at path: {path}"); - } - - // Load the file - var fileData = File.ReadAllText(path); - - if (string.IsNullOrEmpty(_propertyName)) - { - IEnumerable retour; - - var docs = Regex.Split(fileData, @"^---", RegexOptions.Multiline); - - List to = new List(); - - - foreach (var doc in docs) - { - if (string.IsNullOrWhiteSpace(doc)) continue; - - - IDeserializer deserializer = NewDeserilizer(testMethod); - - var ggs = deserializer.Deserialize>("---\n" + doc); - - if (ggs is null) continue; - - var arr = ggs.Values.ToArray(); - - displayNames.Add(arr.GetHashCode(), "Nom " + arr[0]); - - to.Add(arr); - } - - - return to; - - } - - return null; - } - - private static IDeserializer NewDeserilizer(MethodInfo testMethod) - { - IDeserializer deserializer = new DeserializerBuilder() - .WithNodeTypeResolver(new MDATYamlTypeResolver(testMethod)) - .WithTypeConverter(new ByteArayConverter()) - .WithAttemptingUnquotedStringTypeDeserialization() - .IgnoreUnmatchedProperties() - .Build(); - return deserializer; - } - - public string? GetDisplayName(MethodInfo methodInfo, object?[]? data) - { - var parameters = methodInfo.GetParameters(); - - if (data != null) - { - var name = displayNames[data.GetHashCode()]; - return $"{methodInfo.Name}@{_filePath}_#{JsonConvert.SerializeObject(data)}"; - } - - return null; - } - } -} \ No newline at end of file diff --git a/src/MDATTests/CustomResolverTests.cs b/src/MDATTests/CustomResolverTests.cs deleted file mode 100644 index aa6143f..0000000 --- a/src/MDATTests/CustomResolverTests.cs +++ /dev/null @@ -1,29 +0,0 @@ -using MDAT; -using MDATTests.Resolver; -using Microsoft.Extensions.Primitives; - -[assembly: Parallelize(Workers = 0, Scope = ExecutionScope.MethodLevel)] - -namespace MDAT.Tests; - -[TestClass] -public class CustomResolverTests -{ - [ClassInitialize] - public static void ClassInitialize(TestContext testContext) - { - MdatConfig.AddYamlTypeConverter(new StringValuesTestYamlTypeConverter()); - } - - /// - /// Test AddYamlTypeConverter - /// - [TestMethod] - [DoNotParallelize] - [MarkdownTest("~\\Tests\\{method}.md")] - public async Task Test_AddYamlTypeConverter(StringValues input, Expected expected) - { - _ = await Verify.Assert(() => - Task.FromResult(input), expected); - } -} diff --git a/src/MDATTests/MDATTests.csproj b/src/MDATTests/MDATTests.csproj deleted file mode 100644 index a8ab2e4..0000000 --- a/src/MDATTests/MDATTests.csproj +++ /dev/null @@ -1,63 +0,0 @@ - - - - net6.0;net7.0;net8.0;net9.0;net10.0 - enable - enable - false - True - - - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - Always - - - Always - - - Always - - - Always - - - - - - - - - - - - - - Always - - - - diff --git a/src/MDATTests/Tests/test-tests-naming.md b/src/MDATTests/Tests/test-tests-naming.md deleted file mode 100644 index e048df9..0000000 --- a/src/MDATTests/Tests/test-tests-naming.md +++ /dev/null @@ -1,15 +0,0 @@ -# Test_Tests_Naming - -> Test tests naming values - -## Case 1 - -Description - -``````yaml -expected: - verify: - - type: match - allowAdditionalProperties: false - data: "Md1@~\\Tests\\test.md_Demo tests_Cas 1_Cas 1.1_Cas 1.2.2.1_Cas 1.2.2.2.1_Cas 1.2.3.4.5.6" -`````` \ No newline at end of file diff --git a/src/MDATTests/coverage.bat b/src/MDATTests/coverage.bat deleted file mode 100644 index 0e0bacc..0000000 --- a/src/MDATTests/coverage.bat +++ /dev/null @@ -1,5 +0,0 @@ -REM $env:VSTEST_HOST_DEBUG=1 -dotnet test --collect:"XPlat Code Coverage" --settings .\..\.runsettings -reportgenerator -reports:.\TestResults\*\coverage.cobertura.xml -targetdir:coveragereport -assemblyfilters:"-*Tests;" -del /s /q .\TestResults\ -rmdir /s /q .\TestResults\ \ No newline at end of file