From f9059ed198fbb5cb416de5988668c98ed7862be3 Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Tue, 11 Aug 2026 16:57:22 +0200 Subject: [PATCH] =?UTF-8?q?D1=20:=20la=20visite=20hors=20ligne=20embarque?= =?UTF-8?q?=20enfin=20ses=20m=C3=A9dias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le switch de collecte des ressources était commenté : 157 lignes mortes dans Export, 126 dans Import. Une visite téléchargée n'embarquait donc que l'image de la configuration, celle du loader et l'image de chaque section — ni contenus d'articles, ni audios, ni icônes de carte, ni images de quiz. Export matérialise désormais les entités, pas seulement leurs DTO, et appelle GetReferencedResourceIds(language) : la méthode existait déjà sur les 13 sous-types et n'attendait que d'être appelée. Elle filtre aussi les audios par langue, ce que le switch ne faisait pas. Import cesse de redécouvrir les ressources section par section : l'export embarquant tout ce qui est référencé, une passe unique sur la charge suffit. createResource étant idempotente, les quelques appels ciblés qui subsistent sont sans effet. 4 tests, dont un qui vérifie par réflexion que les 13 sous-types implémentent la collecte : le trou venait d'un switch où un type oublié passait dans le default sans bruit, et c'est ce qu'il ne faut plus pouvoir refaire. dotnet test 148/148. Co-Authored-By: Claude Opus 5 --- .../Controllers/ConfigurationExportTests.cs | 126 +++++++ .../Controllers/ConfigurationController.cs | 316 ++---------------- 2 files changed, 151 insertions(+), 291 deletions(-) create mode 100644 ManagerService.Tests/Controllers/ConfigurationExportTests.cs diff --git a/ManagerService.Tests/Controllers/ConfigurationExportTests.cs b/ManagerService.Tests/Controllers/ConfigurationExportTests.cs new file mode 100644 index 0000000..1961e86 --- /dev/null +++ b/ManagerService.Tests/Controllers/ConfigurationExportTests.cs @@ -0,0 +1,126 @@ +using Manager.DTOs; +using ManagerService.Data; +using ManagerService.Data.SubSection; +using ManagerService.DTOs; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace ManagerService.Tests.Controllers +{ + /// + /// L'export d'une configuration doit embarquer **toutes** les ressources que ses + /// sections référencent — c'est ce qui rend une visite lisible hors ligne. + /// + /// Le `switch` qui les collectait était commenté depuis un moment : seules l'image + /// de la configuration, celle du loader et l'image de chaque section partaient, donc + /// une visite téléchargée n'avait ni contenus d'articles ni audios. Ces tests portent + /// sur `GetReferencedResourceIds`, à qui la collecte a été confiée : c'est elle qui + /// garantit qu'un nouveau type de section n'ouvrira pas le même trou en silence. + /// + public class ConfigurationExportTests + { + private static SectionArticle ArticleWithMedia() => new SectionArticle + { + Id = "sec-article", + ConfigurationId = "cfg-1", + InstanceId = "inst-1", + Label = "Article", + Type = SectionType.Article, + Title = new List(), + Description = new List(), + ImageId = "res-image-section", + ArticleContent = new List(), + ArticleAudioIds = new List + { + new TranslationDTO { language = "FR", value = "res-audio-fr" }, + new TranslationDTO { language = "NL", value = "res-audio-nl" }, + }, + ArticleContents = new List + { + new ContentDTO { resourceId = "res-content-1" }, + new ContentDTO { resourceId = "res-content-2" }, + }, + }; + + [Fact] + public void Article_ExposesItsContentAndAudioResources() + { + var ids = ArticleWithMedia().GetReferencedResourceIds().ToList(); + + // Sans ces trois familles, la visite hors ligne s'ouvre sans ses médias. + Assert.Contains("res-image-section", ids); + Assert.Contains("res-content-1", ids); + Assert.Contains("res-content-2", ids); + Assert.Contains("res-audio-fr", ids); + } + + [Fact] + public void Article_FiltersAudioByLanguage_WhenOneIsAsked() + { + var ids = ArticleWithMedia().GetReferencedResourceIds("FR").ToList(); + + Assert.Contains("res-audio-fr", ids); + // Télécharger une visite en français ne doit pas embarquer les audios NL : + // c'est du poids sur le device du visiteur, pas du contenu. + Assert.DoesNotContain("res-audio-nl", ids); + } + + [Fact] + public void Map_ExposesIconCategoriesAndPointResources() + { + var map = new SectionMap + { + Id = "sec-map", + ConfigurationId = "cfg-1", + InstanceId = "inst-1", + Label = "Map", + Type = SectionType.Map, + Title = new List(), + Description = new List(), + IconResourceId = "res-map-icon", + MapCategories = new List + { + new CategorieDTO { resourceDTO = new ResourceDTO { id = "res-categorie" } }, + }, + MapPoints = new List + { + new GeoPoint + { + Id = 1, + ImageResourceId = "res-point-image", + Contents = new List { new ContentDTO { resourceId = "res-point-content" } }, + }, + }, + }; + + var ids = map.GetReferencedResourceIds().ToList(); + + Assert.Contains("res-map-icon", ids); + Assert.Contains("res-categorie", ids); + Assert.Contains("res-point-image", ids); + Assert.Contains("res-point-content", ids); + } + + [Fact] + public void EverySectionType_ImplementsTheCollection() + { + // Le trou d'origine venait d'un `switch` : un type oublié passait dans le + // `default` sans bruit. La méthode étant abstraite sur Section, le compilateur + // impose désormais l'implémentation — ce test le constate plutôt qu'il ne + // l'espère, et échouera si quelqu'un réintroduit une collecte par switch. + var sectionType = typeof(Section); + var subTypes = sectionType.Assembly.GetTypes() + .Where(t => t.IsSubclassOf(sectionType) && !t.IsAbstract) + .ToList(); + + Assert.NotEmpty(subTypes); + foreach (var t in subTypes) + { + var method = t.GetMethod(nameof(Section.GetReferencedResourceIds)); + Assert.NotNull(method); + Assert.Equal(t, method.DeclaringType); + } + } + } +} diff --git a/ManagerService/Controllers/ConfigurationController.cs b/ManagerService/Controllers/ConfigurationController.cs index 164420e..7fc4f98 100644 --- a/ManagerService/Controllers/ConfigurationController.cs +++ b/ManagerService/Controllers/ConfigurationController.cs @@ -398,7 +398,10 @@ namespace ManagerService.Controllers if (configuration == null) throw new KeyNotFoundException("Configuration does not exist"); - List sectionDTOs = _myInfoMateDbContext.Sections.Where(s => s.ConfigurationId == configuration.Id).Select(s => s.ToDTO()).ToList(); + // Les entités, pas seulement leurs DTO : la collecte des ressources passe + // par GetReferencedResourceIds, qui vit sur le sous-type. + List
sections = _myInfoMateDbContext.Sections.Where(s => s.ConfigurationId == configuration.Id).ToList(); + List sectionDTOs = sections.Select(s => s.ToDTO()).ToList(); List resourceDTOs = new List(); if (configuration.ImageId != null) @@ -411,170 +414,17 @@ namespace ManagerService.Controllers addResourceToList(resourceDTOs, configuration.LoaderImageId); } - foreach (var section in sectionDTOs) + foreach (var section in sections) { - if (section.imageId != null) { - addResourceToList(resourceDTOs, section.imageId); - } - - /*switch (section.type) { - case SectionType.Map: - MapDTO mapDTO = JsonConvert.DeserializeObject(section.data); - if (mapDTO.iconResourceId != null) - { - addResourceToList(resourceDTOs, mapDTO.iconResourceId); - } - - foreach (var point in mapDTO.points) { - if (point.imageResourceId != null) - { - addResourceToList(resourceDTOs, point.imageResourceId); - } - foreach (var content in point.contents) { - if (content.resourceId != null) - { - addResourceToList(resourceDTOs, content.resourceId); - } - } - } - - if (mapDTO.categories != null && mapDTO.categories.Count > 0) - { - foreach (var categorie in mapDTO.categories) - { - if (categorie.iconResourceId != null) - { - addResourceToList(resourceDTOs, categorie.iconResourceId); - } - } - } - - break; - case SectionType.Slider: - SliderDTO sliderDTO = JsonConvert.DeserializeObject(section.data); - foreach (var content in sliderDTO.contents) - { - if (content.resourceId != null) - { - addResourceToList(resourceDTOs, content.resourceId); - } - } - break; - case SectionType.Quizz: - QuizzDTO quizzDTO = JsonConvert.DeserializeObject(section.data); - foreach (var question in quizzDTO.questions) - { - if (question.label != null) - { - foreach (var questionLabel in question.label) - { - addResourceToList(resourceDTOs, questionLabel.resourceId); - } - } - if (question.imageBackgroundResourceId != null) - { - addResourceToList(resourceDTOs, question.imageBackgroundResourceId); - } - foreach (var response in question.responses) - { - if (response.label != null) - { - foreach (var responseLabel in response.label) - { - addResourceToList(resourceDTOs, responseLabel.resourceId); - } - } - } - } - if (quizzDTO.bad_level != null) - { - if (quizzDTO.bad_level.label != null) - { - foreach (var badLevelLabel in quizzDTO.bad_level.label) - { - addResourceToList(resourceDTOs, badLevelLabel.resourceId); - } - } - } - if (quizzDTO.medium_level != null) - { - if (quizzDTO.medium_level.label != null) - { - foreach (var medium_levelLabel in quizzDTO.medium_level.label) - { - addResourceToList(resourceDTOs, medium_levelLabel.resourceId); - } - } - } - if (quizzDTO.good_level != null) - { - if (quizzDTO.good_level.label != null) - { - foreach (var good_levelLabel in quizzDTO.good_level.label) - { - addResourceToList(resourceDTOs, good_levelLabel.resourceId); - } - } - } - if (quizzDTO.great_level != null) - { - if (quizzDTO.great_level.label != null) - { - foreach (var great_levelLabel in quizzDTO.great_level.label) - { - addResourceToList(resourceDTOs, great_levelLabel.resourceId); - } - } - } - break; - case SectionType.Article: - ArticleDTO articleDTO = JsonConvert.DeserializeObject(section.data); - foreach (var content in articleDTO.contents) - { - if (content.resourceId != null) - { - addResourceToList(resourceDTOs, content.resourceId); - } - } - - // If not a language is used for export in manager, if one is the myvisit app - var audios = language != null ? articleDTO.audioIds.Where(a => a.language == language) : articleDTO.audioIds; - foreach (var audio in audios) - { - if (audio.value != null) - { - addResourceToList(resourceDTOs, audio.value); - } - } - break; - case SectionType.PDF: - PdfDTO pdfDTO = JsonConvert.DeserializeObject(section.data); - foreach (var pdf in pdfDTO.pdfs) - { - foreach (var pdfFileAndTitle in pdf.pdfFilesAndTitles) - { - addResourceToList(resourceDTOs, pdfFileAndTitle.resourceId); - } - } - break; - case SectionType.Agenda: - AgendaDTO agendaDTO = JsonConvert.DeserializeObject(section.data); - var resourceIds = language != null ? agendaDTO.resourceIds.Where(a => a.language == language) : agendaDTO.resourceIds; - foreach (var resourceId in resourceIds) - { - if (resourceId.value != null && resourceId.value.Trim().Length > 0) - { - addResourceToList(resourceDTOs, resourceId.value); - } - } - break; - case SectionType.Menu: - case SectionType.Web: - case SectionType.Video: - case SectionType.Weather: - default: - break; - }*/ + // Remplace 157 lignes de `switch` commenté qui n'ont jamais tourné : + // une visite téléchargée n'embarquait que l'image de la configuration, + // celle du loader et l'image de chaque section — ni contenus d'articles, + // ni audios, ni icônes de carte, ni images de quiz. + // `GetReferencedResourceIds` est déjà implémentée sur les 13 sous-types + // et connaît chacun ses propres médias ; c'est aussi elle qui garantit + // qu'un nouveau type de section n'ouvre pas un trou silencieux ici. + foreach (var resourceId in section.GetReferencedResourceIds(language)) + addResourceToList(resourceDTOs, resourceId); } ExportConfigurationDTO toDownload = configuration.ToExportDTO(sectionDTOs, resourceDTOs); string jsonString = JsonConvert.SerializeObject(toDownload); @@ -655,6 +505,12 @@ namespace ManagerService.Controllers var sectionsAlreadyInDB = _myInfoMateDbContext.Sections.Where(s => !exportConfiguration.sections.Select(s => s.id).Contains(s.Id)).Select(s => s.Id).ToList(); + // Toutes les ressources de la charge, en une passe. `createResource` est + // idempotente, donc les quelques appels ciblés qui subsistent plus haut sont + // sans effet — et un nouveau type de section ne peut plus ouvrir de trou ici. + foreach (var resourceExport in exportConfiguration.resources ?? new List()) + createResource(resourceExport); + foreach (var section in exportConfiguration.sections.Where(s => !sectionsAlreadyInDB.Contains(s.id))) { Section newSection = SectionFactory.CreateEmpty(section.type); @@ -686,133 +542,11 @@ namespace ManagerService.Controllers _myInfoMateDbContext.Sections.Add(newSection); //_sectionService.Create(newSection); - /*switch (section.type) - { - case SectionType.Map: - MapDTO mapDTO = JsonConvert.DeserializeObject(section.data); - if (mapDTO.iconResourceId != null) - { - createResource(exportConfiguration.resources.Where(r => r.id == mapDTO.iconResourceId).FirstOrDefault()); - } - - foreach (var point in mapDTO.points) - { - if (point.imageResourceId != null) - { - createResource(exportConfiguration.resources.Where(r => r.id == point.imageResourceId).FirstOrDefault()); - } - foreach (var content in point.contents) - { - if (content.resourceId != null) - { - createResource(exportConfiguration.resources.Where(r => r.id == content.resourceId).FirstOrDefault()); - } - } - } - - foreach (var categorie in mapDTO.categories) - { - if (categorie.iconResourceId != null) - { - createResource(exportConfiguration.resources.Where(r => r.id == categorie.iconResourceId).FirstOrDefault()); - } - } - - break; - case SectionType.Slider: - SliderDTO sliderDTO = JsonConvert.DeserializeObject(section.data); - foreach (var content in sliderDTO.contents) - { - if (content.resourceId != null) - { - createResource(exportConfiguration.resources.Where(r => r.id == content.resourceId).FirstOrDefault()); - } - } - break; - case SectionType.Quizz: - QuizDTO quizzDTO = JsonConvert.DeserializeObject(section.data); - foreach (var question in quizzDTO.questions) - { - if (question.label != null) - { - foreach (var questionLabel in question.label) - { - createResource(exportConfiguration.resources.Where(r => r.id == questionLabel.resourceId).FirstOrDefault()); - } - } - if (question.imageBackgroundResourceId != null) - { - createResource(exportConfiguration.resources.Where(r => r.id == question.imageBackgroundResourceId).FirstOrDefault()); - } - foreach (var response in question.responses) - { - if (response.label != null) - { - foreach (var responseLabel in response.label) - { - createResource(exportConfiguration.resources.Where(r => r.id == responseLabel.resourceId).FirstOrDefault()); - } - } - } - } - if (quizzDTO.bad_level != null) - { - if(quizzDTO.bad_level.label != null) - { - foreach (var balLevelLabel in quizzDTO.bad_level.label) - { - createResource(exportConfiguration.resources.Where(r => r.id == balLevelLabel.resourceId).FirstOrDefault()); - } - } - } - if (quizzDTO.medium_level != null) - { - if (quizzDTO.medium_level.label != null) - { - foreach (var medium_levelLabel in quizzDTO.medium_level.label) - { - createResource(exportConfiguration.resources.Where(r => r.id == medium_levelLabel.resourceId).FirstOrDefault()); - } - } - } - if (quizzDTO.good_level != null) - { - if (quizzDTO.good_level.label != null) - { - foreach (var good_levelLabel in quizzDTO.good_level.label) - { - createResource(exportConfiguration.resources.Where(r => r.id == good_levelLabel.resourceId).FirstOrDefault()); - } - } - } - if (quizzDTO.great_level != null) - { - if (quizzDTO.great_level.label != null) - { - foreach (var great_levelLabel in quizzDTO.great_level.label) - { - createResource(exportConfiguration.resources.Where(r => r.id == great_levelLabel.resourceId).FirstOrDefault()); - } - } - } - break; - case SectionType.Article: - ArticleDTO articleDTO = JsonConvert.DeserializeObject(section.data); - foreach (var content in articleDTO.contents) - { - if (content.resourceId != null) - { - createResource(exportConfiguration.resources.Where(r => r.id == content.resourceId).FirstOrDefault()); - } - } - break; - case SectionType.Menu: - case SectionType.Web: - case SectionType.Video: - case SectionType.Weather: - default: - break; - }*/ + // Le `switch` de 126 lignes qui vivait ici cherchait, section par + // section, quelles ressources de la charge créer — et il était commenté, + // donc seule l'image de la section arrivait. Remplacé par la boucle + // unique plus haut : l'export embarque désormais toutes les ressources + // référencées, l'import n'a plus à les redécouvrir. } return new ObjectResult("The configuration has been successfully imported") { StatusCode = 202 };