1210 lines
47 KiB
C#
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);
|
|
}
|
|
}
|