diff --git a/.forgejo/workflows/buildAndTest.yml b/.forgejo/workflows/buildAndTest.yml index ea58d2fef..2f14e449e 100644 --- a/.forgejo/workflows/buildAndTest.yml +++ b/.forgejo/workflows/buildAndTest.yml @@ -48,4 +48,4 @@ jobs: --verbosity normal \ --filter="Category!=Platform-Android" \ --logger "xunit;LogFileName=test-results.xml" \ - && echo "✅ Success !" || echo "❌ Fail ($?)!" + && echo "✅ Success !" || { echo "❌ Fail ($?)!"; exit 1; } diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index a72f92bd6..911fc831d 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -175,6 +175,12 @@ jobs: run: | cd /src/_src dotnet restore + - name: Test + run: | + cd /src/_src && dotnet test \ + --verbosity normal \ + --filter="Category!=Platform-Android" \ + --logger "xunit;LogFileName=test-results.xml" - name: Build de PostIt.Android ARM64 run: | @@ -200,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 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/tasks.json b/.vscode/tasks.json index fd46437ac..a30c9c065 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -7,7 +7,7 @@ "fileLocation": ["relative", "${workspaceFolder}"], "source": "dotnet", "pattern": { - "regexp": "^\\s+(.*)\\((\\d+),(\\d+)\\):\\s+(error|warning) (.+): (.*)$", + "regexp": "^\\s*(.*)\\((\\d+),(\\d+)\\):\\s+(error|warning) (.*)$", "file": 1, "line": 2, "column": 3, diff --git a/CHANGELOG.md b/CHANGELOG.md index a7c3209c6..111c1491a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,97 @@ # Changelog +## [1.0.8-rc14] - unstable + +### Added + +* [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. + +### Changed + +* [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. + +### 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fd408c5c1..8aa0567d3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,6 +49,19 @@ 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 @@ -61,8 +74,8 @@ et ce projet adhère au [Semantic Versioning](https://semver.org/spec/v2.0.0.htm À noter : la **parité du numéro de patch** porte une signification de canal : -- **patch pair** (ex. `1.0.0`, `1.0.2`) → **stable** -- **patch impair** (ex. `1.0.1`, `1.0.3`) → **preview** +- **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 diff --git a/Directory.Packages.props b/Directory.Packages.props index 7505c8512..f1be93f91 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,15 +1,31 @@ true + 8.1.0-pazofrc007 - - - - - - - + + + + + + + + + + + + + + + + + + + + + + @@ -17,14 +33,24 @@ - + + + + + + + + + + + - \ No newline at end of file + diff --git a/README.md b/README.md index b132f3f20..549348f4a 100644 --- a/README.md +++ b/README.md @@ -28,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/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 4dd4b2889..2f27340e9 100644 --- a/src/PostIt/Directory.Packages.props +++ b/src/PostIt/Directory.Packages.props @@ -16,6 +16,8 @@ + + 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/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.Tests/ActivitiesPageViewModelTests.cs b/src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs index c2fab4c31..04cc0f314 100644 --- a/src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs @@ -7,6 +7,20 @@ 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() { @@ -25,6 +39,22 @@ public class ActivitiesPageViewModelTests 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() { @@ -40,6 +70,7 @@ public class ActivitiesPageViewModelTests 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); @@ -50,6 +81,7 @@ public class ActivitiesPageViewModelTests 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); @@ -72,9 +104,9 @@ public class ActivitiesPageViewModelTests { Paths.Add(path); - if (typeof(T) == typeof(List)) + if (typeof(T) == typeof(List)) { - var activities = new List + var activities = new List { new() { @@ -82,11 +114,11 @@ public class ActivitiesPageViewModelTests Name = "Brush", Description = "Coiffure à domicile", PerformerCount = 1, - Forms = new List + Forms = new List { new() { Id = 1, ActionName = "Rdv", Title = "Rendez-vous" } }, - Children = new List + Children = new List { new() { @@ -95,7 +127,7 @@ public class ActivitiesPageViewModelTests Description = "Spécialisation premium", ParentCode = "brush", PerformerCount = 1, - Forms = new List + Forms = new List { new() { Id = 2, ActionName = "Rdv", Title = "Rendez-vous premium" } } @@ -106,14 +138,14 @@ public class ActivitiesPageViewModelTests return Task.FromResult((T)(object)activities); } - if (typeof(T) == typeof(List)) + if (typeof(T) == typeof(List)) { var performers = path.EndsWith("brush-pro/users", StringComparison.Ordinal) - ? new List + ? new List { new() { PerformerId = "pro-2", HasPerformerProfile = true, Active = false, UserName = "Bob", ActivityCode = "brush-pro", ActivityName = "Brush Pro", ExtraActivityCount = 2 } } - : new List + : new List { new() { PerformerId = "pro-1", HasPerformerProfile = true, Active = true, UserName = "Alice", ActivityCode = "brush", ActivityName = "Brush", ExtraActivityCount = 0 } }; 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 index eef3fe215..17fcc968c 100644 --- a/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs @@ -1,6 +1,8 @@ -using System.Net.Http; 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; @@ -15,19 +17,19 @@ public class BillingCommandPageViewModelTests { var api = new RecordingApi(); var client = new BillingApiClient(api, "https://business.example/api/v1/"); - var vm = new BillingCommandPageViewModel( - new ActivityBrowseItemDto { Code = "dev", Name = "Développement" }, + 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" }, - new CommandFormSummaryDto { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" }, - client) - { - EventDateText = "2026-09-02 14:30", - Reason = "Point de cadrage", - Address = "1 rue du Test", - LatitudeText = "48.8566", - LongitudeText = "2.3522", - Consent = true, - }; + 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); @@ -46,16 +48,154 @@ public class BillingCommandPageViewModelTests { var api = new RecordingApi(); var client = new BillingApiClient(api, "https://business.example/api/v1/"); - var vm = new BillingCommandPageViewModel( - new ActivityBrowseItemDto { Code = "book", Name = "Book" }, + + 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" }, - new CommandFormSummaryDto { Id = 13, ActionName = "Book", Title = "Réservation" }, 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); - Assert.Null(api.LastPath); - Assert.Contains("n'est pas encore pris en charge", vm.StatusMessage, StringComparison.OrdinalIgnoreCase); + 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] @@ -70,19 +210,18 @@ public class BillingCommandPageViewModelTests } }; var client = new BillingApiClient(api, "https://business.example/api/v1/"); - var vm = new BillingCommandPageViewModel( - new ActivityBrowseItemDto { Code = "brush", Name = "Brush" }, + var vm = + new CommandFormSummary { Id = 13, ActionName = "Brush", Title = "Coupe" } + .CreateCommandPageViewModel( + new ActivityInfo { Code = "brush", Name = "Brush" }, new ActivityUserDisplayItem { PerformerId = "perf-2", UserName = "Bob" }, - new CommandFormSummaryDto { Id = 13, ActionName = "Brush", Title = "Coupe" }, - client) - { - EventDateText = "2026-09-02 14:30", - Address = "1 rue du Test", - LatitudeText = "48.8566", - LongitudeText = "2.3522", - Consent = true, - AdditionalInfo = "Prévoir shampoing", - }; + 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]; @@ -106,23 +245,22 @@ public class BillingCommandPageViewModelTests } }; var client = new BillingApiClient(api, "https://business.example/api/v1/"); - var vm = new BillingCommandPageViewModel( - new ActivityBrowseItemDto { Code = "mbrush", Name = "MBrush" }, + 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" }, - new CommandFormSummaryDto { Id = 14, ActionName = "MBrush", Title = "Coupe groupée" }, - client) - { - EventDateText = "2026-09-03 10:00", - Address = "2 rue du Test", - LatitudeText = "48.8567", - LongitudeText = "2.3523", - Consent = true, - }; + 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); + 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)); @@ -144,13 +282,13 @@ public class BillingCommandPageViewModelTests } }; var client = new BillingApiClient(api, "https://business.example/api/v1/"); - var vm = new BillingCommandPageViewModel( - new ActivityBrowseItemDto { Code = "brush", Name = "Brush" }, + var vm = + new CommandFormSummary { Id = 13, ActionName = "Brush", Title = "Coupe" } + .CreateCommandPageViewModel( + new ActivityInfo { Code = "brush", Name = "Brush" }, new ActivityUserDisplayItem { PerformerId = "perf-2", UserName = "Bob" }, - new CommandFormSummaryDto { Id = 13, ActionName = "Brush", Title = "Coupe" }, - client); - - await vm.InitializeAsync(new BillingQueryDetailsDto + client) as BrushViewModel; + await vm!.InitializeAsync(new BillingQueryDetailsDto { Id = 77, BillingCode = "Brush", @@ -170,9 +308,9 @@ public class BillingCommandPageViewModelTests } }); - vm.SelectedPrestation = vm.AvailablePrestations[1]; - vm.AdditionalInfo = "Note mise à jour"; - await vm.SubmitCommand.ExecuteAsync(null); + 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); @@ -216,4 +354,4 @@ public class BillingCommandPageViewModelTests public ValueTask DisposeAsync() => ValueTask.CompletedTask; } -} \ No newline at end of file +} diff --git a/src/PostIt/PostIt.Tests/BillingQueriesPageViewModelTests.cs b/src/PostIt/PostIt.Tests/BillingQueriesPageViewModelTests.cs index a5d375323..d3d1d5694 100644 --- a/src/PostIt/PostIt.Tests/BillingQueriesPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/BillingQueriesPageViewModelTests.cs @@ -14,9 +14,9 @@ public class BillingQueriesPageViewModelTests var api = new StubBillingApi(); var client = new BillingApiClient(api, "https://business.example/api/v1/"); var vm = new BillingQueriesPageViewModel( - new ActivityBrowseItemDto { Code = "dev", Name = "Développement" }, + new ActivityInfo { Code = "dev", Name = "Développement" }, new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" }, - new CommandFormSummaryDto { Id = 1, ActionName = "Rdv", Title = "Rendez-vous" }, + new CommandFormSummary { Id = 1, ActionName = "Rdv", Title = "Rendez-vous" }, client); await vm.InitializeAsync(); @@ -33,9 +33,9 @@ public class BillingQueriesPageViewModelTests var api = new StubBillingApi(); var client = new BillingApiClient(api, "https://business.example/api/v1/"); var vm = new BillingQueriesPageViewModel( - new ActivityBrowseItemDto { Code = "dev", Name = "Développement" }, + new ActivityInfo { Code = "dev", Name = "Développement" }, new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" }, - new CommandFormSummaryDto { Id = 1, ActionName = "Rdv", Title = "Rendez-vous" }, + new CommandFormSummary { Id = 1, ActionName = "Rdv", Title = "Rendez-vous" }, client, isReadOnly: true, ongoingOnly: true); @@ -45,10 +45,8 @@ public class BillingQueriesPageViewModelTests 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.False(vm.CanOpenDetails); - vm.SelectedQuery = vm.Queries[0]; - Assert.False(vm.OpenSelectedQueryCommand.CanExecute(null)); + Assert.True(vm.Queries.Count > 0); } private sealed class StubBillingApi : IYavscApiClient @@ -131,4 +129,4 @@ public class BillingQueriesPageViewModelTests public ValueTask DisposeAsync() => ValueTask.CompletedTask; } -} \ No newline at end of file +} 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 dd2776291..e7175203c 100644 --- a/src/PostIt/PostIt.Tests/PostAclDialogTests.cs +++ b/src/PostIt/PostIt.Tests/PostAclDialogTests.cs @@ -95,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(); @@ -123,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); 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