Remonte le type de document, colle le menu, et fais du retour une remontée
Cinq retours d'usage du 2026-08-21 qui ne touchent que l'interface (lots Q, R, S, T et V d'IDEES.md). Aucune migration, aucune entité modifiée. Q — L'ordre des champs du formulaire livre est une décision, pas une mise en page : type de document, thèmes, auteurs, rôles, puis le reste. Le type commande la suite de la saisie — les rôles ne se posent que là — et il fallait descendre tout le formulaire pour dire « c'est une BD », c'est-à-dire après avoir saisi ce qui en dépend. Rien n'est présélectionné : « non précisé » ne prétend toujours rien, et les rôles gardent leur règle actée (deux auteurs au moins, BD ou non). Des tests verrouillent la préservation des rôles à chaque frappe, que ce réordonnancement ne doit pas entamer. R — Bandeau et menu passent dans un même conteneur collant : sur PC la rangée des six destinations défilait avec la page et devenait inatteignable au bas d'une longue liste. Un seul conteneur, et non deux éléments collants superposés, qui auraient obligé à écrire en dur la hauteur d'un bandeau qui varie avec la pastille hors-ligne et le nom d'utilisateur. Sur téléphone le menu déployé occupe l'écran entier : sous le bandeau, il partageait l'écran avec la liste qu'on quittait. Trois portes de sortie s'ajoutent à la fermeture déjà en place sur LocationChanged — croix, Échap (le calque prend le focus à l'ouverture, comme celui d'agrandissement des couvertures) et clic hors des liens. Le plein écran est explicitement annulé au-delà de 40 rem, sans quoi un menu ouvert au doigt puis une fenêtre agrandie laisseraient un calque sans bascule pour le refermer. Toutes les règles du menu restent en feuille globale. S — ⚠️ Le retour du bandeau devient une remontée hiérarchique d'un cran de route, ce qui RENVERSE la décision actée le 2026-08-20 (« le retour passe par l'historique du navigateur, jamais par une destination calculée »). Le motif d'alors reste vrai — un même écran s'atteint par plusieurs chemins — mais l'historique remonte aussi les allers-retours (filtre, ordre, édition) et l'on cliquait cinq fois sans quitter le même écran. La parenté est une table explicite et testable, pas un découpage naïf d'URL : toutes les routes n'ont pas la forme d'une arborescence, /auteurs/{id}/bibliographie remontant à la liste des auteurs, dont il n'existe pas de fiche. Le garde-fou « ne jamais sortir de l'application » y vit désormais aussi : la fonction rend toujours un chemin interne, là où history.length ne disait pas ce qu'on croyait dans une PWA standalone. js/navigation.js n'a plus d'utilisateur et disparaît. T — Sur la fiche d'une série, « Modifier » et « Changer l'ordre » rejoignent la ligne du titre et de l'avancement, groupés à droite comme la bibliographie et la liste des auteurs le font déjà. Reléguées au bas de l'écran, les actions d'une saga de vingt tomes ne se découvraient qu'après avoir déroulé la liste. « Changer l'ordre » n'apparaît toujours qu'à partir de deux tomes ou deux sous-séries. V — Dans la liste des revues, toute la ligne ouvre la fiche, comme la carte entière le fait au catalogue. Le titre reste un vrai lien — adresse, clavier, clic-milieu — et son clic ne remonte pas jusqu'à la ligne, qui naviguerait une seconde fois ; toute action posée un jour sur cette ligne devra faire de même. ⚠️ Aucune vérification en navigateur : le rendu de ces écrans repose sur la compilation et la relecture. 552 tests au vert (506 avant ce lot). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
using MaBibli.Shared.Dtos;
|
||||
using MaBibli.Shared.Entites;
|
||||
|
||||
namespace MaBibli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// La vue « une seule ligne » du champ des auteurs se relie à <b>chaque frappe</b> : elle
|
||||
/// reconstruit la liste entière à chaque caractère tapé.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Sans reprise des rôles déjà choisis, corriger une faute de frappe dans le champ des noms
|
||||
/// remettrait tous les rôles à « non précisé » sous les yeux de l'utilisateur. Le lot Q remonte
|
||||
/// les rôles juste après les auteurs dans le formulaire, ce qui rend la perte d'autant plus
|
||||
/// visible : ces tests verrouillent l'invariant que le réordonnancement ne doit pas entamer.
|
||||
/// </remarks>
|
||||
public class EnregistrementLivreTests
|
||||
{
|
||||
[Fact]
|
||||
public void Une_frappe_dans_le_champ_des_noms_preserve_les_roles()
|
||||
{
|
||||
var saisie = new EnregistrementLivre { Auteur = "Dobbs ; Christophe Regnault" };
|
||||
saisie.Auteurs[0] = saisie.Auteurs[0] with { Role = RoleAuteur.Scenario };
|
||||
saisie.Auteurs[1] = saisie.Auteurs[1] with { Role = RoleAuteur.Dessin };
|
||||
|
||||
// L'utilisateur ajoute un troisième auteur : le champ entier est relu.
|
||||
saisie.Auteur = "Dobbs ; Christophe Regnault ; Marie Coloriste";
|
||||
|
||||
Assert.Equal(RoleAuteur.Scenario, saisie.Auteurs[0].Role);
|
||||
Assert.Equal(RoleAuteur.Dessin, saisie.Auteurs[1].Role);
|
||||
Assert.Equal(RoleAuteur.NonPrecise, saisie.Auteurs[2].Role);
|
||||
}
|
||||
|
||||
/// <summary>Le rapprochement se fait sur la forme normalisée : un accent ajouté après coup
|
||||
/// ne doit pas coûter son rôle à l'auteur.</summary>
|
||||
[Fact]
|
||||
public void Un_accent_ajoute_apres_coup_ne_perd_pas_le_role()
|
||||
{
|
||||
var saisie = new EnregistrementLivre { Auteur = "Dobbs ; Christophe Regnault" };
|
||||
saisie.Auteurs[1] = saisie.Auteurs[1] with { Role = RoleAuteur.Dessin };
|
||||
|
||||
saisie.Auteur = "Dobbs ; Christophé Regnault";
|
||||
|
||||
Assert.Equal(RoleAuteur.Dessin, saisie.Auteurs[1].Role);
|
||||
}
|
||||
|
||||
/// <summary>Un nom réellement différent repart à zéro : on ne devine pas que c'est la même
|
||||
/// personne.</summary>
|
||||
[Fact]
|
||||
public void Un_nom_different_repart_sans_role()
|
||||
{
|
||||
var saisie = new EnregistrementLivre { Auteur = "Dobbs ; Christophe Regnault" };
|
||||
saisie.Auteurs[1] = saisie.Auteurs[1] with { Role = RoleAuteur.Dessin };
|
||||
|
||||
saisie.Auteur = "Dobbs ; Jean Dupont";
|
||||
|
||||
Assert.Equal(RoleAuteur.NonPrecise, saisie.Auteurs[1].Role);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Les rôles ne s'affichent qu'à partir de deux auteurs (règle actée, inchangée par le lot Q) :
|
||||
/// c'est le nombre d'auteurs qui commande, pas le type de document.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("Émile Zola", 1)]
|
||||
[InlineData("Dobbs ; Christophe Regnault", 2)]
|
||||
[InlineData(" ; Dobbs ; ; ", 1)]
|
||||
[InlineData("", 0)]
|
||||
public void Le_nombre_dauteurs_est_celui_des_entrees_non_vides(string ligne, int attendu) =>
|
||||
Assert.Equal(attendu, new EnregistrementLivre { Auteur = ligne }.Auteurs.Count);
|
||||
|
||||
/// <summary>Le type de document reste « non précisé » tant que personne ne l'a choisi — le
|
||||
/// lot Q remonte le champ, il ne le préremplit pas.</summary>
|
||||
[Fact]
|
||||
public void Le_type_de_document_ne_pretend_rien_par_defaut() =>
|
||||
Assert.Equal(TypeDocument.NonPrecise, new EnregistrementLivre().TypeDocument);
|
||||
|
||||
/// <summary>Le format, lui, DOIT rester physique par défaut : dans les listes, l'absence
|
||||
/// d'étiquette signifie « physique ».</summary>
|
||||
[Fact]
|
||||
public void Le_format_reste_physique_par_defaut() =>
|
||||
Assert.Equal(Format.Physique, new EnregistrementLivre().Format);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using MaBibli.Client.Services;
|
||||
|
||||
namespace MaBibli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// La flèche de retour du bandeau remonte d'un cran de route, et ne sort jamais de
|
||||
/// l'application. Ces cas sont ceux qui ont été écrits noir sur blanc avant de coder.
|
||||
/// </summary>
|
||||
public class RemonteeRoutesTests
|
||||
{
|
||||
[Theory]
|
||||
// Fiche livre
|
||||
[InlineData("/livres/3/edition", "/livres/3")]
|
||||
[InlineData("/livres/3", "/")]
|
||||
// Séries
|
||||
[InlineData("/series/7/edition", "/series/7")]
|
||||
[InlineData("/series/7/ordre", "/series/7")]
|
||||
[InlineData("/series/7", "/series")]
|
||||
[InlineData("/series", "/")]
|
||||
// Envies
|
||||
[InlineData("/souhaits/ordre", "/souhaits")]
|
||||
[InlineData("/souhaits/ajout", "/souhaits")]
|
||||
[InlineData("/souhaits", "/")]
|
||||
// Ajout d'un ouvrage
|
||||
[InlineData("/ajout", "/")]
|
||||
[InlineData("/ajout/isbn", "/")]
|
||||
[InlineData("/ajout/manuel", "/")]
|
||||
// Revues
|
||||
[InlineData("/revues/ajout", "/revues")]
|
||||
[InlineData("/revues/12", "/revues")]
|
||||
[InlineData("/revues", "/")]
|
||||
// Auteurs
|
||||
[InlineData("/auteurs/5/bibliographie", "/auteurs")]
|
||||
[InlineData("/auteurs", "/")]
|
||||
// Le reste du menu
|
||||
[InlineData("/prets", "/")]
|
||||
[InlineData("/", "/")]
|
||||
public void Remonte_dun_cran(string chemin, string attendu) =>
|
||||
Assert.Equal(attendu, RemonteeRoutes.Parent(chemin));
|
||||
|
||||
/// <summary>
|
||||
/// « ajout » n'est pas un identifiant : sans l'ordre de lecture de la table, il serait pris
|
||||
/// pour la fiche d'une revue.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Ajout_de_revue_nest_pas_une_fiche() =>
|
||||
Assert.Equal("/revues", RemonteeRoutes.Parent("/revues/ajout"));
|
||||
|
||||
/// <summary>La requête d'un filtre ou d'un tri ne change pas la parenté.</summary>
|
||||
[Theory]
|
||||
[InlineData("/?auteur=3", "/")]
|
||||
[InlineData("/livres/3/edition?onglet=pret", "/livres/3")]
|
||||
[InlineData("/auteurs/5/bibliographie#bas", "/auteurs")]
|
||||
public void La_requete_et_le_fragment_sont_ignores(string chemin, string attendu) =>
|
||||
Assert.Equal(attendu, RemonteeRoutes.Parent(chemin));
|
||||
|
||||
/// <summary>Le chemin peut arriver relatif à la base, sans barre oblique de tête.</summary>
|
||||
[Theory]
|
||||
[InlineData("livres/3/edition", "/livres/3")]
|
||||
[InlineData("series/7/ordre", "/series/7")]
|
||||
[InlineData("", "/")]
|
||||
public void Un_chemin_relatif_est_accepte(string chemin, string attendu) =>
|
||||
Assert.Equal(attendu, RemonteeRoutes.Parent(chemin));
|
||||
|
||||
/// <summary>Une barre oblique finale ne doit pas faire manquer la ligne de table.</summary>
|
||||
[Fact]
|
||||
public void La_barre_finale_est_ignoree() =>
|
||||
Assert.Equal("/livres/3", RemonteeRoutes.Parent("/livres/3/edition/"));
|
||||
|
||||
/// <summary>
|
||||
/// Repli d'une route inconnue : la destination de menu de sa branche, jamais un chemin
|
||||
/// deviné, et jamais rien d'externe.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("/revues/12/edition", "/revues")]
|
||||
[InlineData("/series/7/inconnu", "/series")]
|
||||
[InlineData("/inconnu/total", "/")]
|
||||
[InlineData("/livres", "/")]
|
||||
public void Une_route_inconnue_retombe_sur_sa_branche(string chemin, string attendu) =>
|
||||
Assert.Equal(attendu, RemonteeRoutes.Parent(chemin));
|
||||
|
||||
/// <summary>
|
||||
/// ⚠️ Le garde-fou du lot S : quoi qu'on lui donne, le retour reste dans l'application.
|
||||
/// Une URL absolue ou un chemin protocolaire ne doit jamais ressortir tel quel.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("https://exemple.test/livres/3")]
|
||||
[InlineData("//exemple.test/")]
|
||||
[InlineData("/livres/abc/edition")]
|
||||
public void Ne_sort_jamais_de_lapplication(string? chemin)
|
||||
{
|
||||
var parent = RemonteeRoutes.Parent(chemin);
|
||||
|
||||
Assert.StartsWith("/", parent);
|
||||
Assert.DoesNotContain("//", parent);
|
||||
Assert.DoesNotContain(":", parent);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user