diff --git a/.forgejo/workflows/buildAndTest.yml b/.forgejo/workflows/buildAndTest.yml index 86694c899..2f14e449e 100644 --- a/.forgejo/workflows/buildAndTest.yml +++ b/.forgejo/workflows/buildAndTest.yml @@ -44,4 +44,8 @@ jobs: - name: Test run: | echo "🚀 Lancement des tests..." - cd /src/_src && dotnet test --verbosity normal && echo "✅ Success !" + cd /src/_src && dotnet test \ + --verbosity normal \ + --filter="Category!=Platform-Android" \ + --logger "xunit;LogFileName=test-results.xml" \ + && echo "✅ Success !" || { echo "❌ Fail ($?)!"; exit 1; } diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 930c97b90..911fc831d 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -68,10 +68,8 @@ jobs: # WORKDIR de l'image (cf. dotnet-android-build-image/Dockerfile). cd /src - # Clone unshallow pour que GitVersion.MsBuild ait l'historique - # et les tags (sinon MSB3073 sur la cible Android cf. PR #21). if [[ ! -d _src/.git ]]; then - git clone https://forgejo.pschneider.fr/notazof/yavsc.git _src + git clone --depth=1 https://forgejo.pschneider.fr/notazof/yavsc.git _src fi cd _src @@ -173,37 +171,28 @@ jobs: echo "EOF" >> "$GITHUB_ENV" echo "IS_PRERELEASE=$([ "$CHANNEL" = "stable" ] && echo false || echo true)" >> "$GITHUB_ENV" - - name: Build des projets .NET (sans docker) - # L'image runner (pazof/yavsc-build-env) a le SDK .NET 10 + le - # workload Android, mais PAS le binaire `docker` ni de daemon - # Docker. On exécute donc les commandes dotnet directement - # au lieu de passer par `docker build`. - # Equivalent des stages build-env du Dockerfile (lignes - # restore + build Yavsc.Org + build Yavsc.Api + build - # Yavsc.Blogs + build PostIt.Android -r android-arm64). + - name: Restore run: | cd /src/_src dotnet restore - dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly - dotnet build src/Yavsc.Api/Yavsc.Api.csproj -c Release --no-restore -clp:ErrorsOnly - dotnet build src/Yavsc.Blogs/Yavsc.Blogs.csproj -c Release --no-restore -clp:ErrorsOnly - dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj \ - -c Release --no-restore -clp:ErrorsOnly -r android-arm64 + - name: Test + run: | + cd /src/_src && dotnet test \ + --verbosity normal \ + --filter="Category!=Platform-Android" \ + --logger "xunit;LogFileName=test-results.xml" - - name: Copier l'APK signé vers un emplacement connu - # Le build Android avec -r android-arm64 produit l'APK dans - # bin/Release/net10.0-android/android-arm64/. On le copie à - # la racine du checkout pour que l'étape d'upload le trouve. + - name: Build de PostIt.Android ARM64 run: | cd /src/_src - APK=src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-arm64/fr.pschneider.postit-Signed.apk - if [[ ! -f "$APK" ]]; then - echo "::error::APK not found at $APK" - ls -la src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-arm64 2>/dev/null || true - exit 1 - fi - cp "$APK" /src/_src/PostIt.Android.apk - ls -la /src/_src/PostIt.Android.apk + dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj \ + -c Release -r android-arm64 --no-restore -clp:ErrorsOnly + + - name: Build de PostIt.Android x64 + run: | + cd /src/_src + dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj \ + -c Release -r android-x64 --no-restore -clp:ErrorsOnly - name: Publier la release Forgejo via l'API REST # Pas d'action tierce (pas de Node dans l'image runner). @@ -217,6 +206,7 @@ jobs: RELEASE_BODY: ${{ env.RELEASE_BODY }} IS_PRERELEASE: ${{ env.IS_PRERELEASE }} run: | + set -e if [[ -z "$TAG" ]]; then echo "::error::No tag resolved for the API call." exit 1 @@ -312,21 +302,22 @@ jobs: # sinon curl l'interprète comme un second fichier d'input # (un fichier nommé '?name=PostIt.Android.apk') et l'API # Forgejo renvoie 400 "Missing 'name' parameter". - echo "::group::Upload APK asset" - HTTP=$(curl -sS -o /tmp/asset.json -w '%{http_code}' \ - -X POST \ - -H "Authorization: token $GITHUB_TOKEN" \ - -H "Content-Type: application/octet-stream" \ - -H "Accept: application/json" \ - --data-binary "@/src/_src/PostIt.Android.apk" \ - "$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=PostIt.Android.apk") - echo "POST asset -> HTTP $HTTP" + echo "::group::Upload PostIt APK assets" + for MARCH in arm64 x64; do + HTTP=$(curl -sS -o /tmp/asset.json -w '%{http_code}' \ + -X POST \ + -H "Authorization: token $GITHUB_TOKEN" \ + -H "Content-Type: application/octet-stream" \ + -H "Accept: application/json" \ + --data-binary "@/src/_src/src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-$MARCH/fr.pschneider.postit-Signed.apk" \ + "$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=PostIt.Android-$MARCH.apk") + echo "POST asset -> HTTP $HTTP" + if [[ "$HTTP" != "201" ]]; then + echo "::error::Asset upload failed (HTTP $HTTP):" + cat /tmp/asset.json + exit 1 + fi + done echo "::endgroup::" - if [[ "$HTTP" != "201" ]]; then - echo "::error::Asset upload failed (HTTP $HTTP):" - cat /tmp/asset.json - exit 1 - fi - - echo "Release publiée: $API_BASE/$GITHUB_REPOSITORY/releases/tag/$TAG" + echo "✅ Release publiée: $API_BASE/$GITHUB_REPOSITORY/releases/tag/$TAG" diff --git a/.github/workflows/docker-publish-android.yml b/.github/workflows/docker-publish-android.yml deleted file mode 100644 index a4ce15aa4..000000000 --- a/.github/workflows/docker-publish-android.yml +++ /dev/null @@ -1,183 +0,0 @@ -name: Build and Push Yavsc Apk - -on: - push: - branches: - - main - tags: - - '*' - workflow_dispatch: - inputs: - force_unstable: - description: 'Publier une release avec suffixe (ex. 1.0.0-rc1) malgré le fail-fast par défaut.' - required: false - type: boolean - default: false - -# softprops/action-gh-release a besoin de contents: write -# pour publier une release + uploader un asset. -permissions: - contents: write - -jobs: - apk-deploy: - runs-on: ubuntu-latest - steps: - - name: Checkout du code - uses: actions/checkout@v7 - with: - fetch-depth: 0 - fetch-tags: true - - # 1. Votre étape de build actuelle (on nomme l'image "postit-android") - # --target build-env : on ne veut que le stage de build (qui - # contient les artefacts .apk). Sans --target, Docker ciblerait - # le DERNIER stage du Dockerfile (blogs-runtime, qui est une - # image ASP.NET runtime sans aucun APK à extraire). - - name: Build de l'image Docker - run: docker build --build-arg ANDROID_TARGET_RID=android-arm64 --target build-env -t postit-android . - # 2. EXTRACTION : Créer un conteneur éphémère pour copier l'APK vers l'hôte GitHub - - name: Extraire l'APK du conteneur Docker - run: | - docker create --name extractor postit-android - docker cp extractor:/src/src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-arm64/fr.pschneider.postit-Signed.apk ./PostIt.Android.apk - docker rm extractor - - - name: Téléverser l'APK en tant qu'Artéfact GitHub - uses: actions/upload-artifact@v7 - with: - name: application-apk-release - path: ./PostIt.Android.apk - retention-days: 7 - - # Job de validation : parse le tag, vérifie le format, applique la règle - # de parité du patch (pair=stable / impair=preview / suffixe=instable), - # et s'assure que CHANGELOG.md contient une section cohérente. - # Sans ce job, le job publish-release peut être bypassé (un attaquant - # qui contrôle un tag ne peut pas publier de release sans une section - # changelog cohérente). - validate-release: - if: startsWith(github.ref, 'refs/tags/') - runs-on: ubuntu-latest - steps: - - name: Checkout du code - uses: actions/checkout@v7 - with: - fetch-depth: 0 - fetch-tags: true - - - name: Valider le tag et la section CHANGELOG - env: - FORCE_UNSTABLE: ${{ inputs.force_unstable || github.event.inputs.force_unstable || 'false' }} - run: | - TAG="${GITHUB_REF_NAME}" - - # Parse semver : MAJOR.MINOR.PATCH[-SUFFIX] - if [[ ! "$TAG" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-.*)?$ ]]; then - echo "::error::Tag '$TAG' does not match MAJOR.MINOR.PATCH[-SUFFIX] format." - exit 1 - fi - - MAJOR="${BASH_REMATCH[1]}" - MINOR="${BASH_REMATCH[2]}" - PATCH="${BASH_REMATCH[3]}" - SUFFIX="${BASH_REMATCH[4]}" - - # Classification du canal par parité du patch. - # Patch pair + pas de suffixe -> stable. - # Patch impair + pas de suffixe -> preview. - # Suffixe présent -> instable. - if [[ -n "$SUFFIX" ]]; then - CHANNEL="unstable" - elif (( PATCH % 2 == 0 )); then - CHANNEL="stable" - else - CHANNEL="preview" - fi - - echo "Tag $TAG classifié comme channel=$CHANNEL" - - # Fail-fast sur instable sauf opt-in explicite via workflow_dispatch. - if [[ "$CHANNEL" == "unstable" && "$FORCE_UNSTABLE" != "true" ]]; then - echo "::error::Tag '$TAG' is unstable (suffix '$SUFFIX'). Refusing to publish." - echo "Set force_unstable=true via workflow_dispatch to override." - exit 1 - fi - - # Lecture du CHANGELOG.md (doit exister à la racine du repo). - if [[ ! -f CHANGELOG.md ]]; then - echo "::error::CHANGELOG.md not found at repo root." - exit 1 - fi - - # Extraction de la section [TAG]. On cherche la première ligne - # commençant par '## [' qui contient '[TAG]' (entre '## [' et - # la prochaine ligne '## [' ou fin de fichier). awk en mode - # paragraphe suffit et reste POSIX. - BODY=$(awk -v tag="[$TAG]" ' - /^## \[/ { - if (in_section) exit - if (index($0, tag) > 0) in_section=1 - next - } - in_section { print } - ' CHANGELOG.md) - - if [[ -z "$BODY" ]]; then - echo "::error::No section matching '## [$TAG]' found in CHANGELOG.md." - echo "Add a '## [$TAG] - $CHANNEL' section before tagging." - exit 1 - fi - - # Vérification cohérence du canal déclaré dans le titre de section. - # Format attendu : "## [TAG] - stable" / "- preview" / "- unstable". - HEADER=$(grep -m1 "^## \[$TAG\]" CHANGELOG.md) - if [[ "$HEADER" != *" - $CHANNEL"* ]]; then - echo "::error::Section '## [$TAG]' must declare suffix '- $CHANNEL' to match tag parity." - echo "Current section header: $HEADER" - exit 1 - fi - - echo "Section CHANGELOG validée pour [$TAG] - $CHANNEL" - - # Exposition aux étapes suivantes via $GITHUB_ENV. - # heredoc <> "$GITHUB_ENV" - - publish-release: - # Déclenché uniquement par un push de tag. Le job apk-deploy produit - # l'artefact ; validate-release garantit la cohérence du tag et du - # changelog avant publication. - if: startsWith(github.ref, 'refs/tags/') - needs: [apk-deploy, validate-release] - runs-on: ubuntu-latest - steps: - - name: Récupérer l'APK depuis l'artefact - uses: actions/download-artifact@v7 - with: - name: application-apk-release - path: ./ - - - name: Publier la release GitHub et uploader l'APK - uses: softprops/action-gh-release@v2 - with: - # Le nom de fichier final dans la release. C'est ce qui - # apparaîtra dans l'asset et donc dans le permalink : - # https://github.com///releases/latest/download/PostIt.Android.apk - files: ./PostIt.Android.apk - # Le body est extrait de la section CHANGELOG.md correspondant - # au tag, exposée par validate-release via $GITHUB_ENV. - body: ${{ env.RELEASE_BODY }} - # stable -> false (marque comme Latest). - # preview / unstable -> true (visible mais pas Latest). - prerelease: ${{ env.IS_PRERELEASE }} diff --git a/.github/workflows/docker-publish-backend.yml b/.github/workflows/docker-publish-backend.yml index 6c2431aed..d8466bdb8 100644 --- a/.github/workflows/docker-publish-backend.yml +++ b/.github/workflows/docker-publish-backend.yml @@ -26,7 +26,7 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Test - run: dotnet test --no-build --verbosity normal + run: dotnet test --no-build --verbosity normal --filter="Category!=Platform-Android" # 4. Build et Push de l'image de production finale - name: Build and push production image uses: docker/build-push-action@v7 diff --git a/.gitignore b/.gitignore index b7813f604..056cf69d2 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ appsettings-*.*.json generated/ *.tmp +tmp/ DataDir/ *.tests.trx diff --git a/.vscode/settings.json b/.vscode/settings.json index 915683fcf..83a17ae38 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,29 +2,31 @@ "dotnet-test-explorer.testProjectPath": "test/**/*Tests.csproj", "cSpell.words": [ - "appsettings", - "asciidoctor", - "ASPNETCORE", - "Avalonia", - "Configurabilité", - "Cratie", - "DESTDIR", - "dotnet", - "DOTNET", - "ecdsa", - "envsubst", - "Hsts", - "Newtonsoft", - "Npgsql", - "Oidc", - "PKCE", - "postit", - "pschneider", - "SLNDIR", - "validable", - "www-data", - "yavsc", - "Yavsc" + "appsettings", + "asciidoctor", + "ASPNETCORE", + "Avalonia", + "blogspot", + "Configurabilité", + "Cratie", + "DESTDIR", + "dotnet", + "DOTNET", + "ecdsa", + "envsubst", + "Forgejo", + "Hsts", + "Newtonsoft", + "Npgsql", + "Oidc", + "PKCE", + "postit", + "pschneider", + "SLNDIR", + "validable", + "www-data", + "yavsc", + "Yavsc" ], "cSpell.reportUnknownWords": true, "cSpell.language": "fr,en", @@ -42,5 +44,6 @@ "copilotcli/gpt-5.3-codex" ] } - } + }, + "dotnet.defaultSolution": "yavsc.sln" } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 3c0c0bf26..a30c9c065 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,5 +1,22 @@ { "version": "2.0.0", + "isRoot": true, + "problemMatcher": [ + { + "owner": "dotnet", + "fileLocation": ["relative", "${workspaceFolder}"], + "source": "dotnet", + "pattern": { + "regexp": "^\\s*(.*)\\((\\d+),(\\d+)\\):\\s+(error|warning) (.*)$", + "file": 1, + "line": 2, + "column": 3, + "severity": 4, + "code": 5, + "message": 6 + } + } + ], "tasks": [ { "label": "run-debug-android", @@ -20,21 +37,6 @@ "-p:AndroidAttachDebugger=true", "-p:AndroidSdbHostPort=55555", "-p:AndroidSdbTargetPort=55555" - ], - "problemMatcher": [ - { - "owner": "dotnet", - "fileLocation": ["relative", "${workspaceFolder}"], - "source": "dotnet", - "pattern": { - "regexp": "^(.*):(\\d+):(\\d+):\\s+(error):\\s+(.*)$", - "file": 1, - "line": 2, - "column": 3, - "severity": 4, - "message": 5 - } - } ] }, { @@ -45,7 +47,6 @@ "group": "build", "isBuildCommand": true, "isTestCommand": false, - "problemMatcher": ["$msCompile"], "isBackground": true }, { diff --git a/CHANGELOG.md b/CHANGELOG.md index 6adf462a5..111c1491a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,22 +1,162 @@ # Changelog -Toutes les modifications notables de PostIt et de la plateforme Yavsc -sont documentées dans ce fichier. +## [1.0.8-rc14] - unstable -Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), -et ce projet adhère au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +### Added -À noter : la **parité du numéro de patch** porte une signification de canal : +* [PostIt] Ajout d'un `BillingQueryDetailsPageViewModel` et de sa page associee pour afficher le detail d'une commande billing depuis l'historique. +* [PostIt] Ajout d'un mode detail avec section metier (statut, date, description, motif, infos) et section technique repliable (code, client, provision, lieu, prestations). +* [PostIt] Ajout d'un badge de statut enrichi (couleur + pictogramme) sur le detail d'une commande pour visualiser l'etat en un coup d'oeil. +* [PostIt] Ajout d'un bloc d'actions rapide en tete du detail (`Retour`, `Ouvrir en edition`) pour eviter le scroll jusqu'au bas de page. +* [PostIt] Ajout d'un style monospace sur les metadonnees techniques (code billing, client, provision, lieu, prestations) pour faciliter la lecture des identifiants et valeurs brutes. -- **patch pair** (ex. `1.0.0`, `1.0.2`) → **stable** -- **patch impair** (ex. `1.0.1`, `1.0.3`) → **preview** -- **suffixe** (ex. `1.0.0-rc1`, `1.0.0-alpha`) → **instable** +### Changed -Cette convention est partagée avec le dépôt -[`postit-debian`](https://forgejo.pschneider.fr/notazof/postit-debian) -pour la production des paquets `.deb`. +* [PostIt] Le bouton d'ouverture depuis la liste billing ouvre maintenant une page de detail dediee avant l'eventuelle edition. +* [PostIt] Amelioration UX des pages billing: badges de statut colores, actions remontees en haut de page, et typographie monospace sur les metadonnees techniques. -## [1.0.8-rc3] - unstable +### Fixed + +* [Yavsc.Api] Correction d'un 500 sur le refresh du catalogue d'activites lorsque `Activity.Description` est `NULL` en base (nullabilite explicite + projection null-safe + gardes sur codes vides). +* [Yavsc.Api] Correction des erreurs 400/500 sur les routes billing (`Rdv`, `Brush`, `MBrush`) en imposant `ClientId` depuis l'utilisateur authentifie et en ignorant les champs server-owned lors de la validation modele. +* [Yavsc.Api] Correction du `PUT /api/v1/billing/Rdv/{id}`: mise a jour controlee de l'entite existante (et non remplacement brut du graphe JSON), ce qui supprime les `BadRequest` parasites. +* [Yavsc.Api] Correction PostgreSQL `timestamptz` sur RDV: normalisation UTC de `EventDate` sur `POST/PUT /api/v1/billing/Rdv` pour eviter l'erreur `Cannot write DateTime with Kind=Local`. +* [Yavsc.Api] Correction du flux FrontOffice accept/reject de query: sauvegarde avec contexte utilisateur et fallback d'injection pour `IBillingService` afin d'eviter les erreurs serveur en environnement de test. +* [Yavsc.Blogs] Correction des `BadRequest` sur `POST/PUT /api/v1/blogspot` avec payload JSON (PostIt): les proprietes de navigation/serveur (`Author`, `Tags`, `Comments`, audit) ne bloquent plus la validation. +* [Yavsc.Org] Correction du flux MVC de creation de commentaire: `SaveChangesAsync(userId)` est utilise pour renseigner les champs d'audit requis (`UserCreated`/`UserModified`). +* [Yavsc.Api.Test] Stabilisation des fixtures de seed billing: remplissage des metadonnees d'audit (`UserCreated`, `UserModified`, dates) pour eviter les echecs SQLite `NOT NULL`. + +## [1.0.8-rc13] - unstable + +### Added + +* [PostIt] Integration d'un selecteur de lieu RDV base sur Mapsui (carte interactive dans le formulaire `Rdv`). +* [PostIt] Ajout d'un marqueur de position et d'une action de recentrage sur la carte RDV. +* [PostIt] Ajout d'un service de reverse geocoding pour suggerer une adresse a partir des coordonnees carte. +* [PostIt] Cache et debounce des resolutions d'adresse RDV pour limiter les appels reseau et lisser l'UX. +* [PostIt.Tests] Nouvelles non-regressions sur le panneau d'adresse suggeree RDV et le comportement de la carte. +* [Yavsc.Abstract] Activation de `#nullable enable annotations` sur les fichiers legacy avec annotations nullable. +* [Yavsc.Server] Activation de `#nullable enable annotations` sur les fichiers legacy avec annotations nullable. + +### Changed + +* [PostIt] Generalisation de la barre de statut d'action (severite explicite) sur pages principales, dialogues et formulaires billing. +* [PostIt] Harmonisation des messages de statut utilisateur en francais. +* [PostIt] Renforcement des gardes de navigation dans les flux de gestion des membres de cercle. +* [PostIt] Le flux RDV conserve l'adresse saisie manuellement et propose l'adresse resolue comme suggestion explicite. +* [PostIt] Le flux de geolocalisation RDV tolere les positions proches dans le cache de suggestion d'adresse. + +### Fixed + +* [PostIt.Desktop] Correction d'un crash au demarrage OIDC (`No authority specified`) via durcissement des valeurs par defaut de configuration d'authentification. +* [PostIt] Correction de la persistance des settings: l'etat runtime de statut n'est plus serialize dans le JSON utilisateur. +* [PostIt.Tests] Ajout d'un verrou de non-regression sur le premier chargement des settings. +* [PostIt] Correction du binding de la date RDV: `DatePicker.SelectedDate` est aligne sur un proxy `DateTimeOffset?` (`EventDateSelection`). + +## [1.0.8-rc12] - unstable + +### Added + +* [PostIt] Nouveau helper d'image `ImageHelper` pour charger des bitmaps depuis les ressources et depuis le web. +* [PostIt] Affichage de l'avatar XS dans la liste des performers d'activites, avec fallback visuel (initiale utilisateur). +* [PostIt.Tests] Nouveaux tests autour des URLs avatar et de la source d'autorite. +* [contrib] Ajout d'un `README.md` utilitaire pour les symboles/icones. + +### Changed + +* [PostIt] Les avatars ne sont plus relies en string sur `Image.Source`: ils sont telecharges et lies en `Bitmap`. +* [Yavsc.Api.Client] `ActivityApiClient` accepte une base d'avatar dediee et construit les URLs avatar depuis l'autorite d'identification. +* [PostIt] Les clients Activites/Billing utilisent maintenant `ApiUrl` en lecture dynamique: un changement via Parametres prend effet sans redemarrer l'application (apres sauvegarde et rafraichissement de la page). +* [PostIt] Le header de `MainPage` n'utilise plus `ScrollViewer`; remplacement par une barre de commandes basee sur `WrapPanel`. +* [PostIt] Alignement de la navigation blogs: renommage `PushMainPageAsync` -> `PushBlogsPageAsync` et ajustement de `HomePageViewModel`. + +### Fixed + +* [PostIt.Android] Correction d'un 404 sur la page Activites au premier lancement: la configuration embarquee pointait `ApiUrl` vers le host Blogs au lieu de l'API metier. +* [PostIt] Correction du bouton Sauver de la page Parametres: binding vers `SaveCommand` pour persister correctement `ApiUrl`/`BlogsApiUrl`. + +## [1.0.8-rc11] - unstable + +### Added + +nothing + +### Changed + +* [Yavsc.Api.Test] Mise a jour de `Microsoft.EntityFrameworkCore.Sqlite` vers `10.0.11` afin de supprimer l'alerte NU1903 liee a `SQLitePCLRaw.lib.e_sqlite3` 2.1.11. +* [Yavsc.Org] Nettoyage de la configuration NuGet pour le restore: suppression du fichier local `Directory.Packages.props` au profit du fichier racine centralise. +* [Yavsc.Org] Suppression de references de packages redondantes dans le projet, sans impact fonctionnel attendu. + +### Fixed + +* [Yavsc.Api.Test] Le restore n'emet plus le warning de vulnerabilite `NU1903` sur `SQLitePCLRaw.lib.e_sqlite3`. +* [Yavsc.Org] Suppression d'une vulnerabilite de severite elevee sur AutoMapper apres publication et consommation de la nouvelle version candidate de `HigginsSoft.IdentityServer8`. + +## [1.0.8-rc10] - unstable + +### Added + +* [PostIt] Une page d'historique des commandes billing permet maintenant d'ouvrir une commande existante. +* [PostIt] Une vue "Demandes en cours" en lecture seule est disponible pour le performer, filtrée sur les statuts actifs (Inserted, Accepted, InProgress). +* [Yavsc.Org] Nouvelles entités `Country` et `PerformerCodeInputValidation` pour piloter la validation du code entreprise performer par pays. + +### Changed + +* [PostIt] La page détail billing se préremplit depuis une commande existante (Rdv, Brush, MBrush) et passe en mode mise à jour. +* [Yavsc.Org] Le formulaire `Manage/SetActivity` inclut désormais le pays d'exercice (`fr`, `en`, `pt`) et applique la regex associée au champ `SIREN`. +* [Yavsc.Org] La vérification externe du numéro d'entreprise est conservée uniquement pour le pays `fr`. + +### Fixed + +* [PostIt] Le flux historique n'est plus limité à une simple liste: l'action d'ouverture charge la commande cible puis navigue vers la page détail. +* [Yavsc.Org] Le champ `SIREN` n'est plus validé avec une règle unique indépendante du pays d'exercice. + +## [1.0.8-rc9] - unstable + +### Added + +nothing + +### Changed + +masquage non-owner côté backend de l'ACL du billet + +### Fixed + +On a maintenant le comportement attendu bout en bout: + +ACL chargée depuis le BlogPostDto +noms de cercles affichés dans le dialogue ACL côté PostIt + +## [1.0.8-rc8] - unstable + +### Added + +nothing + +### Changed + +nothing + +### Fixed + +The PostIt publish toggle button + +## [1.0.8-rc7] - unstable + +### Added + +* [PostIt] The search pattern now persists + +### Changed + +* The blog spot path is now `/api/v1/blogspot` (yet in last release) + +### Fixed + +* [Yavsc.Org] (Ticket #45) La forme de l'email de l'utilisateur est maintenant validée avant l'envoi du formulaire d'enregistrement + +## [1.0.8-rc6] - unstable ### Added @@ -31,8 +171,9 @@ L'identifiant de l'application client Android a changé, il passe en minuscules ### Fixed -nothing, [TODO] a bug persists laoding a blog post from PostIt, ACL sould come along with and should not need any "Refresh" button. -Today, assigning ACL succeeds the first time, the following times result in a 409 +a bug posting and retrieving ACL from the backend, +the ACL now comes along with the article, +[TODO][PostIt] keep ACL along with the article ## [1.0.8-rc1] - unstable @@ -187,10 +328,10 @@ Today, assigning ACL succeeds the first time, the following times result in a 40 migration, reverted in this release. The publish toggle covers the same user-visible switch without a schema change. -[Unreleased]: https://github.com/pazof/yavsc/compare/HEAD -[1.0.8-rc1]: https://github.com/pazof/yavsc/compare/1.0.7...1.0.8-rc1 -[1.0.7]: https://github.com/pazof/yavsc/compare/1.0.6...1.0.7 -[1.0.6]: https://github.com/pazof/yavsc/compare/1.0.5...1.0.6 +[Unreleased]: https://forgejo.pschneider.fr/notazof/yavsc/compare/HEAD +[1.0.8-rc1]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.7...1.0.8-rc1 +[1.0.7]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.6...1.0.7 +[1.0.6]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.5...1.0.6 ## [1.0.6] - stable @@ -224,4 +365,4 @@ Today, assigning ACL succeeds the first time, the following times result in a 40 actual release id. Switched to `jq` for both body construction and field extraction. -[1.0.6]: https://github.com/pazof/yavsc/compare/1.0.5...1.0.6 +[1.0.6]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.5...1.0.6 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e528b7a44..8aa0567d3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ ## Premier build ```bash -git clone https://github.com/pazof/yavsc.git +git clone https://forgejo.pschneider.fr/notazof/yavsc.git cd yavsc dotnet restore dotnet build @@ -49,6 +49,39 @@ Les tests sont répartis en : item « Tests d'intégration smoke par BC ». - `src/PostIt.Tests/` — tests unitaires du client desktop PostIt. +## Onboarding assiste par agents IA + +Pour accelerer la prise en main du depot avec Copilot/Plan/Explore : + +- Parcours pas-a-pas : [doc/onboarding-agents.md](./doc/onboarding-agents.md) +- Playbook d'usage des agents : [doc/agent-playbook.md](./doc/agent-playbook.md) +- Matrice intentions -> agent -> preuves : [doc/agent-intent-matrix.md](./doc/agent-intent-matrix.md) + +Regle minimale en contribution assistee par agent : +- expliciter l'impact architecture, +- justifier le niveau de tests execute, +- documenter les risques residuels. + +## Le CHANGELOG.md + +Le `CHANGELOG.md` est un document de changement de version + +Toutes les modifications notables de PostIt et de la plateforme Yavsc +sont documentées dans ce fichier. + +Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), +et ce projet adhère au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +À noter : la **parité du numéro de patch** porte une signification de canal : + +- **patch pair** (ex. `1.0.0`, `1.0.2`) → **preview** +- **patch impair** (ex. `1.0.1`, `1.0.3`) → **stable** +- **suffixe** (ex. `1.0.0-rc1`, `1.0.0-alpha`) → **instable** + +Cette convention est partagée avec le dépôt +[`postit-debian`](https://forgejo.pschneider.fr/notazof/postit-debian) +pour la production des paquets `.deb`. + ## Navigation (PostIt) La navigation est centralisée dans diff --git a/Directory.Build.props b/Directory.Build.props index 0ce825bdb..83d21579d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,16 +1,6 @@ Yavsc - - true NU1701, NU1901, NU1902, NU1507 diff --git a/Directory.Packages.props b/Directory.Packages.props index 84380e449..f1be93f91 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,16 +1,31 @@ true + 8.1.0-pazofrc007 - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + @@ -18,14 +33,24 @@ - + + + + + + + + + + + - \ No newline at end of file + diff --git a/Makefile b/Makefile index fa9d4ecf1..08f0461d2 100644 --- a/Makefile +++ b/Makefile @@ -77,13 +77,6 @@ release: echo " V : version semver (ex. 1.0.7-rc1) — sert à nommer la branche."; \ exit 1; \ fi - @CURRENT=$$(git branch --show-current); \ - if [ "$$CURRENT" != "main" ]; then \ - echo "Refus : la cible doit être lancée depuis main."; \ - echo " Branche courante : $$CURRENT"; \ - echo " Fais : git checkout main && git pull --ff-only origin main"; \ - exit 1; \ - fi @if [ -n "$$(git status --porcelain)" ]; then \ echo "Working tree sale, refus de créer une branche release."; \ git status --short; \ diff --git a/README.md b/README.md index 8e6306125..549348f4a 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,10 @@ https://forgejo.pschneider.fr/notazof/yavsc/actions?workflow=release.yml # Statut actuel des actions GitHub -* [![Build and Push Yavsc Apk](https://github.com/pazof/yavsc/actions/workflows/docker-publish-android.yml/badge.svg)](https://github.com/pazof/yavsc/actions/workflows/docker-publish-android.yml) +* [![CodeQL Advanced](https://github.com/pazof/yavsc/actions/workflows/codeql.yml/badge.svg)](https://github.com/pazof/yavsc/actions/workflows/codeql.yml) * [![Build and Push Yavsc Production Image](https://github.com/pazof/yavsc/actions/workflows/docker-publish-backend.yml/badge.svg)](https://github.com/pazof/yavsc/actions/workflows/docker-publish-backend.yml) -* [![CodeQL Advanced](https://github.com/pazof/yavsc/actions/workflows/codeql.yml/badge.svg)](https://github.com/pazof/yavsc/actions/workflows/codeql.yml) # Documentation @@ -29,6 +28,10 @@ sous [`doc/`](./doc/). Voir l'[index de la documentation](./doc/README.md) pour le sommaire complet. La racine de l'architecture est [Architecture.md](./doc/Architecture.md). +Pour une prise en main guidee avec agents IA: +- parcours onboarding: [doc/onboarding-agents.md](./doc/onboarding-agents.md) +- playbook d'usage: [doc/agent-playbook.md](./doc/agent-playbook.md) + # Construction et déploiement diff --git a/ROADMAP.md b/ROADMAP.md index 4c00e629c..364b98bda 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -68,7 +68,7 @@ Trois principes non négociables traversent tous les jalons : > > Chaque jalon a un **critère de sortie** vérifiable. -### Jalon 0 — Fondations techniques *(en cours)* +### Jalon 0 — Fondations techniques > Cible : pouvoir parler du domaine sans se battre avec le runtime. @@ -81,7 +81,7 @@ Trois principes non négociables traversent tous les jalons : --- -### Jalon 1 — Prestation signée de bout en bout +### Jalon 1 — Prestation signée de bout en bout *(en cours)* > Cible : un projet client/fournisseur aboutit à un **devis signé par les deux parties**, traçable, avec notifications. diff --git a/contrib/.env-sample b/contrib/.env-sample new file mode 100644 index 000000000..fb01ede43 --- /dev/null +++ b/contrib/.env-sample @@ -0,0 +1,24 @@ +# parametres de déploiement au Makefile + +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_DB=yavsc +POSTGRES_USER=yavsc +POSTGRES_PASSWORD= + +HTTP_HOST=localhost + +Org_PORT=83 +Blogs_PORT=85 +Api_PORT=87 + +PostIt_CLIENT_ID=postit + +ASPNETCORE_Smtp__Host="mercure.pschneider.fr" +ASPNETCORE_Smtp__Port=465 +ASPNETCORE_Smtp__SenderName="Paul Schneider" +ASPNETCORE_Smtp__SenderEmail="paul@pschneider.fr" +ASPNETCORE_Smtp__UserName="paul" +ASPNETCORE_Smtp__Password="" + +DESTDIR=/srv/www/yavsc diff --git a/contrib/Makefile b/contrib/Makefile index 151045db0..79145668f 100644 --- a/contrib/Makefile +++ b/contrib/Makefile @@ -1,4 +1,4 @@ -APP_PROJECT_NAMES=Org Blogs +APP_PROJECT_NAMES=Org Blogs Api SLNDIR=.. include $(SLNDIR)/.env @@ -9,9 +9,11 @@ generated/: generated/yavscOrg.service: generated/yavscBlogs.service: +generated/yavscApi.service: generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env @cat template.service | APP_NAME="$*" \ + DESTDIR="$(DESTDIR)" \ HTTP_HOST="$(HTTP_HOST)" \ HTTP_PORT="$*_$(HTTP_PORT)" \ BASEAPPDIR="$(BASEAPPDIR)" \ @@ -33,11 +35,12 @@ generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env @echo Created service file: $@ -copy-services: copy-service-Org copy-service-Blogs +copy-services: copy-service-Org copy-service-Blogs copy-service-Api copy-service-Org: /etc/systemd/system/yavscOrg.service copy-service-Blogs: /etc/systemd/system/yavscBlogs.service +copy-service-Api: /etc/systemd/system/yavscApi.service -copy-binaries: build_publish_Org build_publish_Blogs stop-services +copy-binaries: build_publish_Org build_publish_Blogs build_publish_Api stop-services @for project in $(APP_PROJECT_NAMES); \ do LCAPI=$$(echo $${project}|tr [:upper:] [:lower:]) ; \ echo "$${project} -> $${LCAPI}" ; \ @@ -60,6 +63,8 @@ copy-binaries: build_publish_Org build_publish_Blogs stop-services build_publish_%: clean_publish_dir_% @ASPNETCORE_ENV=$(CONFIGURATION) dotnet publish $(SLNDIR)/src/Yavsc.$*/Yavsc.$*.csproj +build_publish: build_publish_Org build_publish_Blogs build_publish_Api + clean_publish_dir_%: @rm -rf $(SLNDIR)/src/Yavsc.$*/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish @@ -84,6 +89,7 @@ stop-services: $(SLNDIR)/src/Yavsc.Org/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish $(SLNDIR)/src/Yavsc.Blogs/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish +$(SLNDIR)/src/Yavsc.Api/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish showConfig: @echo CONFIGURATION: $(CONFIGURATION) @@ -92,4 +98,3 @@ showConfig: clean: @rm -rf generated -.PHONY: build_publish mep showConfig copy-service-Org copy-service-Blogs reinstall clean diff --git a/contrib/README.md b/contrib/README.md new file mode 100644 index 000000000..011561560 --- /dev/null +++ b/contrib/README.md @@ -0,0 +1,5 @@ +# Read me + +## Note aux icones + +㝉®🅬⛒⛑🩎🩺🞫🞮🞕🞖🞆🔴🔵🔲🖂🔧🔩🔐🔌💾💼💬💭👿👾🏷🎯🏹🌍🎎💩 diff --git a/doc/README.md b/doc/README.md index 912a2e562..fb9bea943 100644 --- a/doc/README.md +++ b/doc/README.md @@ -18,6 +18,9 @@ La racine de l'architecture est [Architecture.md](Architecture.md). | [architecture/postit.md](architecture/postit.md) | PostIt — topologie des projets, ViewLocator custo, navigation, DI, conventions de binding | | [architecture/decoupage-organisation.md](architecture/decoupage-organisation.md) | Découpage des projets .NET (Abstract, Server, Org, Api, Blogs, Web, Org.Tests) | | [testing.md](testing.md) | Stratégie de test : conventions des dossiers, EF Core in-memory, auth stubs, scaffold partagé | +| [onboarding-agents.md](onboarding-agents.md) | Parcours pas-à-pas pour prise en main agents IA + architecture + tests | +| [agent-playbook.md](agent-playbook.md) | Playbook d'usage de Copilot, Plan, Explore avec scénarios et anti-patterns | +| [agent-intent-matrix.md](agent-intent-matrix.md) | Matrice intentions développeur -> agent -> preuves attendues | ## Roadmap & design exploration diff --git a/doc/agent-intent-matrix.md b/doc/agent-intent-matrix.md new file mode 100644 index 000000000..975cd7b79 --- /dev/null +++ b/doc/agent-intent-matrix.md @@ -0,0 +1,20 @@ +# Matrice intentions -> agent -> preuves + +Cette matrice aide a choisir rapidement l'agent adapte et a exiger +une sortie verifiable. + +| Intention developpeur | Agent principal | Entrees minimales | Sortie minimale attendue | Verification | +|---|---|---|---|---| +| Comprendre un BC avant changement | Explore | BC cible, profondeur, contrainte de perimetre | Composants, points d'entree, tests relies, risques | Lire les fichiers cites + confirmer tests proposes | +| Decomposer une tache transverse | Plan | Objectif, contraintes, definition of done | Etapes ordonnees, dependances, criteres de verif | Verifier que chaque etape a une preuve observable | +| Implementer une modif locale | Copilot | Fichier cible, comportement attendu, conventions | Patch minimal, justification courte | Build/test du projet impacte | +| Ajouter un test smoke | Copilot (+Explore) | Route/endpoint, projet de test cible | Test + commande cible | Execution test cible | +| Corriger une regression | Plan + Copilot | Symptome, zone suspecte, test attendu | Fix + test NonRegression | Test rouge avant, vert apres | +| Diagnostiquer flux PostIt/OIDC | Explore + Plan | Flux, symptome, plateforme | Carte du flux + hypotheses testables | Verification manuelle + tests existants | + +## Regles d'arbitrage + +- Si l'intention est "comprendre": commencer par Explore. +- Si l'intention est "orchestrer": commencer par Plan. +- Si l'intention est "produire": utiliser Copilot apres cadrage. +- Si une sortie n'inclut pas de preuve, elle est incomplete. diff --git a/doc/agent-playbook.md b/doc/agent-playbook.md new file mode 100644 index 000000000..ecc14f1d2 --- /dev/null +++ b/doc/agent-playbook.md @@ -0,0 +1,101 @@ +# Playbook d'usage des agents IA (Yavsc) + +Ce playbook normalise l'usage de Copilot, Plan et Explore dans le depot. +Il privilegie des sorties verifiables: fichiers, commandes tests, risques. + +## Quand utiliser quel agent + +- Plan: quand la tache est ambigue, transverse ou risquee. +- Explore: quand il faut cartographier rapidement des zones du code. +- Copilot: quand les specifications sont claires et localisees. + +## Prompt type (base) + +Utiliser ce squelette avant toute tache non triviale: + +```text +Contexte: +Objectif: +Contraintes: +Verification: +Sortie attendue: +``` + +## 4 scenarios de reference + +## 1) Explorer un bounded context + +Intention: +- Comprendre ou implementer un changement dans un BC sans regression laterale. + +Prompt minimal: +```text +Explore le BC avec profondeur medium. +Retour: composants touches, points d'entree, tests existants et risques. +``` + +Preuves attendues: +- Carte des fichiers a modifier. +- Test(s) smoke/mandatory proposes. + +## 2) Ajouter un smoke test + +Intention: +- Couvrir rapidement un endpoint ou une route publique. + +Prompt minimal: +```text +Propose un smoke test pour dans le projet de test approprie. +Respecte les conventions de doc/testing.md. +``` + +Preuves attendues: +- Fichier test cree/modifie. +- Commande precise pour executer le test cible. + +## 3) Corriger une regression backend API + +Intention: +- Corriger un bug sans casser un flux voisin. + +Prompt minimal: +```text +Planifie puis implemente un fix de dans . +Ajoute/ajuste un test NonRegression rouge puis vert. +``` + +Preuves attendues: +- Explication cause racine. +- Test non-regression associe. +- Commande d'execution et resultat attendu. + +## 4) Tracer un flux PostIt/OIDC + +Intention: +- Localiser une cassure d'authentification entre client et serveur. + +Prompt minimal: +```text +Cartographie le flux OIDC PostIt: entrypoints, callback, stockage token, +refresh. Donne points de rupture probables et tests/verification proposes. +``` + +Preuves attendues: +- Liste ordonnee des etapes du flux. +- Fichiers critiques. +- Hypotheses testables. + +## Anti-patterns a eviter + +- Prompt sans objectif verifiable. +- Demande trop large sans perimetre de fichiers. +- Validation basee uniquement sur "ca semble correct". +- Pas de lien entre changement et niveau de test. + +## Gate PR minimale (agent-assiste) + +Avant validation: +- Impact architecture explicite. +- Rationale de choix agent explicite. +- Test(s) executes et justifies. +- Risques residuels documentes. diff --git a/doc/onboarding-agents.md b/doc/onboarding-agents.md new file mode 100644 index 000000000..14a727a17 --- /dev/null +++ b/doc/onboarding-agents.md @@ -0,0 +1,73 @@ +# Onboarding guide: agents IA + architecture + tests + +Ce guide est optimise pour accelerer la prise en main des agents IA +(Copilot, Plan, Explore) dans Yavsc, avec une verification rapide +par les tests. + +## Resultat attendu + +A la fin du parcours, un contributeur doit pouvoir: +- Identifier les projets impactes par une modification. +- Choisir l'agent adapte a l'intention de travail. +- Produire une proposition de changement verifiable par les tests. + +## Parcours en 3 modules + +## Module A - Comprendre le terrain (30-45 min) + +Objectif: acquerir une lecture fiable de l'architecture. + +1. Lire [README.md](../README.md) puis [Architecture.md](Architecture.md). +2. Lire [architecture/decoupage-organisation.md](architecture/decoupage-organisation.md). +3. Selon le domaine: + - Backend/API: [architecture/workflow-multi-parties.md](architecture/workflow-multi-parties.md) + - PostIt: [architecture/postit.md](architecture/postit.md) puis [architecture/postit-oidc.md](architecture/postit-oidc.md) + +Definition of done: +- Expliquer en 5 phrases quelles couches sont touchees. +- Citer le ou les points d'entree applicatifs a verifier. + +## Module B - Boucle tests rapide (20-30 min) + +Objectif: verifier rapidement sans lancer toute la suite. + +1. Lire [testing.md](testing.md). +2. Lancer les smoke tests d'abord, puis mandatory selon le projet. +3. N'elargir au test complet que si le scope depasse le BC touche. + +Definition of done: +- Fournir la commande test executee. +- Expliquer pourquoi ce niveau de test est suffisant. + +## Module C - Usage agentique en production (30-40 min) + +Objectif: utiliser les agents comme accelerateurs, pas comme boites noires. + +1. Plan: decomposer la tache en etapes verifiables. +2. Explore: collecter le contexte code/doc precise. +3. Copilot: implementer localement et verifier. + +Regles: +- Toujours donner un contexte explicite (fichier, but, contrainte). +- Demander des preuves observables (fichiers modifies, tests, risques). +- Refuser toute sortie non verifiable. + +Definition of done: +- Une tache simple est livree avec: + - Plan + - Changement local + - Preuve par test + +## Routine continue (sans echeance fixe) + +Rituels recommandes: +- Hebdo: revue des prompts qui ont bien fonctionne. +- Mensuel: mise a jour du present guide et du playbook. +- A chaque incident: ajouter un anti-pattern dans le playbook. + +## Check-list de validation + +- Le changement indique son impact architecture. +- Le choix de l'agent est justifie. +- La preuve test est incluse. +- Les risques residuels sont explicitement listes. diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props index 0d5fa50cc..2f27340e9 100644 --- a/src/PostIt/Directory.Packages.props +++ b/src/PostIt/Directory.Packages.props @@ -8,8 +8,6 @@ 12.1.1 - - @@ -18,19 +16,17 @@ + + - - - - diff --git a/src/PostIt/PostIt.Android/Application.cs b/src/PostIt/PostIt.Android/Application.cs index f5a7908dd..040b01ca3 100644 --- a/src/PostIt/PostIt.Android/Application.cs +++ b/src/PostIt/PostIt.Android/Application.cs @@ -1,4 +1,5 @@ using Android.App; +using Android; using Android.Runtime; using Avalonia; using Avalonia.Android; @@ -9,6 +10,9 @@ using Avalonia.Controls; using Avalonia.Styling; using Yavsc.Api.Client; +[assembly: UsesPermission(Manifest.Permission.AccessFineLocation)] +[assembly: UsesPermission(Manifest.Permission.AccessCoarseLocation)] + namespace PostIt.Android { [Application] diff --git a/src/PostIt/PostIt.Android/MainActivity.cs b/src/PostIt/PostIt.Android/MainActivity.cs index ad8455ef0..549100804 100644 --- a/src/PostIt/PostIt.Android/MainActivity.cs +++ b/src/PostIt/PostIt.Android/MainActivity.cs @@ -57,6 +57,17 @@ public class MainActivity : AvaloniaMainActivity } + public override void OnRequestPermissionsResult(int requestCode, string[]? permissions, Permission[]? grantResults) + { + if (PostIt.Android.Services.AndroidCurrentLocationProvider + .HandlePermissionResult(requestCode, grantResults)) + { + return; + } + + base.OnRequestPermissionsResult(requestCode, permissions, grantResults); + } + internal static class AndroidOidcCallbackSink { private static System.Threading.Tasks.TaskCompletionSource? _pending; diff --git a/src/PostIt/PostIt.Android/PlatformBootstrap.cs b/src/PostIt/PostIt.Android/PlatformBootstrap.cs index f208f9ce9..5b90267f0 100644 --- a/src/PostIt/PostIt.Android/PlatformBootstrap.cs +++ b/src/PostIt/PostIt.Android/PlatformBootstrap.cs @@ -14,11 +14,12 @@ internal static class PlatformBootstrap { internal static void InitPlatform() { - Platform.CreateBrowser = () => { var activity = MainActivity.Current; return activity is null ? null : new AndroidSystemBrowser(activity); }; + + Platform.TryGetCurrentLocationAsync = AndroidCurrentLocationProvider.TryGetCurrentLocationAsync; } } diff --git a/src/PostIt/PostIt.Android/PostIt.Android.csproj b/src/PostIt/PostIt.Android/PostIt.Android.csproj index 62b494f82..3d8be385c 100644 --- a/src/PostIt/PostIt.Android/PostIt.Android.csproj +++ b/src/PostIt/PostIt.Android/PostIt.Android.csproj @@ -9,23 +9,22 @@ 1.0 apk false + 1.1.0.0 + 1.1.0.0 + 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070 + 1.1.0-beta.1 - Resources\drawable\Icon.png - - - - + - - + \ No newline at end of file diff --git a/src/PostIt/PostIt.Android/Services/AndroidCurrentLocationProvider.cs b/src/PostIt/PostIt.Android/Services/AndroidCurrentLocationProvider.cs new file mode 100644 index 000000000..f710cacc7 --- /dev/null +++ b/src/PostIt/PostIt.Android/Services/AndroidCurrentLocationProvider.cs @@ -0,0 +1,130 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Android; +using Android.App; +using Android.Content.PM; +using Android.Locations; +using AndroidX.Core.App; +using AndroidX.Core.Content; +using PostIt.Services; + +namespace PostIt.Android.Services; + +internal static class AndroidCurrentLocationProvider +{ + public static async Task TryGetCurrentLocationAsync(CancellationToken cancellationToken) + { + var activity = MainActivity.Current; + if (activity is null) + { + return CurrentLocationResult.Unavailable("L'activité Android n'est pas encore prête."); + } + + var permissionGranted = await LocationPermissionBroker.EnsureGrantedAsync(activity, cancellationToken).ConfigureAwait(false); + if (!permissionGranted) + { + return CurrentLocationResult.PermissionDenied(); + } + + var locationManager = activity.GetSystemService(global::Android.Content.Context.LocationService) as LocationManager; + if (locationManager is null) + { + return CurrentLocationResult.Unavailable("Le service de localisation Android est indisponible."); + } + + var location = locationManager.GetProviders(enabledOnly: true)? + .Select(provider => locationManager.GetLastKnownLocation(provider)) + .Where(candidate => candidate is not null) + .OrderByDescending(candidate => candidate!.Time) + .ThenBy(candidate => candidate!.Accuracy) + .FirstOrDefault(); + + if (location is null) + { + return CurrentLocationResult.Unavailable("Aucune position n'est disponible. Activez la localisation du système puis réessayez."); + } + + return CurrentLocationResult.Success(location.Latitude, location.Longitude); + } + + public static bool HandlePermissionResult(int requestCode, Permission[]? grantResults) + => LocationPermissionBroker.HandleResult(requestCode, grantResults); + + private static class LocationPermissionBroker + { + private const int RequestCode = 4042; + private static readonly string[] RequestedPermissions = + { + Manifest.Permission.AccessFineLocation, + Manifest.Permission.AccessCoarseLocation, + }; + + private static readonly object SyncRoot = new(); + private static TaskCompletionSource? _pendingRequest; + + public static Task EnsureGrantedAsync(Activity activity, CancellationToken cancellationToken) + { + if (HasLocationPermission(activity)) + { + return Task.FromResult(true); + } + + lock (SyncRoot) + { + if (_pendingRequest is null) + { + _pendingRequest = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + ActivityCompat.RequestPermissions(activity, RequestedPermissions, RequestCode); + } + + if (!cancellationToken.CanBeCanceled) + { + return _pendingRequest.Task; + } + + return WaitAsync(_pendingRequest.Task, cancellationToken); + } + } + + public static bool HandleResult(int requestCode, Permission[]? grantResults) + { + if (requestCode != RequestCode) + { + return false; + } + + var granted = grantResults is { Length: > 0 } && grantResults.All(result => result == Permission.Granted); + TaskCompletionSource? pendingRequest; + lock (SyncRoot) + { + pendingRequest = _pendingRequest; + _pendingRequest = null; + } + + pendingRequest?.TrySetResult(granted); + return true; + } + + private static bool HasLocationPermission(Activity activity) + { + return ContextCompat.CheckSelfPermission(activity, Manifest.Permission.AccessFineLocation) == Permission.Granted + || ContextCompat.CheckSelfPermission(activity, Manifest.Permission.AccessCoarseLocation) == Permission.Granted; + } + + private static async Task WaitAsync(Task task, CancellationToken cancellationToken) + { + using var registration = cancellationToken.Register(() => + { + lock (SyncRoot) + { + _pendingRequest?.TrySetCanceled(cancellationToken); + _pendingRequest = null; + } + }); + + return await task.ConfigureAwait(false); + } + } +} diff --git a/src/PostIt/PostIt.Browser/PostIt.Browser.csproj b/src/PostIt/PostIt.Browser/PostIt.Browser.csproj index 2816b2f86..4534a2940 100644 --- a/src/PostIt/PostIt.Browser/PostIt.Browser.csproj +++ b/src/PostIt/PostIt.Browser/PostIt.Browser.csproj @@ -6,7 +6,7 @@ enable 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+49.Branch.release-1.0.8-rc3.Sha.5274d7bdf5d14406748a2063b5ae15191aac4359 + 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070 1.1.0-beta.1 @@ -15,4 +15,4 @@ - + \ No newline at end of file diff --git a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj index e47c5b6db..948e726c5 100644 --- a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj +++ b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj @@ -7,7 +7,7 @@ enable 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+49.Branch.release-1.0.8-rc3.Sha.5274d7bdf5d14406748a2063b5ae15191aac4359 + 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070 1.1.0-beta.1 @@ -24,4 +24,4 @@ - + \ No newline at end of file diff --git a/src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs b/src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs new file mode 100644 index 000000000..04cc0f314 --- /dev/null +++ b/src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs @@ -0,0 +1,167 @@ +using System.Net.Http; +using PostIt.ViewModels; +using Yavsc.Abstract.Workflow; +using Yavsc.Api.Client; + +namespace PostIt.Tests; + +public class ActivitiesPageViewModelTests +{ + [Fact] + public void ActivityApiClient_uses_avatar_authority_when_provided() + { + var api = new StubActivityApi(); + var client = new ActivityApiClient( + api, + "https://api.pschneider.fr/api/v1/", + "https://yavsc.pschneider.fr/"); + + var url = client.BuildAvatarXsUrl("paul"); + + Assert.Equal("https://yavsc.pschneider.fr/avatars/paul.xs.png", url); + } + + [Fact] + public async Task ActivityApiClient_uses_business_absolute_paths() + { + var api = new StubActivityApi(); + var client = new ActivityApiClient(api, "https://business.example/api/v1/"); + var billingClient = new BillingApiClient(api, "https://business.example/api/v1/"); + + await client.GetCatalogAsync("brush", TestContext.Current.CancellationToken); + await client.GetUsersAsync("brush-pro", TestContext.Current.CancellationToken); + await billingClient.CreateAsync("Rdv", new { Foo = "Bar" }, TestContext.Current.CancellationToken); + await billingClient.GetQuerySummariesAsync("Rdv", TestContext.Current.CancellationToken); + + Assert.Equal("https://business.example/api/v1/activity/catalog?parentCode=brush", api.Paths[0]); + Assert.Equal("https://business.example/api/v1/activity/brush-pro/users", api.Paths[1]); + Assert.Equal("https://business.example/api/v1/billing/Rdv", api.Paths[2]); + Assert.Equal("https://business.example/api/v1/billing/Rdv", api.Paths[3]); + } + + [Fact] + public async Task ActivityApiClient_uses_updated_business_base_without_restart() + { + var api = new StubActivityApi(); + var baseUrl = "https://business-a.example/api/v1/"; + var client = new ActivityApiClient(api, () => baseUrl); + + await client.GetCatalogAsync(ct: TestContext.Current.CancellationToken); + + baseUrl = "https://business-b.example/api/v1/"; + await client.GetUsersAsync("brush", TestContext.Current.CancellationToken); + + Assert.Equal("https://business-a.example/api/v1/activity/catalog", api.Paths[0]); + Assert.Equal("https://business-b.example/api/v1/activity/brush/users", api.Paths[1]); + } + + [Fact] + public async Task RefreshAsync_loads_first_activity_then_specialization_performers() + { + var api = new StubActivityApi(); + var client = new ActivityApiClient(api, "https://business.example/api/v1/"); + var billingClient = new BillingApiClient(api, "https://business.example/api/v1/"); + var vm = new ActivitiesPageViewModel(client, billingClient); + + await vm.RefreshAsync(); + + Assert.Equal("brush", vm.SelectedActivity?.Code); + Assert.Single(vm.Specializations); + Assert.Equal("brush", vm.CurrentActivity?.Code); + Assert.Single(vm.Performers); + Assert.Equal("Alice", vm.Performers[0].UserName); + Assert.Equal("https://business.example/avatars/Alice.xs.png", vm.Performers[0].AvatarXsUrl); + Assert.True(vm.Performers[0].HasPerformerProfile); + Assert.True(vm.Performers[0].IsPerformerActive); + Assert.Equal("Actif", vm.Performers[0].PerformerStatusBadgeLabel); + Assert.Equal("Pas d'autre activité", vm.Performers[0].ExtraActivityLabel); + + await vm.ShowSpecializationAsync(vm.Specializations[0]); + + Assert.Equal("brush-pro", vm.CurrentActivity?.Code); + Assert.Single(vm.Performers); + Assert.Equal("Bob", vm.Performers[0].UserName); + Assert.Equal("https://business.example/avatars/Bob.xs.png", vm.Performers[0].AvatarXsUrl); + Assert.True(vm.Performers[0].HasPerformerProfile); + Assert.False(vm.Performers[0].IsPerformerActive); + Assert.Equal("Inactif", vm.Performers[0].PerformerStatusBadgeLabel); + Assert.Equal("Autres spécialisations: 2", vm.Performers[0].ExtraActivityLabel); + Assert.Contains("brush pro", vm.StatusMessage, StringComparison.OrdinalIgnoreCase); + + await vm.ShowSpecializationAsync(null); + + Assert.Equal("brush", vm.CurrentActivity?.Code); + Assert.Single(vm.Performers); + Assert.Equal("Alice", vm.Performers[0].UserName); + } + + private sealed class StubActivityApi : IYavscApiClient + { + public HttpClient Http { get; } = new(); + public List Paths { get; } = new(); + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + Paths.Add(path); + + if (typeof(T) == typeof(List)) + { + var activities = new List + { + new() + { + Code = "brush", + Name = "Brush", + Description = "Coiffure à domicile", + PerformerCount = 1, + Forms = new List + { + new() { Id = 1, ActionName = "Rdv", Title = "Rendez-vous" } + }, + Children = new List + { + new() + { + Code = "brush-pro", + Name = "Brush Pro", + Description = "Spécialisation premium", + ParentCode = "brush", + PerformerCount = 1, + Forms = new List + { + new() { Id = 2, ActionName = "Rdv", Title = "Rendez-vous premium" } + } + } + } + } + }; + return Task.FromResult((T)(object)activities); + } + + if (typeof(T) == typeof(List)) + { + var performers = path.EndsWith("brush-pro/users", StringComparison.Ordinal) + ? new List + { + new() { PerformerId = "pro-2", HasPerformerProfile = true, Active = false, UserName = "Bob", ActivityCode = "brush-pro", ActivityName = "Brush Pro", ExtraActivityCount = 2 } + } + : new List + { + new() { PerformerId = "pro-1", HasPerformerProfile = true, Active = true, UserName = "Alice", ActivityCode = "brush", ActivityName = "Brush", ExtraActivityCount = 0 } + }; + + return Task.FromResult((T)(object)performers); + } + + return Task.FromResult(default(T)!); + } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + Paths.Add(path); + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} diff --git a/src/PostIt/PostIt.Tests/AndroidAppLaunchTests.cs b/src/PostIt/PostIt.Tests/AndroidAppLaunchTests.cs index d4980d268..25630c836 100644 --- a/src/PostIt/PostIt.Tests/AndroidAppLaunchTests.cs +++ b/src/PostIt/PostIt.Tests/AndroidAppLaunchTests.cs @@ -12,6 +12,7 @@ namespace PostIt.Tests; /// Skip conditions: the package is not installed on the connected device, /// or no device is connected via adb. /// +[Trait("Category", "Platform-Android")] public class AndroidAppLaunchTests { private const string PackageName = "fr.pschneider.postit"; @@ -23,8 +24,8 @@ public class AndroidAppLaunchTests _output = output; } - // https://twosixtech.com/blog/integrating-docker-and-adb/ - // FIXME ala hosted shared resource adb server - [Fact] + // TODO https://twosixtech.com/blog/integrating-docker-and-adb/ + [Fact] public void PostIt_starts_and_draws_a_first_frame_on_the_emulator() { if (!IsPackageInstalledOnAnyDevice()) diff --git a/src/PostIt/PostIt.Tests/BearerScopeTests.cs b/src/PostIt/PostIt.Tests/BearerScopeTests.cs index c6bf7d56e..984483fc7 100644 --- a/src/PostIt/PostIt.Tests/BearerScopeTests.cs +++ b/src/PostIt/PostIt.Tests/BearerScopeTests.cs @@ -67,7 +67,7 @@ public class BearerScopeTests Scopes = userScopes, RedirectUri = "postit://callback", }, - BusinessApiUrl = "https://example.invalid/api/v1/", + ApiUrl = "https://example.invalid/api/v1/", }; var tokensPath = Path.Combine( @@ -266,7 +266,7 @@ public class BearerScopeTests // private HttpClient is independent, so we resolve the // absolute URI ourselves from Settings.BusinessApiUrl — // the same URL BlogApiClient would have set as BaseAddress. - var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path); + var absolute = new Uri(new Uri(Settings.ApiUrl), path); using var req = new HttpRequestMessage(method, absolute); req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken); diff --git a/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs b/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs new file mode 100644 index 000000000..17fcc968c --- /dev/null +++ b/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs @@ -0,0 +1,357 @@ +using System.Text.Json; +using PostIt.Helpers; +using PostIt.Services; +using PostIt.ViewModels; +using PostIt.ViewModels.Commands; +using Yavsc; +using Yavsc.Abstract.Workflow; +using Yavsc.Api.Client; +using Yavsc.Models.Haircut; + +namespace PostIt.Tests; + +public class BillingCommandPageViewModelTests +{ + [Fact] + public async Task SubmitAsync_posts_rdv_payload_to_selected_billing_route() + { + var api = new RecordingApi(); + var client = new BillingApiClient(api, "https://business.example/api/v1/"); + var vm = + new CommandFormSummary { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" } + .CreateCommandPageViewModel( + new ActivityInfo { Code = "dev", Name = "Développement" }, + new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" }, + client) as RdvViewModel; + + vm!.EventDate = DateTime.Parse("2026-09-02 14:30"); + vm!.Reason = "Point de cadrage"; + vm!.Address = "1 rue du Test"; + vm!.Latitude = 48.8566; + vm!.Longitude = 2.3522; + vm!.Consent = true; + + await vm.SubmitCommand.ExecuteAsync(null); + + Assert.Equal("https://business.example/api/v1/billing/Rdv", api.LastPath); + Assert.NotNull(api.LastBody); + + using var json = JsonDocument.Parse(JsonSerializer.Serialize(api.LastBody)); + Assert.Equal("dev", json.RootElement.GetProperty("ActivityCode").GetString()); + Assert.Equal("perf-1", json.RootElement.GetProperty("PerformerId").GetString()); + Assert.Equal("Point de cadrage", json.RootElement.GetProperty("Reason").GetString()); + Assert.Equal((int)QueryStatus.Inserted, json.RootElement.GetProperty("Status").GetInt32()); + } + + [Fact] + public async Task SubmitAsync_refuses_unsupported_billing_code() + { + var api = new RecordingApi(); + var client = new BillingApiClient(api, "https://business.example/api/v1/"); + + var vm = + new CommandFormSummary { Id = 13, ActionName = "Book", Title = "Réservation" } + .CreateCommandPageViewModel( + new ActivityInfo { Code = "book", Name = "Book" }, + new ActivityUserDisplayItem { PerformerId = "perf-2", UserName = "Bob" }, + client); + + Assert.Null(vm); + } + + [Fact] + public async Task SubmitAsync_allows_missing_coordinates_and_omits_them_from_payload() + { + var api = new RecordingApi(); + var client = new BillingApiClient(api, "https://business.example/api/v1/"); + var vm = + + new CommandFormSummary { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" } + .CreateCommandPageViewModel( + new ActivityInfo { Code = "dev", Name = "Développement" }, + new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" }, + client) as RdvViewModel; + vm!.EventDate = DateTime.Parse("2026-09-02 14:30"); + vm!.Reason = "Point de cadrage"; + vm!.Address = "1 rue du Test"; + vm!.Latitude = null; + vm!.Longitude = null; + vm!.Consent = true; + + await vm.SubmitCommand.ExecuteAsync(null); + + using var json = JsonDocument.Parse(JsonSerializer.Serialize(api.LastBody)); + var location = json.RootElement.GetProperty("Location"); + Assert.Equal("1 rue du Test", location.GetProperty("Address").GetString()); + Assert.False(location.TryGetProperty("Latitude", out _)); + Assert.False(location.TryGetProperty("Longitude", out _)); + } + + [Fact] + public async Task UseCurrentLocationAsync_prefills_coordinates_from_platform_provider() + { + var original = Platform.TryGetCurrentLocationAsync; + try + { + Platform.TryGetCurrentLocationAsync = _ => Task.FromResult(CurrentLocationResult.Success(48.8566, 2.3522)); + + var api = new RecordingApi(); + var client = new BillingApiClient(api, "https://business.example/api/v1/"); + var vm = + new CommandFormSummary { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" } + .CreateCommandPageViewModel( + new ActivityInfo { Code = "dev", Name = "Développement" }, + new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" }, + client) as RdvViewModel; + + await vm!.UseCurrentLocationCommand.ExecuteAsync(null); + + Assert.Equal(48.8566, vm!.Latitude); + Assert.Equal(2.3522, vm!.Longitude); + } + finally + { + Platform.TryGetCurrentLocationAsync = original; + } + } + + [Fact] + public void ApplyLocationFromMap_sets_coordinates_and_updates_status() + { + var api = new RecordingApi(); + var client = new BillingApiClient(api, "https://business.example/api/v1/"); + var vm = + new CommandFormSummary { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" } + .CreateCommandPageViewModel( + new ActivityInfo { Code = "dev", Name = "Développement" }, + new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" }, + client) as RdvViewModel; + + vm!.Address = string.Empty; + vm.ApplyLocationFromMap(48.85661234, 2.35224567); + + Assert.Equal(48.856612, vm.Latitude); + Assert.Equal(2.352246, vm.Longitude); + Assert.Contains("Position sélectionnée", vm.StatusMessage, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void EventDateSelection_round_trips_with_EventDate_for_DatePicker_binding() + { + var api = new RecordingApi(); + var client = new BillingApiClient(api, "https://business.example/api/v1/"); + var vm = + new CommandFormSummary { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" } + .CreateCommandPageViewModel( + new ActivityInfo { Code = "dev", Name = "Développement" }, + new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" }, + client) as RdvViewModel; + + var selected = new DateTimeOffset(2026, 9, 7, 14, 30, 0, TimeSpan.FromHours(2)); + vm!.EventDateSelection = selected; + + Assert.Equal(selected.LocalDateTime, vm.EventDate); + Assert.Equal(vm.EventDate, vm.EventDateSelection!.Value.LocalDateTime); + } + + [Fact] + public void ApplyResolvedAddress_populates_empty_address_directly() + { + var api = new RecordingApi(); + var client = new BillingApiClient(api, "https://business.example/api/v1/"); + var vm = + new CommandFormSummary { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" } + .CreateCommandPageViewModel( + new ActivityInfo { Code = "dev", Name = "Développement" }, + new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" }, + client) as RdvViewModel; + + vm!.Address = string.Empty; + vm.ApplyResolvedAddress("10 rue de Rivoli, 75001 Paris"); + + Assert.Equal("10 rue de Rivoli, 75001 Paris", vm.Address); + Assert.False(vm.HasSuggestedAddress); + } + + [Fact] + public void ApplyResolvedAddress_preserves_manual_address_and_exposes_suggestion() + { + var api = new RecordingApi(); + var client = new BillingApiClient(api, "https://business.example/api/v1/"); + var vm = + new CommandFormSummary { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" } + .CreateCommandPageViewModel( + new ActivityInfo { Code = "dev", Name = "Développement" }, + new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" }, + client) as RdvViewModel; + + vm!.Address = "Saisie manuelle"; + vm.ApplyResolvedAddress("10 rue de Rivoli, 75001 Paris"); + + Assert.Equal("Saisie manuelle", vm.Address); + Assert.True(vm.HasSuggestedAddress); + Assert.Equal("10 rue de Rivoli, 75001 Paris", vm.SuggestedAddress); + + vm.ApplySuggestedAddressCommand.Execute(null); + + Assert.Equal("10 rue de Rivoli, 75001 Paris", vm.Address); + Assert.False(vm.HasSuggestedAddress); + } + + [Fact] + public async Task InitializeAsync_loads_prestations_for_brush_and_submit_posts_selected_prestation() + { + var api = new RecordingApi + { + HairPrestations = new List + { + new() { Id = 10, Title = "Femme · Cheveux mi-longs", Details = "Coupe · Brushing" }, + new() { Id = 11, Title = "Homme · Cheveux courts", Details = "Coupe · Coiffage" }, + } + }; + var client = new BillingApiClient(api, "https://business.example/api/v1/"); + var vm = + new CommandFormSummary { Id = 13, ActionName = "Brush", Title = "Coupe" } + .CreateCommandPageViewModel( + new ActivityInfo { Code = "brush", Name = "Brush" }, + new ActivityUserDisplayItem { PerformerId = "perf-2", UserName = "Bob" }, + client) as BrushViewModel; + vm!.EventDate = DateTime.Parse("2026-09-02 14:30"); + vm!.Address = "1 rue du Test"; + vm!.Latitude = 48.8566; + vm!.Longitude = 2.3522; + vm!.Consent = true; + vm!.AdditionalInfo = "Prévoir shampoing"; + + await vm.InitializeAsync(); + vm.SelectedPrestation = vm.AvailablePrestations[1]; + await vm.SubmitCommand.ExecuteAsync(null); + + Assert.Equal("https://business.example/api/v1/billing/Brush", api.LastPath); + using var json = JsonDocument.Parse(JsonSerializer.Serialize(api.LastBody)); + Assert.Equal(11, json.RootElement.GetProperty("PrestationId").GetInt32()); + Assert.Equal("Prévoir shampoing", json.RootElement.GetProperty("AdditionalInfo").GetString()); + } + + [Fact] + public async Task InitializeAsync_loads_prestations_for_mbrush_and_submit_posts_selected_prestations() + { + var api = new RecordingApi + { + HairPrestations = new List + { + new() { Id = 21, Title = "Femme · Cheveux longs", Details = "Coupe · Couleur" }, + new() { Id = 22, Title = "Enfant · Cheveux courts", Details = "Coupe · Sans technique" }, + } + }; + var client = new BillingApiClient(api, "https://business.example/api/v1/"); + var vm = + new CommandFormSummary { Id = 14, ActionName = "MBrush", Title = "Coupe groupée" } + .CreateCommandPageViewModel( + new ActivityInfo { Code = "mbrush", Name = "MBrush" }, + new ActivityUserDisplayItem { PerformerId = "perf-3", UserName = "Cara" }, + client) as MBrushViewModel; + vm!.EventDate = DateTime.Parse("2026-09-03 10:00"); + vm!.Address = "2 rue du Test"; + vm!.Latitude = 48.8567; + vm!.Longitude = 2.3523; + vm!.Consent = true; + + await vm.InitializeAsync(); + vm!.MultiPrestations[0].IsSelected = true; + vm!.MultiPrestations[1].IsSelected = true; + await vm!.SubmitCommand.ExecuteAsync(null); + + Assert.Equal("https://business.example/api/v1/billing/MBrush", api.LastPath); + using var json = JsonDocument.Parse(JsonSerializer.Serialize(api.LastBody)); + var prestations = json.RootElement.GetProperty("Prestations"); + Assert.Equal(2, prestations.GetArrayLength()); + Assert.Equal(21, prestations[0].GetProperty("PrestationId").GetInt32()); + Assert.Equal(22, prestations[1].GetProperty("PrestationId").GetInt32()); + } + + [Fact] + public async Task InitializeAsync_with_existing_brush_query_prefills_and_submit_updates_query() + { + var api = new RecordingApi + { + HairPrestations = new List + { + new() { Id = 30, Title = "Femme · Cheveux longs", Details = "Coupe · Brushing" }, + new() { Id = 31, Title = "Homme · Cheveux courts", Details = "Coupe" }, + } + }; + var client = new BillingApiClient(api, "https://business.example/api/v1/"); + var vm = + new CommandFormSummary { Id = 13, ActionName = "Brush", Title = "Coupe" } + .CreateCommandPageViewModel( + new ActivityInfo { Code = "brush", Name = "Brush" }, + new ActivityUserDisplayItem { PerformerId = "perf-2", UserName = "Bob" }, + client) as BrushViewModel; + await vm!.InitializeAsync(new BillingQueryDetailsDto + { + Id = 77, + BillingCode = "Brush", + ActivityCode = "brush", + PerformerId = "perf-2", + ClientId = "cli-1", + EventDate = new DateTime(2026, 9, 2, 14, 30, 0, DateTimeKind.Utc), + Consent = true, + Status = QueryStatus.Accepted, + PrestationId = 30, + AdditionalInfo = "Ancienne note", + Location = new BillingLocationDto + { + Address = "1 rue du Test", + Latitude = 48.8566, + Longitude = 2.3522, + } + }); + + vm!.SelectedPrestation = vm!.AvailablePrestations[1]; + vm!.AdditionalInfo = "Note mise à jour"; + await vm!.SubmitCommand.ExecuteAsync(null); + + Assert.Equal(HttpMethod.Put, api.LastMethod); + Assert.Equal("https://business.example/api/v1/billing/Brush/77", api.LastPath); + Assert.True(vm.IsEditingExisting); + Assert.Equal("Mettre à jour la commande", vm.SubmitLabel); + + using var json = JsonDocument.Parse(JsonSerializer.Serialize(api.LastBody)); + Assert.Equal(77, json.RootElement.GetProperty("Id").GetInt32()); + Assert.Equal(31, json.RootElement.GetProperty("PrestationId").GetInt32()); + Assert.Equal("Note mise à jour", json.RootElement.GetProperty("AdditionalInfo").GetString()); + Assert.Equal((int)QueryStatus.Accepted, json.RootElement.GetProperty("Status").GetInt32()); + } + + private sealed class RecordingApi : IYavscApiClient + { + public HttpClient Http { get; } = new(); + public HttpMethod? LastMethod { get; private set; } + public string? LastPath { get; private set; } + public object? LastBody { get; private set; } + public List? HairPrestations { get; init; } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + LastMethod = method; + LastPath = path; + LastBody = body; + if (typeof(T) == typeof(List)) + { + return Task.FromResult((T)(object)(HairPrestations ?? new List())); + } + return Task.FromResult(default(T)!); + } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + LastMethod = method; + LastPath = path; + LastBody = body; + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} diff --git a/src/PostIt/PostIt.Tests/BillingQueriesPageViewModelTests.cs b/src/PostIt/PostIt.Tests/BillingQueriesPageViewModelTests.cs new file mode 100644 index 000000000..d3d1d5694 --- /dev/null +++ b/src/PostIt/PostIt.Tests/BillingQueriesPageViewModelTests.cs @@ -0,0 +1,132 @@ +using System.Net.Http; +using PostIt.ViewModels; +using Yavsc; +using Yavsc.Abstract.Workflow; +using Yavsc.Api.Client; + +namespace PostIt.Tests; + +public class BillingQueriesPageViewModelTests +{ + [Fact] + public async Task RefreshAsync_filters_queries_by_selected_activity_and_performer() + { + var api = new StubBillingApi(); + var client = new BillingApiClient(api, "https://business.example/api/v1/"); + var vm = new BillingQueriesPageViewModel( + new ActivityInfo { Code = "dev", Name = "Développement" }, + new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" }, + new CommandFormSummary { Id = 1, ActionName = "Rdv", Title = "Rendez-vous" }, + client); + + await vm.InitializeAsync(); + + Assert.Equal("https://business.example/api/v1/billing/Rdv", api.Paths.Single()); + Assert.Equal(3, vm.Queries.Count); + Assert.Contains(vm.Queries, q => q.Description == "Rendez-vous #1"); + Assert.Contains("3 commande", vm.StatusMessage, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task RefreshAsync_in_readonly_ongoing_mode_keeps_only_ongoing_statuses_and_disables_open() + { + var api = new StubBillingApi(); + var client = new BillingApiClient(api, "https://business.example/api/v1/"); + var vm = new BillingQueriesPageViewModel( + new ActivityInfo { Code = "dev", Name = "Développement" }, + new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" }, + new CommandFormSummary { Id = 1, ActionName = "Rdv", Title = "Rendez-vous" }, + client, + isReadOnly: true, + ongoingOnly: true); + + await vm.InitializeAsync(); + + Assert.Equal(2, vm.Queries.Count); + Assert.All(vm.Queries, q => Assert.DoesNotContain("Rejected", q.StatusLabel, StringComparison.OrdinalIgnoreCase)); + Assert.Contains("lecture seule", vm.StatusMessage, StringComparison.OrdinalIgnoreCase); + + Assert.True(vm.Queries.Count > 0); + } + + private sealed class StubBillingApi : IYavscApiClient + { + public HttpClient Http { get; } = new(); + public List Paths { get; } = new(); + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + Paths.Add(path); + + if (typeof(T) == typeof(List)) + { + var data = new List + { + new() + { + Id = 11, + ActivityCode = "dev", + PerformerId = "perf-1", + ClientId = "cli-1", + Status = QueryStatus.Inserted, + Description = "Rendez-vous #1", + Reason = "Point de cadrage", + EventDate = new DateTime(2026, 9, 1, 10, 0, 0, DateTimeKind.Utc), + }, + new() + { + Id = 12, + ActivityCode = "other", + PerformerId = "perf-1", + ClientId = "cli-1", + Status = QueryStatus.Accepted, + Description = "Autre activité", + EventDate = new DateTime(2026, 9, 2, 10, 0, 0, DateTimeKind.Utc), + }, + new() + { + Id = 13, + ActivityCode = "dev", + PerformerId = "perf-2", + ClientId = "cli-1", + Status = QueryStatus.Accepted, + Description = "Autre performer", + EventDate = new DateTime(2026, 9, 3, 10, 0, 0, DateTimeKind.Utc), + }, + new() + { + Id = 14, + ActivityCode = "dev", + PerformerId = "perf-1", + ClientId = "cli-1", + Status = QueryStatus.InProgress, + Description = "En cours", + EventDate = new DateTime(2026, 9, 4, 10, 0, 0, DateTimeKind.Utc), + }, + new() + { + Id = 15, + ActivityCode = "dev", + PerformerId = "perf-1", + ClientId = "cli-1", + Status = QueryStatus.Rejected, + Description = "Rejetée", + EventDate = new DateTime(2026, 9, 5, 10, 0, 0, DateTimeKind.Utc), + } + }; + + return Task.FromResult((T)(object)data); + } + + return Task.FromResult(default(T)!); + } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + Paths.Add(path); + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} diff --git a/src/PostIt/PostIt.Tests/BlogPostAuthorDtoTests.cs b/src/PostIt/PostIt.Tests/BlogPostAuthorDtoTests.cs index 895f220ec..daabdf59b 100644 --- a/src/PostIt/PostIt.Tests/BlogPostAuthorDtoTests.cs +++ b/src/PostIt/PostIt.Tests/BlogPostAuthorDtoTests.cs @@ -166,4 +166,51 @@ public class BlogPostAuthorDtoTests Assert.True(root.TryGetProperty("userName", out _)); Assert.True(root.TryGetProperty("avatar", out _)); } + + [Fact] + public void BlogPostDto_deserialises_acl_from_detail_payload() + { + // Detail payload shape emitted by BlogApiController.GetBlog: + // ACL entries are included under "acl"/"ACL". + var json = """ + { + "id": 99, + "title": "ACL test", + "authorId": "u-alice", + "acl": [ + { "circleId": 12, "blogPostId": 99 }, + { "circleId": 34, "blogPostId": 99 } + ] + } + """; + + var post = JsonSerializer.Deserialize(json, CaseInsensitiveJson); + + Assert.NotNull(post); + var acl = post!.GetACL(); + Assert.Equal(2, acl.Length); + Assert.Contains(acl, a => a.CircleId == 12); + Assert.Contains(acl, a => a.CircleId == 34); + } + + [Fact] + public void BlogPostDto_does_not_emit_acl_when_serialized_for_write() + { + var post = new BlogPostDto + { + Id = 77, + Title = "Write payload" + }; + post.AuthorizeCircle(11); + + // The client should not send ACL through POST/PUT blog payloads. + // ACL mutations have their own dedicated /blogacl endpoint. + var json = JsonSerializer.Serialize(post, + new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + Assert.False(root.TryGetProperty("acl", out _)); + Assert.False(root.TryGetProperty("wireAcl", out _)); + } } diff --git a/src/PostIt/PostIt.Tests/MainPageSaveTests.cs b/src/PostIt/PostIt.Tests/MainPageSaveTests.cs index 5baae6581..0fd5627c8 100644 --- a/src/PostIt/PostIt.Tests/MainPageSaveTests.cs +++ b/src/PostIt/PostIt.Tests/MainPageSaveTests.cs @@ -79,9 +79,9 @@ public class MainPageSaveTests // whose Title is exactly what the user typed. The bug // fails this assertion with Title == string.Empty. Assert.NotEmpty(recorder.Calls); - var (method, path, body) = recorder.FirstCall; + var (method, path, body) = recorder.Calls[1]; Assert.Equal(HttpMethod.Post, method); - Assert.Equal("blog", path); + Assert.Equal("blogspot", path); var sent = Assert.IsType(body); Assert.Equal(typed, sent.Title); } diff --git a/src/PostIt/PostIt.Tests/NominatimReverseGeocodingServiceTests.cs b/src/PostIt/PostIt.Tests/NominatimReverseGeocodingServiceTests.cs new file mode 100644 index 000000000..4eceb7a2f --- /dev/null +++ b/src/PostIt/PostIt.Tests/NominatimReverseGeocodingServiceTests.cs @@ -0,0 +1,72 @@ +using System.Net; +using System.Text; +using PostIt.Services; + +namespace PostIt.Tests; + +public class NominatimReverseGeocodingServiceTests +{ + [Fact] + public async Task TryResolveAddressAsync_formats_compact_street_address_from_nominatim_payload() + { + var handler = new StubHandler(""" + { + "display_name": "6, Place de l'Hôtel-de-Ville - Esplanade de la Libération, Paris, 75004, France", + "address": { + "house_number": "6", + "road": "Place de l'Hôtel-de-Ville - Esplanade de la Libération", + "postcode": "75004", + "city": "Paris" + } + } + """); + + var service = new NominatimReverseGeocodingService(new HttpClient(handler) + { + BaseAddress = new Uri("https://nominatim.openstreetmap.org/") + }); + + var result = await service.TryResolveAddressAsync(48.8566, 2.3522); + + Assert.Equal("6, Place de l'Hôtel-de-Ville - Esplanade de la Libération, 75004, Paris", result); + Assert.NotNull(handler.LastRequest); + Assert.Contains("reverse?format=jsonv2", handler.LastRequest!.RequestUri!.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task TryResolveAddressAsync_returns_null_on_unsuccessful_response() + { + var handler = new StubHandler("{}", HttpStatusCode.TooManyRequests); + var service = new NominatimReverseGeocodingService(new HttpClient(handler) + { + BaseAddress = new Uri("https://nominatim.openstreetmap.org/") + }); + + var result = await service.TryResolveAddressAsync(48.8566, 2.3522); + + Assert.Null(result); + } + + private sealed class StubHandler : HttpMessageHandler + { + private readonly string _payload; + private readonly HttpStatusCode _statusCode; + + public HttpRequestMessage? LastRequest { get; private set; } + + public StubHandler(string payload, HttpStatusCode statusCode = HttpStatusCode.OK) + { + _payload = payload; + _statusCode = statusCode; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + return Task.FromResult(new HttpResponseMessage(_statusCode) + { + Content = new StringContent(_payload, Encoding.UTF8, "application/json") + }); + } + } +} \ No newline at end of file diff --git a/src/PostIt/PostIt.Tests/PostAclDialogTests.cs b/src/PostIt/PostIt.Tests/PostAclDialogTests.cs index ccb33ec7a..e7175203c 100644 --- a/src/PostIt/PostIt.Tests/PostAclDialogTests.cs +++ b/src/PostIt/PostIt.Tests/PostAclDialogTests.cs @@ -9,6 +9,7 @@ using PostIt.Services; using PostIt.ViewModels; using PostIt.Views; using Yavsc.Api.Client; +using Yavsc.Api.Client.Dtos; using Yavsc.Blogspot; namespace PostIt.Tests; @@ -94,7 +95,7 @@ public class PostAclDialogTests HttpMethod method, string path, object? body = null, CancellationToken ct = default) { - var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path); + var absolute = new Uri(new Uri(Settings.ApiUrl), path); using var req = new HttpRequestMessage(method, absolute); using var resp = _http.SendAsync(req, ct).GetAwaiter().GetResult(); resp.EnsureSuccessStatusCode(); @@ -122,8 +123,8 @@ public class PostAclDialogTests var handler = new CountingHttpHandler(); var settings = new Settings(); var api = new TestableYavscApiClient(settings, new TokenStore(System.IO.Path.GetTempFileName()), handler); - var aclClient = new BlogAclApiClient(api, settings.BusinessApiUrl); - var circleClient = new CircleApiClient(api, settings.BusinessApiUrl); + var aclClient = new BlogAclApiClient(api, settings.ApiUrl); + var circleClient = new CircleApiClient(api, settings.ApiUrl); var services = new ServiceCollection(); services.AddSingleton(settings); @@ -190,9 +191,8 @@ public class PostAclDialogTests await Task.Delay(20); } - // Assert: exactly two GETs went out (one to /blogacl, - // one to /circle), both from the LoadAsync call. - Assert.Equal(2, handler.RequestCount); + // Assert: one GET went out (for /circle) from LoadAsync. + Assert.Equal(1, handler.RequestCount); // And the VM's idempotency gate has flipped. Assert.True(vm.Loaded); @@ -218,7 +218,58 @@ public class PostAclDialogTests await vm.LoadAsync(); // Assert: the second call short-circuited on _loaded. - Assert.Equal(2, handler.RequestCount); + Assert.Equal(1, handler.RequestCount); Assert.True(vm.Loaded); } + + [Fact] + public async Task LoadAsync_keeps_acl_from_blogpostdto_and_only_loads_circles() + { + var post = new BlogPostDto { Id = 42, Title = "ACL hydration" }; + post.AuthorizeCircle(12); + post.AuthorizeCircle(34); + + var api = new StubAclApiClient(); + var aclClient = new BlogAclApiClient(api, "http://localhost/"); + var circleClient = new CircleApiClient(api, "http://localhost/"); + var vm = new PostAclDialogViewModel(post, aclClient, circleClient); + + await vm.LoadAsync(); + + Assert.Equal(1, api.CallCount); + Assert.Equal(2, vm.AclEntries.Count); + Assert.Contains(vm.AclEntries, a => a.CircleId == 12); + Assert.Contains(vm.AclEntries, a => a.CircleId == 34); + } + + private sealed class StubAclApiClient : IYavscApiClient + { + public HttpClient Http { get; } = new(); + public int CallCount { get; private set; } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + CallCount++; + + if (typeof(T) == typeof(List)) + { + var circles = new List + { + new() { Id = 12, Name = "A", OwnerId = "owner", Public = false }, + new() { Id = 34, Name = "B", OwnerId = "owner", Public = false }, + }; + return Task.FromResult((T)(object)circles); + } + + return Task.FromResult(default(T)!); + } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + CallCount++; + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } } diff --git a/src/PostIt/PostIt.Tests/PostIt.Tests.csproj b/src/PostIt/PostIt.Tests/PostIt.Tests.csproj index 663bdd12f..2c4f954d9 100644 --- a/src/PostIt/PostIt.Tests/PostIt.Tests.csproj +++ b/src/PostIt/PostIt.Tests/PostIt.Tests.csproj @@ -8,7 +8,7 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+49.Branch.release-1.0.8-rc3.Sha.5274d7bdf5d14406748a2063b5ae15191aac4359 + 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070 1.1.0-beta.1 @@ -16,9 +16,7 @@ - - @@ -28,6 +26,5 @@ - - - + + \ No newline at end of file diff --git a/src/PostIt/PostIt.Tests/PostItViewModelTests.cs b/src/PostIt/PostIt.Tests/PostItViewModelTests.cs index d2c5d78e9..1a867bd61 100644 --- a/src/PostIt/PostIt.Tests/PostItViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/PostItViewModelTests.cs @@ -55,6 +55,21 @@ public class PostItViewModelTests Assert.Equal("Hello", posts[0].Title); } + [Fact] + public async Task TogglePublishCommand_uses_the_current_checked_state_without_inverting_it() + { + var api = new RecordingPublishApi(); + var blog = new BlogApiClient(api, "http://localhost/"); + var viewModel = new MainViewModel(blog); + + viewModel.SelectedPost = new BlogPostDto { Id = 42, IsPublished = false }; + + await viewModel.SetPublishStateAsync(true); + + Assert.True(api.LastPublishValue); + Assert.True(viewModel.DraftIsPublished); + } + /// Test fake that always throws if the API is invoked. private sealed class ThrowingYavscApiClient : YavscApiClient { @@ -103,4 +118,34 @@ public class PostItViewModelTests return Task.FromResult(default(T)!); } } + + private sealed class RecordingPublishApi : IYavscApiClient + { + public bool LastPublishValue { get; private set; } + public HttpClient Http { get; } = new(); + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + if (method == HttpMethod.Put && path.Contains("/publish", StringComparison.OrdinalIgnoreCase)) + { + var publish = body?.GetType().GetProperty("publish")?.GetValue(body) is bool value && value; + LastPublishValue = publish; + } + + return Task.FromResult(default(T)!); + } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + if (method == HttpMethod.Put && path.Contains("/publish", StringComparison.OrdinalIgnoreCase)) + { + var publish = body?.GetType().GetProperty("publish")?.GetValue(body) is bool value && value; + LastPublishValue = publish; + } + + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } } diff --git a/src/PostIt/PostIt.Tests/RdvPageHeadlessTests.cs b/src/PostIt/PostIt.Tests/RdvPageHeadlessTests.cs new file mode 100644 index 000000000..fdf8689ab --- /dev/null +++ b/src/PostIt/PostIt.Tests/RdvPageHeadlessTests.cs @@ -0,0 +1,79 @@ +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using PostIt.ViewModels; +using PostIt.ViewModels.Commands; +using PostIt.Views.Commands; +using Yavsc.Abstract.Workflow; +using Yavsc.Api.Client; + +namespace PostIt.Tests; + +public class RdvPageHeadlessTests +{ + [AvaloniaFact] + public void Suggested_address_panel_is_hidden_by_default() + { + var page = CreatePage(out _); + + var panel = page.FindControl("SuggestedAddressPanel"); + var progress = page.FindControl("SuggestedAddressProgress"); + + Assert.NotNull(panel); + Assert.NotNull(progress); + Assert.False(panel!.IsVisible); + Assert.False(progress!.IsVisible); + } + + [AvaloniaFact] + public async Task Suggested_address_panel_and_spinner_follow_viewmodel_state() + { + var page = CreatePage(out var vm); + var panel = page.FindControl("SuggestedAddressPanel")!; + var progress = page.FindControl("SuggestedAddressProgress")!; + var applyButton = page.FindControl