Files
Omika/tests/Omika.Tests/Mobile/Local/AgendaViewModelTests.cs
T
2026-10-23 18:46:50 +02:00

1210 lines
47 KiB
C#

using System.Net.Http;
using System.Text.Json;
using Omika.App.Local.Abstractions;
using Omika.App.Local.Models;
using Omika.App.Local.Services;
using Omika.App.Navigation;
using Omika.App.Services;
using Omika.App.ViewModels;
using Omika.Core.Contracts.Common;
using Omika.Core.Contracts.Configuration;
using Omika.Core.Contracts.Mobile.Sync;
using Omika.Core.Contracts.Planning;
using Omika.Core.Observability;
namespace Omika.Tests.Mobile.Local;
/// <summary>
/// Tests du ViewModel Agenda : chargement, navigation jour précédent/suivant,
/// état vide, état d'erreur et ouverture du détail.
/// </summary>
[Collection("TelemetrieStatique")]
public class AgendaViewModelTests : LocalTestBase
{
private readonly AgendaViewModel _viewModel;
private readonly NavigationService _navigation;
private readonly FakeSessionService _sessionService = new();
private readonly FakeSuiviTempsService _suiviTempsService = new();
private readonly StubParametreMetierService _parametreMetierService = new();
public AgendaViewModelTests()
{
IAgendaService agendaService = new AgendaService(
RendezVousRepository, ClientRepository, SiteRepository, InterventionRepository, CollaborateurRepository, ApiClient, SessionService);
_navigation = new NavigationService();
_viewModel = new AgendaViewModel(agendaService, _sessionService, _suiviTempsService, _parametreMetierService, _navigation, new PermissionService(), new RendezVousStateNotifier(), Network);
}
private static LocalRendezVous CreateRendezVous(Guid id, DateTime debut, DateTime fin, string titre, string? interventionId = null)
{
var dto = new RendezVousDetailsDto
{
Id = id.ToString(),
Titre = titre,
Debut = debut,
Fin = fin,
InterventionId = interventionId,
Statut = new ReferenceValueDto { Label = "Planifié" }
};
return new LocalRendezVous
{
Id = id,
PayloadJson = JsonSerializer.Serialize(dto),
UpdatedAtUtc = DateTimeOffset.UtcNow
};
}
private sealed class FakeSessionService : IMobileSessionService
{
public Guid? CollaborateurGuid { get; set; }
public Task<bool> HasValidSessionAsync(CancellationToken ct = default) => Task.FromResult(false);
public Task<bool> HasExpiredSessionAsync(CancellationToken ct = default) => Task.FromResult(false);
public Task<Omika.Core.Contracts.Mobile.License.LicenseResponse> ActivateAsync(
string codeClient, string numeroLicence, CancellationToken ct = default) => throw new NotImplementedException();
public Task<Omika.Core.Contracts.Mobile.License.LicenseResponse> ValidateAsync(CancellationToken ct = default)
=> throw new NotImplementedException();
public Task<LocalBootstrapState?> GetBootstrapAsync(CancellationToken ct = default)
=> Task.FromResult<LocalBootstrapState?>(CollaborateurGuid is { } guid
? new LocalBootstrapState
{
Collaborateur = new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = guid.ToString("D") }
}
: null);
public Task<string?> GetAccessTokenAsync(CancellationToken ct = default) => Task.FromResult<string?>(null);
public Task ClearSessionAsync(CancellationToken ct = default) => Task.CompletedTask;
public Task EnsureDevelopmentSessionAsync(CancellationToken ct = default) => Task.CompletedTask;
}
private sealed class FakeSuiviTempsService : ISuiviTempsLocalService
{
public IReadOnlyList<AgendaItem> RendezVousAvecBrouillon { get; set; } = [];
public Task<Guid> CreateAsync(SuiviTempsSyncPayload payload, CancellationToken ct = default) => Task.FromResult(Guid.NewGuid());
public Task<SuiviTempsDto?> GetByGuidAsync(Guid suiviTempsGuid, CancellationToken ct = default) => Task.FromResult<SuiviTempsDto?>(null);
public Task<IReadOnlyList<SuiviTempsDto>> GetAllAsync(CancellationToken ct = default) => Task.FromResult<IReadOnlyList<SuiviTempsDto>>([]);
public Task<IReadOnlyList<SuiviTempsDto>> GetByInterventionAsync(Guid interventionGuid, CancellationToken ct = default) => Task.FromResult<IReadOnlyList<SuiviTempsDto>>([]);
public Task<IReadOnlyList<SuiviTempsDto>> GetByRendezVousAsync(Guid rendezVousGuid, CancellationToken ct = default) => Task.FromResult<IReadOnlyList<SuiviTempsDto>>([]);
public Task<IReadOnlyList<SuiviTempsDraftEntry>> GetDraftsByRendezVousAsync(Guid rendezVousGuid, CancellationToken ct = default) => Task.FromResult<IReadOnlyList<SuiviTempsDraftEntry>>([]);
public Task CreateDraftAsync(SuiviTempsDraftEntry draft, CancellationToken ct = default) => Task.CompletedTask;
public Task DeleteDraftAsync(Guid rendezVousGuid, string typeTempsKey, CancellationToken ct = default) => Task.CompletedTask;
public Task<bool> HasTypeTempsAsync(Guid rendezVousGuid, string typeTempsKey, CancellationToken ct = default) => Task.FromResult(false);
public Task<bool> IsValideAsync(Guid rendezVousGuid, CancellationToken ct = default) => Task.FromResult(false);
public Task ValidateRendezVousAsync(Guid rendezVousGuid, CancellationToken ct = default) => Task.CompletedTask;
public Task SaveRendezVousPourBrouillonAsync(AgendaItem rendezVous, CancellationToken ct = default) => Task.CompletedTask;
public Task<IReadOnlyList<AgendaItem>> GetRendezVousAvecBrouillonAsync(CancellationToken ct = default) => Task.FromResult(RendezVousAvecBrouillon);
public Task DeleteRendezVousPourBrouillonAsync(Guid rendezVousId, CancellationToken ct = default) => Task.CompletedTask;
}
[Fact]
public async Task Load_JourAvecRendezVous_PeutLaCollection()
{
// Arrange
DateTime jour = new(2026, 8, 12);
await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), jour.AddHours(9), jour.AddHours(10), "RDV 1"));
await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), jour.AddHours(11), jour.AddHours(12), "RDV 2"));
// Date déterministe : indépendante de la date système.
_viewModel.SelectedAgendaDateOffset = jour;
// Act
await _viewModel.LoadCommand.ExecuteAsync(null);
// Assert
Assert.False(_viewModel.IsLoading);
Assert.Null(_viewModel.ErrorMessage);
Assert.True(_viewModel.HasRendezVous);
Assert.Equal(2, _viewModel.RendezVous.Count);
}
[Fact]
public async Task Load_JourSansRendezVous_EtatVide()
{
// Act
await _viewModel.LoadCommand.ExecuteAsync(null);
// Assert
Assert.False(_viewModel.HasRendezVous);
Assert.Empty(_viewModel.RendezVous);
Assert.Null(_viewModel.ErrorMessage);
}
[Fact]
public async Task Load_RendezVousPlusieursJours_VisibleSurChaqueJourDeLIntervalle()
{
// Arrange : un rendez-vous groupé du 12/08 08:00 au 14/08 17:00.
Guid rdvId = Guid.NewGuid();
DateTime premierJour = new(2026, 8, 12);
DateTime dernierJour = new(2026, 8, 14);
await RendezVousRepository.UpsertAsync(
CreateRendezVous(rdvId, premierJour.AddHours(8), dernierJour.AddHours(17), "Chantier 3 jours"));
// Assert : présent sur CHAQUE journée couverte…
foreach (DateTime jour in new[] { premierJour, premierJour.AddDays(1), dernierJour })
{
_viewModel.SelectedAgendaDateOffset = jour;
await _viewModel.LoadCommand.ExecuteAsync(null);
Assert.True(_viewModel.HasRendezVous);
Assert.Contains(_viewModel.RendezVous, r => r.RendezVousGuid == rdvId);
}
// … et absent de la journée qui précède son début.
_viewModel.SelectedAgendaDateOffset = premierJour.AddDays(-1);
await _viewModel.LoadCommand.ExecuteAsync(null);
Assert.Empty(_viewModel.RendezVous);
}
[Fact]
public async Task Load_HorsFenetre_ConserveLeRendezVousAvecBrouillon()
{
// Arrange : un RDV conservé (porteur de brouillon non envoyé) sur une date
// ancienne, hors snapshot local.
Guid rdvId = Guid.NewGuid();
DateTime jour = new(2024, 7, 1);
_suiviTempsService.RendezVousAvecBrouillon =
[
new AgendaItem
{
RendezVousGuid = rdvId,
Titre = "RDV conservé",
Debut = jour.AddHours(9),
Fin = jour.AddHours(10)
}
];
// Act : ouvre l'agenda sur cette date.
_viewModel.SelectedAgendaDateOffset = jour;
await _viewModel.LoadCommand.ExecuteAsync(null);
// Assert : le RDV conservé est bien ré-affiché.
Assert.True(_viewModel.HasRendezVous);
Assert.Contains(_viewModel.RendezVous, r => r.RendezVousGuid == rdvId);
}
[Fact]
public async Task Load_DateHistoriqueHorsSnapshot_RepliApiAvecResultat()
{
// Arrange : un rendez-vous historique n'existe que via l'API (pas en local).
Guid collaborateurGuid = Guid.NewGuid();
Guid rdvId = Guid.NewGuid();
DateTime jour = new(2024, 7, 1);
var api = new HistoricalRendezVousApiClient(rdvId, jour);
IAgendaService agendaService = new AgendaService(
RendezVousRepository, ClientRepository, SiteRepository, InterventionRepository, CollaborateurRepository, api, SessionService);
var session = new FakeSessionService { CollaborateurGuid = collaborateurGuid };
var vm = new AgendaViewModel(agendaService, session, _suiviTempsService, _parametreMetierService, _navigation, new PermissionService(), new RendezVousStateNotifier(), Network);
// Act
vm.SelectedAgendaDateOffset = jour;
await vm.LoadCommand.ExecuteAsync(null);
// Assert : le repli API a ramené le rendez-vous historique.
Assert.True(vm.HasRendezVous);
Assert.Single(vm.RendezVous);
Assert.Equal(rdvId, vm.RendezVous[0].RendezVousGuid);
}
[Fact]
public async Task Load_DateHistorique_BrouillonNeMasquePasLeRepliApi()
{
// Arrange : aucun rendez-vous local (hors snapshot), mais :
// - une référence de brouillon de saisie de temps (donnée PÉRIMÉE) ;
// - l'API renvoie la liste complète et À JOUR pour cette date.
Guid collaborateurGuid = Guid.NewGuid();
Guid draftGuid = Guid.NewGuid();
Guid autreGuid = Guid.NewGuid();
DateTime jour = new(2024, 7, 1);
_suiviTempsService.RendezVousAvecBrouillon =
[
new AgendaItem
{
RendezVousGuid = draftGuid,
Titre = "TITRE PÉRIMÉ",
Debut = jour.AddHours(9),
Fin = jour.AddHours(10)
}
];
var api = new MultiRendezVousApiClient(
[
new RendezVousDetailsDto
{
Id = draftGuid.ToString(),
Titre = "TITRE À JOUR",
Debut = jour.AddHours(9),
Fin = jour.AddHours(10),
Statut = new ReferenceValueDto { Label = "Confirmé" }
},
new RendezVousDetailsDto
{
Id = autreGuid.ToString(),
Titre = "Autre RDV",
Debut = jour.AddHours(11),
Fin = jour.AddHours(12),
Statut = new ReferenceValueDto { Label = "Confirmé" }
}
]);
IAgendaService agendaService = new AgendaService(
RendezVousRepository, ClientRepository, SiteRepository, InterventionRepository, CollaborateurRepository, api, SessionService);
var session = new FakeSessionService { CollaborateurGuid = collaborateurGuid };
var vm = new AgendaViewModel(agendaService, session, _suiviTempsService, _parametreMetierService, _navigation, new PermissionService(), new RendezVousStateNotifier(), Network);
// Act
vm.SelectedAgendaDateOffset = jour;
await vm.LoadCommand.ExecuteAsync(null);
// Assert : la liste complète est affichée (2 RDV) et la donnée fraîche
// prime sur l'instantané de brouillon (pas de titre périmé).
Assert.Equal(2, vm.RendezVous.Count);
AgendaItem draft = Assert.Single(vm.RendezVous, r => r.RendezVousGuid == draftGuid);
Assert.Equal("TITRE À JOUR", draft.Titre);
Assert.Contains(vm.RendezVous, r => r.RendezVousGuid == autreGuid);
}
[Fact]
public async Task Load_CleMetierAbsente_UtiliseLeLibelleBatik()
{
// Arrange : schéma Batik — la clé canonique (Key) est vide et la clé
// métier est portée par le libellé (PA_Libelle). Le mapping doit retomber
// sur le libellé pour que la résolution des couleurs type/statut puisse
// retrouver le paramètre métier.
Guid rdvId = Guid.NewGuid();
DateTime jour = new(2026, 8, 12);
var dto = new RendezVousDetailsDto
{
Id = rdvId.ToString(),
Titre = "RDV Batik",
Debut = jour.AddHours(9),
Fin = jour.AddHours(10),
TypeActivite = new ReferenceValueDto { Key = "", Label = "Intervention" },
Statut = new ReferenceValueDto { Key = "", Label = "Fait" }
};
await RendezVousRepository.UpsertAsync(new LocalRendezVous
{
Id = rdvId,
PayloadJson = JsonSerializer.Serialize(dto),
UpdatedAtUtc = DateTimeOffset.UtcNow
});
_parametreMetierService.TypeRdvParametres = new TypeRdvParametresDto { Couleur = "1393623" };
_parametreMetierService.StatutRdvParametres = new StatutRdvParametresDto { Couleur = "5288012" };
_viewModel.SelectedAgendaDateOffset = jour;
// Act
await _viewModel.LoadCommand.ExecuteAsync(null);
// Assert : la clé métier effective retombe sur le libellé Batik, ce qui
// permet ensuite de résoudre les couleurs type/statut.
AgendaItem item = Assert.Single(_viewModel.RendezVous);
Assert.Equal("Intervention", item.TypeActiviteKey);
Assert.Equal("Fait", item.StatutKey);
Assert.Equal("#1543D7", item.Couleur);
Assert.Equal("#50B04C", item.StatutCouleur);
}
private sealed class HistoricalRendezVousApiClient : StubMobileApiClient
{
private readonly Guid _rdvId;
private readonly DateTime _debut;
public HistoricalRendezVousApiClient(Guid rdvId, DateTime debut)
{
_rdvId = rdvId;
_debut = debut;
}
public override Task<IReadOnlyList<RendezVousDetailsDto>> GetRendezVousByCollaborateurAsync(
Guid collaborateurGuid,
DateTime debutMin,
DateTime debutMax,
CancellationToken cancellationToken = default)
{
return Task.FromResult<IReadOnlyList<RendezVousDetailsDto>>(
[
new RendezVousDetailsDto
{
Id = _rdvId.ToString(),
Titre = "RDV historique",
Debut = _debut.AddHours(9),
Fin = _debut.AddHours(10),
Statut = new ReferenceValueDto { Label = "Confirmé" }
}
]);
}
}
private sealed class MultiRendezVousApiClient : StubMobileApiClient
{
private readonly IReadOnlyList<RendezVousDetailsDto> _rdvs;
public MultiRendezVousApiClient(IReadOnlyList<RendezVousDetailsDto> rdvs) => _rdvs = rdvs;
public override Task<IReadOnlyList<RendezVousDetailsDto>> GetRendezVousByCollaborateurAsync(
Guid collaborateurGuid,
DateTime debutMin,
DateTime debutMax,
CancellationToken cancellationToken = default)
=> Task.FromResult(_rdvs);
}
[Fact]
public async Task PreviousDay_ChangeLaDateEtRecharge()
{
// Arrange
DateTime jour = new(2026, 8, 12);
await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), jour.AddHours(9), jour.AddHours(10), "RDV"));
// Date déterministe : indépendante de la date système.
_viewModel.SelectedAgendaDateOffset = jour;
// Act
await _viewModel.LoadCommand.ExecuteAsync(null);
await _viewModel.PreviousDayCommand.ExecuteAsync(null);
// Assert : la date a reculé d'un jour et la liste est vide (pas de RDV la veille).
Assert.Equal(jour.AddDays(-1), _viewModel.SelectedDate);
Assert.False(_viewModel.HasRendezVous);
}
[Fact]
public async Task NextDay_ChangeLaDateEtRecharge()
{
// Arrange
DateTime jour = new(2026, 8, 12);
await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), jour.AddHours(9), jour.AddHours(10), "RDV"));
// Date déterministe : indépendante de la date système.
_viewModel.SelectedAgendaDateOffset = jour;
// Act
await _viewModel.LoadCommand.ExecuteAsync(null);
await _viewModel.NextDayCommand.ExecuteAsync(null);
// Assert : la date a avancé d'un jour et la liste est vide.
Assert.Equal(jour.AddDays(1), _viewModel.SelectedDate);
Assert.False(_viewModel.HasRendezVous);
}
[Fact]
public async Task GoToToday_RevientAAujourdhui()
{
// Arrange
DateTime jour = new(2026, 8, 12);
await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), jour.AddHours(9), jour.AddHours(10), "RDV"));
// Act
await _viewModel.LoadCommand.ExecuteAsync(null);
await _viewModel.NextDayCommand.ExecuteAsync(null);
await _viewModel.GoToTodayCommand.ExecuteAsync(null);
// Assert : retour à aujourd'hui.
Assert.Equal(DateTime.Today, _viewModel.SelectedDate);
}
[Fact]
public async Task SelectedDateLabel_FormatFrancais()
{
// Arrange
DateTime jour = new(2026, 8, 12);
await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), jour.AddHours(9), jour.AddHours(10), "RDV"));
// Date déterministe : indépendante de la date système.
_viewModel.SelectedAgendaDateOffset = jour;
// Act
await _viewModel.LoadCommand.ExecuteAsync(null);
// Assert : le libellé contient le jour et le mois.
Assert.Contains("12", _viewModel.SelectedDateLabel);
Assert.Contains("août", _viewModel.SelectedDateLabel);
}
[Fact]
public async Task OpenRendezVous_NavigueVersDetail()
{
// Arrange
Guid rdvId = Guid.NewGuid();
DateTime jour = new(2026, 8, 12);
await RendezVousRepository.UpsertAsync(CreateRendezVous(rdvId, jour.AddHours(9), jour.AddHours(10), "RDV"));
// Date déterministe : indépendante de la date système.
_viewModel.SelectedAgendaDateOffset = jour;
await _viewModel.LoadCommand.ExecuteAsync(null);
// Act
AgendaItem item = _viewModel.RendezVous[0];
_viewModel.OpenRendezVousCommand.Execute(item);
// Assert : la fiche reçoit l'élément complet (déjà enrichi).
Assert.Equal(AppPage.RendezVousDetail, _navigation.CurrentPage);
Assert.Same(item, _navigation.CurrentParameter);
}
[Fact]
public async Task OpenRendezVous_Null_NeNaviguePas()
{
// Act
_viewModel.OpenRendezVousCommand.Execute(null);
// Assert : pas de navigation.
Assert.Equal(AppPage.Home, _navigation.CurrentPage);
}
[Fact]
public void Days_Contient7Jours_UnSeulSelectionne()
{
// Arrange : date déterministe (mercredi).
_viewModel.SelectedAgendaDateOffset = new DateTime(2026, 8, 12);
// Assert : 7 jours et un seul sélectionné (la date sélectionnée).
Assert.Equal(7, _viewModel.Days.Count);
Assert.Single(_viewModel.Days, day => day.IsSelected);
Assert.Equal(new DateTime(2026, 8, 12), _viewModel.Days.Single(day => day.IsSelected).Date);
}
[Fact]
public async Task SelectDay_ChangeLaDateEtRecharge()
{
// Arrange : un rendez-vous le 13 août.
DateTime jour = new(2026, 8, 13);
await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), jour.AddHours(9), jour.AddHours(10), "RDV"));
_viewModel.SelectedAgendaDateOffset = new DateTime(2026, 8, 12);
await _viewModel.LoadCommand.ExecuteAsync(null);
Assert.False(_viewModel.HasRendezVous);
AgendaDayDisplayItem target = _viewModel.Days.Single(day => day.Date == jour);
// Act
await _viewModel.SelectDayCommand.ExecuteAsync(target);
// Assert : date changée et rendez-vous rechargé.
Assert.Equal(jour, _viewModel.SelectedDate);
Assert.True(_viewModel.HasRendezVous);
Assert.Single(_viewModel.RendezVous);
}
[Fact]
public async Task Compteurs_DerivesDesRendezVousReels()
{
// Arrange : 1 lié + 1 autonome.
DateTime jour = new(2026, 8, 12);
await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), jour.AddHours(9), jour.AddHours(10), "Lié", Guid.NewGuid().ToString()));
await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), jour.AddHours(11), jour.AddHours(12), "Autonome"));
_viewModel.SelectedAgendaDateOffset = jour;
await _viewModel.LoadCommand.ExecuteAsync(null);
// Assert : compteurs dérivés de la collection réelle.
Assert.Equal(2, _viewModel.AppointmentCount);
Assert.Equal(1, _viewModel.LinkedCount);
Assert.Equal(1, _viewModel.StandaloneCount);
}
[Fact]
public async Task OpenFirstRendezVous_NavigueVersDetail()
{
// Arrange
Guid first = Guid.NewGuid();
DateTime jour = new(2026, 8, 12);
await RendezVousRepository.UpsertAsync(CreateRendezVous(first, jour.AddHours(9), jour.AddHours(10), "Premier"));
await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), jour.AddHours(11), jour.AddHours(12), "Second"));
_viewModel.SelectedAgendaDateOffset = jour;
await _viewModel.LoadCommand.ExecuteAsync(null);
// Act
_viewModel.OpenFirstRendezVousCommand.Execute(null);
// Assert : navigation vers le détail du premier rendez-vous (élément complet).
Assert.Equal(AppPage.RendezVousDetail, _navigation.CurrentPage);
Assert.Same(_viewModel.RendezVous[0], _navigation.CurrentParameter);
}
[Fact]
public void VueInitiale_EstJour()
{
Assert.Equal(AgendaViewMode.Day, _viewModel.SelectedViewMode);
Assert.True(_viewModel.IsDayMode);
Assert.False(_viewModel.IsWeekMode);
}
[Fact]
public void ShowWeek_PasseEnModeSemaine()
{
// Act
_viewModel.ShowWeekCommand.Execute(null);
// Assert
Assert.Equal(AgendaViewMode.Week, _viewModel.SelectedViewMode);
Assert.True(_viewModel.IsWeekMode);
Assert.False(_viewModel.IsDayMode);
}
[Fact]
public void ShowDay_RetourAuModeJour()
{
// Arrange
_viewModel.ShowWeekCommand.Execute(null);
// Act
_viewModel.ShowDayCommand.Execute(null);
// Assert
Assert.Equal(AgendaViewMode.Day, _viewModel.SelectedViewMode);
Assert.True(_viewModel.IsDayMode);
}
[Fact]
public async Task Previous_EnModeJour_ReculeDUnJour()
{
// Arrange
_viewModel.SelectedAgendaDateOffset = new DateTime(2026, 8, 12);
await _viewModel.LoadCommand.ExecuteAsync(null);
// Act
await _viewModel.PreviousDayCommand.ExecuteAsync(null);
// Assert
Assert.Equal(new DateTime(2026, 8, 11), _viewModel.SelectedDate);
}
[Fact]
public async Task Next_EnModeJour_AvanceDUnJour()
{
// Arrange
_viewModel.SelectedAgendaDateOffset = new DateTime(2026, 8, 12);
await _viewModel.LoadCommand.ExecuteAsync(null);
// Act
await _viewModel.NextDayCommand.ExecuteAsync(null);
// Assert
Assert.Equal(new DateTime(2026, 8, 13), _viewModel.SelectedDate);
}
[Fact]
public async Task Previous_EnModeSemaine_ReculeDeSeptJours()
{
// Arrange
_viewModel.SelectedAgendaDateOffset = new DateTime(2026, 8, 12);
_viewModel.ShowWeekCommand.Execute(null);
await _viewModel.LoadCommand.ExecuteAsync(null);
// Act
await _viewModel.PreviousDayCommand.ExecuteAsync(null);
// Assert
Assert.Equal(new DateTime(2026, 8, 5), _viewModel.SelectedDate);
}
[Fact]
public async Task Next_EnModeSemaine_AvanceDeSeptJours()
{
// Arrange
_viewModel.SelectedAgendaDateOffset = new DateTime(2026, 8, 12);
_viewModel.ShowWeekCommand.Execute(null);
await _viewModel.LoadCommand.ExecuteAsync(null);
// Act
await _viewModel.NextDayCommand.ExecuteAsync(null);
// Assert
Assert.Equal(new DateTime(2026, 8, 19), _viewModel.SelectedDate);
}
[Fact]
public void DatePicker_SelectedAgendaDateOffset_SynchroniseSelectedDate()
{
// Act : choix d'une date dans le calendrier.
_viewModel.SelectedAgendaDateOffset = new DateTime(2026, 8, 13);
// Assert : SelectedDate est synchronisée immédiatement.
Assert.Equal(new DateTime(2026, 8, 13), _viewModel.SelectedDate);
}
[Fact]
public async Task CompteurParJour_DeriveDeLaSemaineChargee()
{
// Arrange : 3 rendez-vous le lundi, 1 le mardi. 2026-08-10 est un lundi.
DateTime lundi = new(2026, 8, 10);
for (int i = 0; i < 3; i++)
{
await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), lundi.AddHours(8 + i), lundi.AddHours(9 + i), $"Lundi {i}"));
}
await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), lundi.AddDays(1).AddHours(10), lundi.AddDays(1).AddHours(11), "Mardi"));
_viewModel.SelectedAgendaDateOffset = new DateTime(2026, 8, 12);
await _viewModel.LoadCommand.ExecuteAsync(null);
// Assert : compteurs réels par jour.
AgendaDayDisplayItem lundiItem = _viewModel.Days.Single(d => d.Date == lundi);
AgendaDayDisplayItem mardiItem = _viewModel.Days.Single(d => d.Date == lundi.AddDays(1));
Assert.Equal(3, lundiItem.AppointmentCount);
Assert.Equal("3 rdv", lundiItem.CountLabel);
Assert.True(lundiItem.HasAppointments);
Assert.Equal(1, mardiItem.AppointmentCount);
Assert.Equal("1 rdv", mardiItem.CountLabel);
Assert.Equal("—", _viewModel.Days.Single(d => d.Date == lundi.AddDays(2)).CountLabel);
}
[Fact]
public async Task WeekAppointmentCount_TotalDeLaSemaine()
{
// Arrange : 2 rendez-vous dans la semaine du 2026-08-10.
DateTime lundi = new(2026, 8, 10);
await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), lundi.AddHours(9), lundi.AddHours(10), "A"));
await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), lundi.AddDays(2).AddHours(9), lundi.AddDays(2).AddHours(10), "B"));
_viewModel.SelectedAgendaDateOffset = new DateTime(2026, 8, 12);
await _viewModel.LoadCommand.ExecuteAsync(null);
// Assert : total réel de la semaine chargée.
Assert.Equal(2, _viewModel.WeekAppointmentCount);
}
[Fact]
public async Task WeekGroups_SeptSectionsReelles_JourVideCompact()
{
// Arrange : 1 rendez-vous le mercredi 2026-08-12.
DateTime jour = new(2026, 8, 12);
await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), jour.AddHours(9), jour.AddHours(10), "RDV"));
_viewModel.SelectedAgendaDateOffset = jour;
await _viewModel.LoadCommand.ExecuteAsync(null);
// Assert : 7 sections, une seule avec des rendez-vous.
Assert.Equal(7, _viewModel.WeekGroups.Count);
Assert.Single(_viewModel.WeekGroups, g => g.HasAppointments);
Assert.Equal(1, _viewModel.WeekGroups.Single(g => g.HasAppointments).Items.Count);
Assert.Equal(6, _viewModel.WeekGroups.Count(g => !g.HasAppointments));
Assert.All(_viewModel.WeekGroups.Where(g => !g.HasAppointments), g => Assert.Empty(g.Items));
}
[Fact]
public void Days_SemaineCommenceLundi_SeTermineDimanche()
{
// Arrange : mercredi 2026-08-12.
_viewModel.SelectedAgendaDateOffset = new DateTime(2026, 8, 12);
// Assert : lundi 2026-08-10 → dimanche 2026-08-16.
Assert.Equal(new DateTime(2026, 8, 10), _viewModel.Days[0].Date);
Assert.Equal(new DateTime(2026, 8, 16), _viewModel.Days[6].Date);
}
[Fact]
public async Task SelectDay_RechargeLaJourneeSelectionnee()
{
// Arrange : un rendez-vous le 13 août.
DateTime jour = new(2026, 8, 13);
await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), jour.AddHours(9), jour.AddHours(10), "RDV"));
_viewModel.SelectedAgendaDateOffset = new DateTime(2026, 8, 12);
await _viewModel.LoadCommand.ExecuteAsync(null);
Assert.False(_viewModel.HasRendezVous);
AgendaDayDisplayItem target = _viewModel.Days.Single(day => day.Date == jour);
// Act
await _viewModel.SelectDayCommand.ExecuteAsync(target);
// Assert
Assert.Equal(jour, _viewModel.SelectedDate);
Assert.True(_viewModel.HasRendezVous);
Assert.Single(_viewModel.RendezVous);
}
[Fact]
public async Task ShowCalendar_PasseEnModeCalendrier()
{
// Act
await _viewModel.ShowCalendarCommand.ExecuteAsync(null);
// Assert
Assert.Equal(AgendaViewMode.Calendar, _viewModel.SelectedViewMode);
Assert.True(_viewModel.IsCalendarMode);
Assert.False(_viewModel.IsDayMode);
Assert.False(_viewModel.IsWeekMode);
}
[Fact]
public async Task MonthDays_Contient42Jours_CommenceLundi()
{
// Arrange : août 2026 commence un samedi 1er août.
_viewModel.SelectedAgendaDateOffset = new DateTime(2026, 8, 12);
// Act
await _viewModel.ShowCalendarCommand.ExecuteAsync(null);
// Assert : 42 cellules, la première est le lundi 27 juillet 2026.
Assert.Equal(42, _viewModel.MonthDays.Count);
Assert.Equal(new DateTime(2026, 7, 27), _viewModel.MonthDays[0].Date);
Assert.Equal(DayOfWeek.Monday, _viewModel.MonthDays[0].Date.DayOfWeek);
}
[Fact]
public async Task MonthDays_ContientLesRendezVousDuJour()
{
// Arrange
DateTime jour = new(2026, 8, 12);
await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), jour.AddHours(9), jour.AddHours(10), "RDV"));
_viewModel.SelectedAgendaDateOffset = jour;
// Act
await _viewModel.ShowCalendarCommand.ExecuteAsync(null);
// Assert : la cellule du jour porte le rendez-vous.
AgendaMonthDayItem cell = _viewModel.MonthDays.Single(d => d.Date == jour);
Assert.True(cell.IsCurrentMonth);
Assert.True(cell.HasAppointments);
Assert.Single(cell.Appointments);
}
[Fact]
public async Task PeriodLabel_EnModeCalendrier_AfficheLeMois()
{
// Arrange
_viewModel.SelectedAgendaDateOffset = new DateTime(2026, 8, 12);
// Act
await _viewModel.ShowCalendarCommand.ExecuteAsync(null);
// Assert : libellé du mois en français.
Assert.Contains("août", _viewModel.PeriodLabel);
Assert.Contains("2026", _viewModel.PeriodLabel);
}
[Fact]
public async Task Next_EnModeCalendrier_AvanceDUnMois()
{
// Arrange
_viewModel.SelectedAgendaDateOffset = new DateTime(2026, 8, 12);
await _viewModel.ShowCalendarCommand.ExecuteAsync(null);
// Act
await _viewModel.NextDayCommand.ExecuteAsync(null);
// Assert
Assert.Equal(new DateTime(2026, 9, 12), _viewModel.SelectedDate);
}
[Fact]
public async Task SelectCalendarDay_OuvreLaVueJour()
{
// Arrange
_viewModel.SelectedAgendaDateOffset = new DateTime(2026, 8, 12);
await _viewModel.ShowCalendarCommand.ExecuteAsync(null);
AgendaMonthDayItem target = _viewModel.MonthDays.Single(d => d.Date == new DateTime(2026, 8, 13));
// Act
_viewModel.SelectCalendarDayCommand.Execute(target);
// Assert : bascule en vue Jour avec la date sélectionnée.
Assert.Equal(AgendaViewMode.Day, _viewModel.SelectedViewMode);
Assert.Equal(new DateTime(2026, 8, 13), _viewModel.SelectedDate);
}
private AgendaViewModel CreateViewModelWithFakeAgenda(FakeThrowingAgendaService agenda)
=> new(agenda, _sessionService, _suiviTempsService, _parametreMetierService, _navigation, new PermissionService(), new RendezVousStateNotifier(), Network);
[Fact]
public async Task Load_AnnulationNormale_TokenAnnule_MasqueLErreur()
{
var fake = new FakeThrowingAgendaService();
AgendaViewModel vm = CreateViewModelWithFakeAgenda(fake);
fake.ExceptionToThrow = new OperationCanceledException();
using var cts = new CancellationTokenSource();
cts.Cancel();
await vm.RefreshAsync(cts.Token);
Assert.False(vm.IsLoading);
Assert.Null(vm.ErrorMessage);
}
[Fact]
public async Task Load_AnnulationSousIOException_TokenAnnule_MasqueLErreur()
{
var fake = new FakeThrowingAgendaService();
AgendaViewModel vm = CreateViewModelWithFakeAgenda(fake);
fake.ExceptionToThrow = new IOException("Canceled");
using var cts = new CancellationTokenSource();
cts.Cancel();
await vm.RefreshAsync(cts.Token);
Assert.False(vm.IsLoading);
Assert.Null(vm.ErrorMessage);
}
/// <summary>
/// Une vraie erreur réseau (token NON annulé) est un état hors ligne, pas un
/// incident : l'agenda reste consultable, sans message d'erreur bloquant, et
/// l'événement est journalisé à sa vraie gravité (Warning → P2), jamais en P1.
/// </summary>
[Fact]
public async Task Load_IOExceptionReelle_TokenNonAnnule_SignaleHorsLigne()
{
var fake = new FakeThrowingAgendaService();
AgendaViewModel vm = CreateViewModelWithFakeAgenda(fake);
fake.ExceptionToThrow = new IOException("Network unreachable");
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, null);
try
{
await vm.RefreshAsync(CancellationToken.None);
}
finally
{
ExceptionTelemetry.Initialize(null, null);
}
Assert.Null(vm.ErrorMessage);
Assert.True(vm.IsOffline);
AssertAucunIncident(telemetry);
Assert.Equal(TelemetryLevel.Warning, EvenementAgenda(telemetry).Level);
Assert.Equal("IOException", EvenementAgenda(telemetry).ErrorCode);
}
/// <summary>
/// Le repli API de l'agenda (plage locale vide) qui échoue pour cause de réseau
/// reproduit le payload de production « Connection failure » : aucun message
/// bloquant, état hors ligne, et un événement en Warning — surtout pas un P1.
/// </summary>
[Fact]
public async Task Load_RepliApiEnEchecReseau_SignaleHorsLigneSansIncident()
{
_sessionService.CollaborateurGuid = Guid.NewGuid();
var fake = new FakeThrowingAgendaService
{
Items = [],
LeverSeulementSurRepliApi = true,
ExceptionToThrow = new HttpRequestException("Connection failure")
};
AgendaViewModel vm = CreateViewModelWithFakeAgenda(fake);
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, null);
try
{
await vm.RefreshAsync(CancellationToken.None);
}
finally
{
ExceptionTelemetry.Initialize(null, null);
}
Assert.True(vm.IsOffline);
Assert.Null(vm.ErrorMessage);
// L'événement est bien journalisé (l'indisponibilité reste observable),
// mais en Warning : aucune entrée en Error/P1.
AssertAucunIncident(telemetry);
DiagnosticEvent evt = EvenementAgenda(telemetry);
Assert.Equal(TelemetryLevel.Warning, evt.Level);
Assert.Equal("HttpRequestException", evt.ErrorCode);
Assert.NotNull(evt.Message);
Assert.StartsWith("Connection failure", evt.Message);
}
/// <summary>
/// Ticket de production du 09/10 : le repli API a révélé qu'aucune URL d'API
/// n'était configurée (appareil non inscrit) — l'appareil pouvait être en ligne.
/// Cet état est actionnable : message « rescannez le QR » (l'utilisateur sait quoi
/// faire), PAS de bandeau « hors ligne » (ce serait faux) et aucun incident P1.
/// </summary>
[Fact]
public async Task Load_RepliApiSansUrlDApi_SignaleLInscriptionIncompleteSansIncident()
{
_sessionService.CollaborateurGuid = Guid.NewGuid();
var fake = new FakeThrowingAgendaService
{
Items = [],
LeverSeulementSurRepliApi = true,
ExceptionToThrow = new MobileEnrollmentRequiredException()
};
AgendaViewModel vm = CreateViewModelWithFakeAgenda(fake);
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, null);
try
{
await vm.RefreshAsync(CancellationToken.None);
}
finally
{
ExceptionTelemetry.Initialize(null, null);
}
Assert.False(vm.IsOffline);
Assert.Equal(
"Inscription incomplète — rescannez le QR code pour reconnecter l'application.",
vm.ErrorMessage);
AssertAucunIncident(telemetry);
DiagnosticEvent evt = EvenementAgenda(telemetry);
Assert.Equal(TelemetryLevel.Warning, evt.Level);
Assert.Equal("MobileEnrollmentRequiredException", evt.ErrorCode);
}
/// <summary>
/// Deux déclenchements d'interface rapprochés (cas réel : deux <c>LOAD</c> à 43 ms
/// d'intervalle) ne doivent produire qu'UNE requête de repli API : le second
/// chargement attend et réutilise l'appel déjà en cours au lieu d'en émettre un
/// doublon (double consommation réseau et double latence).
/// </summary>
[Fact]
public async Task Load_DeuxChargementsConcurrents_UnSeulAppelAuRepliApi()
{
_sessionService.CollaborateurGuid = Guid.NewGuid();
var fake = new FakeThrowingAgendaService
{
Items = [],
BlocageRepli = new TaskCompletionSource()
};
AgendaViewModel vm = CreateViewModelWithFakeAgenda(fake);
// Act : deux déclenchements non annulables, en rafale. Le premier suspend le
// repli API sur le blocage, le second doit trouver l'appel en cours.
vm.Refresh();
vm.Refresh();
Assert.Equal(1, fake.RepliApiCalls);
// Libération : les deux chargements s'achèvent, sans appel supplémentaire.
fake.BlocageRepli.SetResult();
await AttendreAsync(() => !vm.IsLoading);
Assert.Equal(1, fake.RepliApiCalls);
// Le repli a réussi (plage vide, aucune exception) : état neutre, pas hors ligne.
Assert.Null(vm.ErrorMessage);
Assert.False(vm.IsOffline);
}
/// <summary>Attente bornée d'une condition (jamais d'attente infinie en test).</summary>
private static async Task AttendreAsync(Func<bool> condition)
{
for (int i = 0; i < 200 && !condition(); i++)
{
await Task.Delay(10);
}
Assert.True(condition(), "Condition non satisfaite dans le délai imparti.");
}
/// <summary>
/// Contre-épreuve : une erreur métier (hors réseau) reste un incident Error/P1
/// avec son message — le classement préalable ne dilue pas les vraies erreurs.
/// </summary>
[Fact]
public async Task Load_ErreurMetier_ConserveLeMessageEtLIncident()
{
var fake = new FakeThrowingAgendaService
{
ExceptionToThrow = new InvalidOperationException("boom")
};
AgendaViewModel vm = CreateViewModelWithFakeAgenda(fake);
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, null);
try
{
await vm.RefreshAsync(CancellationToken.None);
}
finally
{
ExceptionTelemetry.Initialize(null, null);
}
Assert.Equal("boom", vm.ErrorMessage);
Assert.False(vm.IsOffline);
DiagnosticEvent evt = EvenementAgenda(telemetry);
Assert.Equal(TelemetryLevel.Error, evt.Level);
Assert.Equal("InvalidOperationException", evt.ErrorCode);
}
/// <summary>
/// Seul événement de diagnostic attribué au ViewModel Agenda : la télémétrie
/// d'usage (features) est un <see cref="TelemetryEvent"/>, pas un
/// <see cref="DiagnosticEvent"/>.
/// </summary>
private static DiagnosticEvent EvenementAgenda(CapturingTelemetry telemetry) =>
Assert.Single(
telemetry.Events.OfType<DiagnosticEvent>(),
e => e.Component == nameof(AgendaViewModel));
/// <summary>
/// Aucun événement de diagnostic de l'agenda en Error/Critical : c'est la garantie
/// recherchée (une coupure réseau ne doit plus alimenter les incidents P1). Le
/// filtre porte sur le composant, la télémétrie statique globale étant partagée
/// avec les classes de tests exécutées en parallèle.
/// </summary>
private static void AssertAucunIncident(CapturingTelemetry telemetry) =>
Assert.DoesNotContain(
telemetry.Events.OfType<DiagnosticEvent>(),
e => e.Component == nameof(AgendaViewModel) && e.Level == TelemetryLevel.Error);
[Fact]
public async Task Load_TaskCanceledException_TokenNonAnnule_SignaleHorsLigne()
{
// Un timeout réseau (token NON annulé) est désormais traité comme un état
// hors ligne : l'agenda reste consultable, sans message d'erreur bloquant.
var fake = new FakeThrowingAgendaService();
AgendaViewModel vm = CreateViewModelWithFakeAgenda(fake);
fake.ExceptionToThrow = new TaskCanceledException("Timeout");
await vm.RefreshAsync(CancellationToken.None);
Assert.Null(vm.ErrorMessage);
Assert.True(vm.IsOffline);
}
[Fact]
public async Task Load_Annulation_ConserveLesRendezVousExistants()
{
Guid rdvId = Guid.NewGuid();
DateTime jour = DateTime.Today;
var fake = new FakeThrowingAgendaService
{
Items =
[
new AgendaItem { RendezVousGuid = rdvId, Titre = "RDV", Debut = jour.AddHours(9), Fin = jour.AddHours(10) }
]
};
AgendaViewModel vm = CreateViewModelWithFakeAgenda(fake);
// Premier chargement réussi : le RDV est affiché.
await vm.RefreshAsync(CancellationToken.None);
Assert.True(vm.HasRendezVous);
// Deuxième chargement annulé : les données visibles ne sont pas effacées.
fake.ExceptionToThrow = new IOException("Canceled");
using var cts = new CancellationTokenSource();
cts.Cancel();
await vm.RefreshAsync(cts.Token);
Assert.True(vm.HasRendezVous);
Assert.Contains(vm.RendezVous, r => r.RendezVousGuid == rdvId);
Assert.Null(vm.ErrorMessage);
}
[Fact]
public async Task Load_RechargementApresAnnulation_AfficheLeNouveauContenu()
{
Guid newRdvId = Guid.NewGuid();
DateTime jour = DateTime.Today;
var fake = new FakeThrowingAgendaService();
AgendaViewModel vm = CreateViewModelWithFakeAgenda(fake);
// Premier chargement annulé.
fake.ExceptionToThrow = new IOException("Canceled");
using var cts = new CancellationTokenSource();
cts.Cancel();
await vm.RefreshAsync(cts.Token);
Assert.Null(vm.ErrorMessage);
// Deuxième chargement réussi.
fake.ExceptionToThrow = null;
fake.Items =
[
new AgendaItem { RendezVousGuid = newRdvId, Titre = "RDV", Debut = jour.AddHours(9), Fin = jour.AddHours(10) }
];
await vm.RefreshAsync(CancellationToken.None);
Assert.Null(vm.ErrorMessage);
Assert.Contains(vm.RendezVous, r => r.RendezVousGuid == newRdvId);
}
/// <summary>
/// Service d'agenda jetable dont la lecture de plage peut lever une exception
/// configurée (pour simuler l'annulation Android « IOException("Canceled") »,
/// un timeout, une vraie erreur réseau…), sinon renvoie une liste configurée.
/// </summary>
private sealed class FakeThrowingAgendaService : IAgendaService
{
public Exception? ExceptionToThrow { get; set; }
public IReadOnlyList<AgendaItem> Items { get; set; } = [];
/// <summary>
/// Quand vrai, la lecture de plage réussit (et renvoie <see cref="Items"/>) :
/// seule la lecture du repli API lève. Reproduit l'échec réseau survenu
/// pendant le repli (payload de production « Connection failure »), là où
/// l'implémentation par défaut lève dès la première lecture.
/// </summary>
public bool LeverSeulementSurRepliApi { get; set; }
/// <summary>Nombre d'appels au repli API (détection des requêtes en doublon).</summary>
public int RepliApiCalls { get; private set; }
/// <summary>Si posé, le repli API attend cette libération (entrelacement déterministe).</summary>
public TaskCompletionSource? BlocageRepli { get; set; }
public Task<IReadOnlyList<AgendaItem>> GetRendezVousForRangeAsync(
DateTime startInclusive, DateTime endExclusive, CancellationToken cancellationToken = default)
{
if (ExceptionToThrow is { } ex && !LeverSeulementSurRepliApi)
{
throw ex;
}
return Task.FromResult(Items);
}
public async Task<IReadOnlyList<AgendaItem>> GetRendezVousForCollaborateurAsync(
Guid collaborateurGuid, DateTime startInclusive, DateTime endExclusive, CancellationToken cancellationToken = default)
{
RepliApiCalls++;
if (BlocageRepli is { } blocage)
{
await blocage.Task;
}
if (ExceptionToThrow is { } ex)
{
throw ex;
}
return Items;
}
public Task<IReadOnlyList<AgendaItem>> GetRendezVousForDayAsync(DateTime date, CancellationToken cancellationToken = default)
=> Task.FromResult(Items);
public Task<AgendaItem?> GetRendezVousByIdAsync(Guid rendezVousGuid, CancellationToken cancellationToken = default, string? affectationId = null)
=> Task.FromResult<AgendaItem?>(null);
public Task EnrichSiteAccessAsync(AgendaItem item, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task<IReadOnlyList<AgendaItem>> GetRendezVousForCollaborateursAsync(
IReadOnlyList<Guid> collaborateurGuids, DateTime startInclusive, DateTime endExclusive, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<AgendaItem>>([]);
}
/// <summary>
/// Capture en mémoire des événements de la télémétrie statique globale
/// (<see cref="ExceptionTelemetry.Initialize"/>), rétablie à <c>null</c> après
/// chaque test — la classe appartient à la collection « TelemetrieStatique ».
/// </summary>
private sealed class CapturingTelemetry : IOmikaTelemetry
{
public List<OmikaEvent> Events { get; } = [];
public void Track(TelemetryEvent telemetryEvent) => Events.Add(telemetryEvent);
public void Log(DiagnosticEvent diagnosticEvent) => Events.Add(diagnosticEvent);
}
}