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.ViewModels;
using Omika.Core.Contracts.Common;
using Omika.Core.Contracts.Interventions;
using Omika.Core.Contracts.Mobile.Sync;
using Omika.Core.Contracts.Organisation;
namespace Omika.Tests.Mobile.Local;
///
/// Tests du ViewModel du formulaire partagé Intervention (modes Create / Edit).
/// Vérifie l'ouverture en mode Create (formulaire vide), la validation qui
/// appelle exclusivement IInterventionLocalService (CreateAsync / UpdateAsync),
/// l'annulation sans mutation, le préremplissage en mode Edit, la protection
/// contre le double clic et le fonctionnement 100 % offline (aucun serveur).
///
public class InterventionEditViewModelTests : LocalTestBase
{
private readonly InterventionEditViewModel _viewModel;
private readonly NavigationService _navigation;
private readonly IInterventionLocalService _interventionService;
public InterventionEditViewModelTests()
{
_interventionService = new InterventionLocalService(
InterventionRepository, ClientRepository, SiteRepository, DossierRepository,
RendezVousRepository, DocumentMetadataRepository, CollaborateurRepository,
OutboxService, ReconciliationService, ApiClient, InterventionCacheRepository, ContextInterventionCacheRepository, Database);
IClientLocalService clientService = new ClientLocalService(
ClientRepository, DossierRepository, ContextInterventionCacheRepository, ClientStateRepository, ApiClient);
ISiteLocalService siteService = new SiteLocalService(
SiteRepository, ClientRepository, PersonneRepository, RendezVousRepository, InterventionRepository,
ContextInterventionCacheRepository, SiteStateRepository, ApiClient);
IDossierLocalService dossierService = new DossierLocalService(
DossierRepository, ClientRepository,
DossierStateRepository, DossierInterventionCacheRepository, ApiClient);
ICollaborateurLocalService collaborateurService = new CollaborateurLocalService(
CollaborateurRepository, RendezVousRepository, ContextInterventionCacheRepository, ApiClient, SessionService);
_navigation = new NavigationService();
_viewModel = new InterventionEditViewModel(
_interventionService,
clientService,
siteService,
dossierService,
collaborateurService,
InterventionRepository,
_navigation);
}
private static LocalIntervention CreateIntervention(
Guid id,
string reference,
string? titre = null,
string? description = null,
DateTime? debut = null,
DateTime? fin = null,
Guid? clientId = null,
Guid? siteId = null,
Guid? dossierId = null,
IReadOnlyList? collaborateurIds = null)
{
var dto = new InterventionDetailsDto
{
Id = id.ToString(),
Reference = reference,
Titre = titre,
Description = description,
DatePrevueDebut = debut,
DatePrevueFin = fin,
ClientId = clientId?.ToString(),
SiteId = siteId?.ToString(),
DossierId = dossierId?.ToString(),
CollaborateurIds = collaborateurIds?.Select(g => g.ToString()).ToList() ?? []
};
return new LocalIntervention
{
Id = id,
ClientId = clientId,
SiteId = siteId,
DossierId = dossierId,
PayloadJson = JsonSerializer.Serialize(dto),
UpdatedAtUtc = DateTimeOffset.UtcNow
};
}
private static LocalClient CreateClient(Guid id, string nom)
{
var dto = new Omika.Core.Contracts.Clients.ClientDetailsDto
{
Id = id.ToString(),
Nom = nom
};
return new LocalClient
{
Id = id,
PayloadJson = JsonSerializer.Serialize(dto),
UpdatedAtUtc = DateTimeOffset.UtcNow
};
}
private static LocalSite CreateSite(Guid id, string nom)
{
var dto = new Omika.Core.Contracts.Sites.SiteDetailsDto
{
Id = id.ToString(),
Nom = nom
};
return new LocalSite
{
Id = id,
PayloadJson = JsonSerializer.Serialize(dto),
UpdatedAtUtc = DateTimeOffset.UtcNow
};
}
private static LocalDossier CreateDossier(Guid id, string reference)
{
var dto = new DossierDetailsDto
{
Id = id.ToString(),
ReferenceMetier = reference
};
return new LocalDossier
{
Id = id,
PayloadJson = JsonSerializer.Serialize(dto),
UpdatedAtUtc = DateTimeOffset.UtcNow
};
}
private static LocalCollaborateur CreateCollaborateur(Guid id, string nom, string prenom)
{
var dto = new CollaborateurDetailsDto
{
Id = id.ToString(),
Nom = nom,
Prenom = prenom
};
return new LocalCollaborateur
{
Id = id,
PayloadJson = JsonSerializer.Serialize(dto),
UpdatedAtUtc = DateTimeOffset.UtcNow
};
}
private static InterventionEditNavigation CreateNavigation(InterventionEditMode mode, Guid guid)
=> new(mode, guid, new NavigationContext(AppPage.Interventions, null));
[Fact]
public async Task OpenCreate_FormulaireVide_ModeCreate()
{
// Act
await _viewModel.InitializeAsync(CreateNavigation(InterventionEditMode.Create, Guid.Empty));
// Assert : mode création, formulaire vide, aucune entité créée.
Assert.True(_viewModel.IsCreateMode);
Assert.False(_viewModel.IsEditMode);
Assert.Null(_viewModel.Reference);
Assert.Null(_viewModel.Titre);
Assert.Null(_viewModel.Description);
Assert.Null(_viewModel.ErrorMessage);
Assert.Empty(await OutboxRepository.GetReadyAsync());
}
[Fact]
public async Task Save_Create_CreeLocalementEtNavigueDetail()
{
// Arrange
await _viewModel.InitializeAsync(CreateNavigation(InterventionEditMode.Create, Guid.Empty));
_viewModel.Reference = "INT-NEW";
_viewModel.Titre = "Nouvelle intervention";
_viewModel.Description = "Description";
// Act
await _viewModel.SaveCommand.ExecuteAsync(null);
// Assert : navigation vers le détail de la nouvelle intervention (GUID public).
Assert.Equal(AppPage.InterventionDetail, _navigation.CurrentPage);
Guid newGuid = Assert.IsType(_navigation.CurrentParameter);
Assert.NotEqual(Guid.Empty, newGuid);
// Assert : une seule entité locale créée avec le GUID public (A).
LocalIntervention? local = await InterventionRepository.GetByIdAsync(newGuid);
Assert.NotNull(local);
var dto = JsonSerializer.Deserialize(local!.PayloadJson);
Assert.Equal("INT-NEW", dto!.Reference);
Assert.Equal("Nouvelle intervention", dto.Titre);
// Assert : une seule opération Outbox CREATE.
IReadOnlyList ready = await OutboxRepository.GetReadyAsync();
var op = Assert.Single(ready);
Assert.Equal(SyncOperationType.Create, op.OperationType);
Assert.Equal(newGuid, op.RequestedEntityGuid);
Assert.Equal("Intervention", op.EntityType);
}
[Fact]
public async Task Save_Create_SansTitre_ErreurValidation()
{
// Arrange
await _viewModel.InitializeAsync(CreateNavigation(InterventionEditMode.Create, Guid.Empty));
// Act : aucun titre renseigné.
await _viewModel.SaveCommand.ExecuteAsync(null);
// Assert : erreur de validation, aucune mutation ni Outbox.
Assert.NotNull(_viewModel.ErrorMessage);
Assert.Empty(await OutboxRepository.GetReadyAsync());
Assert.Equal(AppPage.Home, _navigation.CurrentPage);
}
[Fact]
public async Task Cancel_Create_AucuneMutation_RetourContexte()
{
// Arrange
await _viewModel.InitializeAsync(CreateNavigation(InterventionEditMode.Create, Guid.Empty));
_viewModel.Titre = "Intervention annulée";
// Act
_viewModel.CancelCommand.Execute(null);
// Assert : aucune entité locale ni Outbox créée, retour au contexte.
Assert.Empty(await OutboxRepository.GetReadyAsync());
Assert.Empty(await InterventionRepository.GetAllAsync());
Assert.Equal(AppPage.Interventions, _navigation.CurrentPage);
}
[Fact]
public async Task OpenEdit_DonneesReellesPreRemplies()
{
// Arrange : une intervention locale existante avec relations.
Guid clientId = Guid.NewGuid();
Guid siteId = Guid.NewGuid();
Guid dossierId = Guid.NewGuid();
Guid collaborateurId = Guid.NewGuid();
Guid interventionId = Guid.NewGuid();
await ClientRepository.UpsertAsync(CreateClient(clientId, "Client Alpha"));
await SiteRepository.UpsertAsync(CreateSite(siteId, "Site Beta"));
await DossierRepository.UpsertAsync(CreateDossier(dossierId, "DOS-001"));
await CollaborateurRepository.UpsertAsync(CreateCollaborateur(collaborateurId, "Dupont", "Jean"));
await InterventionRepository.UpsertAsync(CreateIntervention(
interventionId, "INT-001", "Titre existant", "Description existante",
new DateTime(2026, 9, 1, 9, 0, 0), new DateTime(2026, 9, 1, 12, 0, 0),
clientId, siteId, dossierId, [collaborateurId]));
// Act
await _viewModel.InitializeAsync(CreateNavigation(InterventionEditMode.Edit, interventionId));
// Assert : mode édition et champs préremplis avec les vraies données.
Assert.True(_viewModel.IsEditMode);
Assert.False(_viewModel.IsCreateMode);
Assert.Equal("INT-001", _viewModel.Reference);
Assert.Equal("Titre existant", _viewModel.Titre);
Assert.Equal("Description existante", _viewModel.Description);
Assert.Equal(new DateTime(2026, 9, 1, 9, 0, 0), _viewModel.DatePrevueDebut);
Assert.Equal(clientId, _viewModel.SelectedClient!.ClientGuid);
Assert.Equal(siteId, _viewModel.SelectedSite!.SiteGuid);
Assert.Equal(dossierId, _viewModel.SelectedDossier!.DossierGuid);
Assert.Single(_viewModel.SelectedCollaborateurs);
Assert.Equal(collaborateurId, _viewModel.SelectedCollaborateurs[0].CollaborateurGuid);
}
[Fact]
public async Task Save_Edit_ModifieLocalementEtRetourDetail()
{
// Arrange : une intervention locale existante.
Guid interventionId = Guid.NewGuid();
await InterventionRepository.UpsertAsync(CreateIntervention(interventionId, "INT-001", "Avant"));
await _viewModel.InitializeAsync(CreateNavigation(InterventionEditMode.Edit, interventionId));
// Act : modification du titre.
_viewModel.Titre = "Après";
await _viewModel.SaveCommand.ExecuteAsync(null);
// Assert : retour au détail de la même intervention.
Assert.Equal(AppPage.InterventionDetail, _navigation.CurrentPage);
Assert.Equal(interventionId, Assert.IsType(_navigation.CurrentParameter));
// Assert : l'entité locale est mise à jour.
LocalIntervention? local = await InterventionRepository.GetByIdAsync(interventionId);
Assert.NotNull(local);
Assert.Equal("Après", JsonSerializer.Deserialize(local!.PayloadJson)?.Titre);
// Assert : une seule opération Outbox UPDATE.
IReadOnlyList ready = await OutboxRepository.GetReadyAsync();
var op = Assert.Single(ready);
Assert.Equal(SyncOperationType.Update, op.OperationType);
Assert.Equal(interventionId, op.RequestedEntityGuid);
}
[Fact]
public async Task Cancel_Edit_AucuneModification()
{
// Arrange : une intervention locale existante.
Guid interventionId = Guid.NewGuid();
await InterventionRepository.UpsertAsync(CreateIntervention(interventionId, "INT-001", "Original"));
await _viewModel.InitializeAsync(CreateNavigation(InterventionEditMode.Edit, interventionId));
// Act : modification puis annulation.
_viewModel.Titre = "Modifié mais annulé";
_viewModel.CancelCommand.Execute(null);
// Assert : aucune mutation locale ni Outbox, données inchangées.
Assert.Empty(await OutboxRepository.GetReadyAsync());
LocalIntervention? local = await InterventionRepository.GetByIdAsync(interventionId);
Assert.Equal("Original", JsonSerializer.Deserialize(local!.PayloadJson)?.Titre);
Assert.Equal(AppPage.Interventions, _navigation.CurrentPage);
}
[Fact]
public async Task DoubleSave_PremierEnCours_DeuxiemeIgnore()
{
// Arrange : fake contrôlable qui bloque le premier CreateAsync sur un
// TaskCompletionSource, afin d'observer le verrouillage IsBusy réel.
var blockingService = new BlockingInterventionLocalService();
var viewModel = new InterventionEditViewModel(
blockingService,
new ClientLocalService(ClientRepository, DossierRepository, ContextInterventionCacheRepository, ClientStateRepository, ApiClient),
new SiteLocalService(SiteRepository, ClientRepository, PersonneRepository, RendezVousRepository, InterventionRepository, ContextInterventionCacheRepository, SiteStateRepository, ApiClient),
new DossierLocalService(DossierRepository, ClientRepository, DossierStateRepository, DossierInterventionCacheRepository, ApiClient),
new CollaborateurLocalService(CollaborateurRepository, RendezVousRepository, ContextInterventionCacheRepository, ApiClient, SessionService),
InterventionRepository,
_navigation);
await viewModel.InitializeAsync(CreateNavigation(InterventionEditMode.Create, Guid.Empty));
viewModel.Titre = "Intervention";
// Act : premier Save lancé, reste volontairement en attente sur le TCS.
Task firstSave = viewModel.SaveCommand.ExecuteAsync(null);
// Attendre que le premier appel CreateAsync soit réellement en cours.
await blockingService.WaitForCreateAsync();
// Assert : IsBusy est true pendant que le premier Save est en cours.
Assert.True(viewModel.IsBusy);
Assert.Equal(1, blockingService.CreateCallCount);
// Act : deuxième Save déclenché pendant que le premier est bloqué.
Task secondSave = viewModel.SaveCommand.ExecuteAsync(null);
// Assert : le deuxième clic ne provoque pas un deuxième appel au service.
Assert.Equal(1, blockingService.CreateCallCount);
// Act : compléter le premier Save.
blockingService.CompleteCreate(Guid.NewGuid());
await firstSave;
await secondSave;
// Assert : une seule mutation, IsBusy repassé à false.
Assert.Equal(1, blockingService.CreateCallCount);
Assert.False(viewModel.IsBusy);
}
[Fact]
public async Task FonctionneOffline_SansServeur()
{
// Arrange : aucune dépendance réseau, uniquement SQLite local.
await _viewModel.InitializeAsync(CreateNavigation(InterventionEditMode.Create, Guid.Empty));
_viewModel.Titre = "Intervention offline";
// Act
await _viewModel.SaveCommand.ExecuteAsync(null);
// Assert : création locale réussie sans serveur.
Assert.Equal(AppPage.InterventionDetail, _navigation.CurrentPage);
Assert.Single(await InterventionRepository.GetAllAsync());
Assert.Single(await OutboxRepository.GetReadyAsync());
}
}
///
/// Fake contrôlable de : le premier
/// appel CreateAsync reste en attente sur un TaskCompletionSource jusqu'à ce
/// que le test le complète. Permet d'observer le verrouillage IsBusy réel du
/// ViewModel lors d'un double clic, de façon déterministe (aucun Task.Delay).
///
sealed class BlockingInterventionLocalService : IInterventionLocalService
{
private readonly TaskCompletionSource _createGate =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _createStarted =
new(TaskCreationOptions.RunContinuationsAsynchronously);
/// Nombre d'appels CreateAsync réellement reçus.
public int CreateCallCount { get; private set; }
public async Task CreateAsync(
InterventionSyncPayload payload,
CancellationToken cancellationToken = default)
{
CreateCallCount++;
_createStarted.TrySetResult();
await _createGate.Task;
return Guid.NewGuid();
}
public Task UpdateAsync(
Guid interventionGuid,
InterventionSyncPayload payload,
CancellationToken cancellationToken = default)
=> Task.FromResult(interventionGuid);
public Task ArchiveAsync(
Guid interventionGuid,
CancellationToken cancellationToken = default)
=> Task.FromResult(interventionGuid);
public Task GetInterventionByIdAsync(
Guid interventionGuid,
CancellationToken cancellationToken = default)
=> Task.FromResult(null);
public Task> GetInterventionsAsync(
CancellationToken cancellationToken = default)
=> Task.FromResult>([]);
/// Attend que le premier CreateAsync soit réellement en cours.
public Task WaitForCreateAsync() => _createStarted.Task;
/// Complète le premier CreateAsync avec le résultat fourni.
public void CompleteCreate(Guid result) => _createGate.TrySetResult(result);
}