Chapitre 1517 minIntermédiaire
Exercices corrigés C# WinForms type E6
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- faire évoluer une application WinForms dans toutes ses couches ;
- trouver et corriger un bug à partir d'un symptôme ;
- lire du code C# et prévoir ce qu'il affiche ;
- justifier tes choix techniques devant une commission.
9 exercices corrigés
Ce chapitre rassemble des exercices dans l'esprit de l'heure de réalisation de l'épreuve E6 SLAM : faire évoluer une application existante, trouver un bug à partir d'un symptôme, lire du code, et justifier ses choix devant la commission. Tous portent sur GSB Ordonnances, telle qu'elle a été construite dans les chapitres précédents. Le déroulement de l'épreuve et la méthode de l'heure de réalisation sont détaillés dans le cours Épreuve E6 SLAM.
Méthode pour une évolution
Devant une demande d'évolution, procède toujours dans le même ordre, de la base vers l'interface :
- Comprendre : reformule la demande, repère les règles de gestion et les cas limites.
- La base : faut-il une colonne, une table, une contrainte ? Écris et exécute le script SQL.
- Le métier : la classe du modèle, la règle, le service. Écris un test si la règle est nouvelle.
- Les données : les requêtes du DAO (paramétrées).
- L'interface : le contrôle, la colonne de la grille, le message d'erreur.
- Vérifier : lance les tests, puis teste à la main le cas normal et les cas d'erreur.
Ce découpage est celui de l'architecture en couches : chaque étape est petite, et tu sais où tu en es si le temps manque.
Les évolutions types et leur méthode détaillée sont dans Épreuve E6 SLAM : les évolutions ; pour t'entraîner sur un client lourd dans les conditions de l'épreuve, fais l'épreuve blanche n° 2.
Évolution 1 : le téléphone du patient
Exercice 1 sur 9 Ajouter le téléphone Réussi À revoir
Le cabinet veut enregistrer le numéro de téléphone (facultatif) de chaque patient, pour le rappeler en cas de résultat d'analyse. Un numéro valide compte 10 chiffres, commence par 0, et son deuxième chiffre n'est pas 0. Le numéro s'affiche dans la grille des patients. Décris et écris les modifications, couche par couche.
Voir la réponseMasquer la réponse de l'exercice 1
Base.
ALTER TABLE patient ADD telephone CHAR(10) NULL;
Métier : une propriété validée dans Patient.
public string? Telephone
{
get;
set => field = value is null || Regex.IsMatch(value, @"^0[1-9]\d{8}$")
? value
: throw new ArgumentException("Le téléphone compte 10 chiffres et commence par 0.");
}
Données : dans PatientDao, ajouter telephone aux deux SELECT, à l'INSERT et à l'UPDATE, un paramètre dans AjouterParametres, et la lecture dans Lire.
// AjouterParametres
cmd.Parameters.AddWithValue("@tel", (object?)p.Telephone ?? DBNull.Value);
// Lire
Telephone = l.IsDBNull(l.GetOrdinal("telephone")) ? null : l.GetString("telephone"),
Interface : une TextBox txtTelephone (MaxLength 10) dans FrmPatientEdition, avec son Validating et l'ErrorProvider ; l'affectation Telephone = string.IsNullOrWhiteSpace(txtTelephone.Text) ? null : txtTelephone.Text.Trim() dans la construction de Resultat ; le remplissage txtTelephone.Text = patient.Telephone; dans le constructeur ; une colonne colTelephone (DataPropertyName = Telephone) dans la grille.
Vérification : 0612345678 est accepté ; 06 12 34 56 78 (espaces) et 612345678 (9 chiffres) sont refusés ; un patient sans téléphone s'enregistre toujours.
Tu avais trouvé ?
Évolution 2 : filtrer les ordonnances par médecin, côté base
Exercice 2 sur 9 Une requête paramétrée avec filtre facultatif Réussi À revoir
L'écran des ordonnances propose une liste déroulante « Médecin » avec un premier choix « Tous ». Écris la méthode ListerLisiblesAsync(int? numMedecin) d'OrdonnanceDao, qui renvoie toutes les ordonnances quand numMedecin vaut null, et seulement celles du médecin sinon, en une seule requête.
Voir la réponseMasquer la réponse de l'exercice 2
public async Task<List<OrdonnanceLisible>> ListerLisiblesAsync(int? numMedecin)
{
const string sql = """
SELECT o.numOrdonnance, o.dateEmission,
CONCAT(p.nom, ' ', COALESCE(p.prenom, '')) AS patient,
CONCAT('Dr ', m.nom) AS medecin
FROM ordonnance o
JOIN patient p ON p.numPatient = o.numPatient
JOIN medecin m ON m.numMedecin = o.numMedecin
WHERE @medecin IS NULL OR o.numMedecin = @medecin
ORDER BY o.dateEmission DESC
""";
await using MySqlConnection conn = await connexion.OuvrirAsync();
await using var cmd = new MySqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@medecin", (object?)numMedecin ?? DBNull.Value);
await using MySqlDataReader l = await cmd.ExecuteReaderAsync();
var liste = new List<OrdonnanceLisible>();
while (await l.ReadAsync())
{
liste.Add(new OrdonnanceLisible(l.GetInt32("numOrdonnance"), l.GetDateTime("dateEmission"),
l.GetString("patient").Trim(), l.GetString("medecin")));
}
return liste;
}
La condition @medecin IS NULL OR o.numMedecin = @medecin est toujours vraie quand le paramètre vaut NULL : toutes les ordonnances sont renvoyées. Avec le jeu d'essai, ListerLisiblesAsync(1) renvoie seulement l'ordonnance 1 (Dupont Marie, Dr Martin). Le même paramètre peut apparaître deux fois dans la requête.
Tu avais trouvé ?
Évolution 3 : contrôler la clé du numéro de sécurité sociale
Exercice 3 sur 9 La clé de contrôle Réussi À revoir
La secrétaire saisit maintenant le numéro complet, avec sa clé : 15 chiffres. Le numéro de 13 chiffres reste stocké dans numeroSecu, mais la saisie doit être refusée si les deux derniers chiffres ne correspondent pas à la clé, égale à 97 − (le numéro de 13 chiffres modulo 97). Écris la méthode statique Nir.CleEstValide(string treizeChiffres, string cle), puis le Validating de txtSecu qui accepte un champ vide ou 15 chiffres dont la clé est juste. Quelle saisie est correcte pour Hugo Bernard, numéro 1081175123456 ?
Voir la réponseMasquer la réponse de l'exercice 3
public static class Nir
{
public static int Cle(string treizeChiffres)
{
if (!Regex.IsMatch(treizeChiffres, @"^\d{13}$"))
throw new ArgumentException("13 chiffres attendus.");
long nombre = long.Parse(treizeChiffres);
return 97 - (int)(nombre % 97);
}
public static bool CleEstValide(string treizeChiffres, string cle) =>
int.TryParse(cle, out int valeur) && valeur == Cle(treizeChiffres);
}
private void txtSecu_Validating(object? sender, CancelEventArgs e)
{
string saisie = txtSecu.Text.Trim();
bool valide = saisie.Length == 0
|| (Regex.IsMatch(saisie, @"^\d{15}$") && Nir.CleEstValide(saisie[..13], saisie[13..]));
errorProvider.SetError(txtSecu, valide ? "" : "Numéro ou clé incorrect (15 chiffres).");
e.Cancel = !valide;
}
1 081 175 123 456 modulo 97 donne 35, la clé vaut 97 − 35 = 62 : la saisie correcte est 108117512345662. On enregistre ensuite saisie[..13]. Un test unitaire [DataRow("1081175123456", 62)] s'ajoute à NirTests. La règle des numéros corses (2A, 2B) est volontairement laissée de côté : l'expression régulière refuse les lettres.
Tu avais trouvé ?
Évolution 4 : protéger la suppression d'un médecin
Exercice 4 sur 9 Un médecin qui a des ordonnances Réussi À revoir
La suppression d'un médecin qui a déjà rédigé des ordonnances fait apparaître une fenêtre d'erreur technique (Cannot delete or update a parent row: a foreign key constraint fails). Corrige MedecinDao.SupprimerAsync pour que l'utilisateur lise « Ce médecin a rédigé des ordonnances : impossible de le supprimer. », sans changer le formulaire, qui affiche déjà les RegleMetierException.
Voir la réponseMasquer la réponse de l'exercice 4
public async Task SupprimerAsync(int numMedecin)
{
await using MySqlConnection conn = await connexion.OuvrirAsync();
await using var cmd = new MySqlCommand("DELETE FROM medecin WHERE numMedecin = @num", conn);
cmd.Parameters.AddWithValue("@num", numMedecin);
try
{
await cmd.ExecuteNonQueryAsync();
}
catch (MySqlException ex) when (ex.ErrorCode == MySqlErrorCode.RowIsReferenced2)
{
throw new RegleMetierException("Ce médecin a rédigé des ordonnances : impossible de le supprimer.");
}
}
On s'appuie sur la contrainte de clé étrangère, qui reste le vrai garde-fou (erreur 1451), plutôt que de compter d'abord les ordonnances : entre le comptage et la suppression, un autre poste pourrait en créer une. On teste le code d'erreur, pas le texte du message.
Tu avais trouvé ?
Bug 1 : le compteur qui plante
Exercice 5 sur 9 InvalidCastException Réussi À revoir
La barre d'état doit afficher le nombre de patients. L'application plante au chargement avec « Unable to cast object of type 'System.Int64' to type 'System.Int32' ». Trouve la ligne fautive et corrige-la.
public async Task<int> CompterAsync()
{
await using MySqlConnection conn = await connexion.OuvrirAsync();
await using var cmd = new MySqlCommand("SELECT COUNT(*) FROM patient", conn);
return (int)(await cmd.ExecuteScalarAsync())!;
}
Voir la réponseMasquer la réponse de l'exercice 5
COUNT(*) renvoie un entier 64 bits (BIGINT) : ExecuteScalarAsync renvoie un long emballé dans un object. Un object qui contient un long ne se convertit pas directement en int : il faut d'abord le déballer en long.
long nombre = (long)(await cmd.ExecuteScalarAsync())!;
return (int)nombre;
// ou : return Convert.ToInt32(await cmd.ExecuteScalarAsync());
Tu avais trouvé ?
Bug 2 : la modification refusée
Exercice 6 sur 9 « Un autre patient a déjà ce numéro » Réussi À revoir
Après une modification du service, il est devenu impossible de corriger le prénom d'un patient qui a un numéro de sécurité sociale : l'enregistrement est refusé avec « Un autre patient a déjà ce numéro de sécurité sociale. » Voici la nouvelle requête du DAO. Explique et corrige. Quel test aurait détecté le problème ?
const string sql = "SELECT COUNT(*) FROM patient WHERE numeroSecu = @secu";
Voir la réponseMasquer la réponse de l'exercice 6
La requête compte tous les patients qui ont ce numéro, y compris le patient en cours de modification : sa propre ligne est trouvée, et la règle croit à un doublon. Il faut exclure le patient modifié :
const string sql = "SELECT COUNT(*) FROM patient WHERE numeroSecu = @secu AND numPatient <> @sauf";
avec le paramètre @sauf = patient.NumPatient (0 pour un nouveau patient, qui n'exclut personne). Le test Enregistrer_ModificationDuMemePatient_GardeSonNumeroSecu du chapitre 13 protège cette règle côté service ; pour le SQL lui-même, il faudrait un test d'intégration sur une base de test.
Tu avais trouvé ?
Lire du code
Exercice 7 sur 9 Que s'affiche-t-il ? Réussi À revoir
Qu'affiche ce code dans la console ?
var o = new Ordonnance { NumMedecin = 1, NumPatient = 3 };
string[] codes = ["PARA1000", "AMOX500", "PARA1000", "IBUP400"];
int[] durees = [5, 120, 3, 7];
for (int i = 0; i < codes.Length; i++)
{
try
{
o.AjouterLigne(new LigneOrdonnance(codes[i], "1 comprimé", durees[i]));
Console.WriteLine($"{codes[i]} ajouté");
}
catch (RegleMetierException ex)
{
Console.WriteLine(ex.Message);
}
}
Console.WriteLine(o.Lignes.Count);
Voir la réponseMasquer la réponse de l'exercice 7
PARA1000 ajouté
La durée doit être comprise entre 1 et 90 jours.
Le médicament PARA1000 figure déjà sur l'ordonnance.
IBUP400 ajouté
2
AMOX500 est refusé à cause de sa durée (120 jours), le second PARA1000 parce qu'il est déjà présent. Chaque exception est interceptée dans la boucle, qui continue avec l'élément suivant. Il reste deux lignes.
Tu avais trouvé ?
Exercice 8 sur 9 L'ordre d'exécution Réussi À revoir
Le label lbl est vide. L'utilisateur clique une fois. Que contient lbl.Text à la fin ?
private async void btnTest_Click(object? sender, EventArgs e)
{
lbl.Text = "1";
Task<int> t = CalculerAsync();
lbl.Text += "2";
int r = await t;
lbl.Text += r;
}
private async Task<int> CalculerAsync()
{
lbl.Text += "3";
await Task.Delay(100);
lbl.Text += "4";
return 5;
}
Voir la réponseMasquer la réponse de l'exercice 8
13245. Le gestionnaire écrit « 1 », puis appelle CalculerAsync, qui s'exécute immédiatement jusqu'à son premier await : « 13 ». CalculerAsync rend la main en renvoyant une tâche non terminée, le gestionnaire continue : « 132 ». Il attend la tâche (await t) et rend la main à la boucle de messages. 100 ms plus tard, CalculerAsync reprend sur le thread de l'interface : « 1324 », puis renvoie 5, ce qui termine la tâche ; le gestionnaire reprend et ajoute 5 : « 13245 ».
Tu avais trouvé ?
Questions d'entretien
À l'E6, la commission te fait expliquer ton code. Entraîne-toi à répondre à voix haute en deux ou trois phrases, avec le vocabulaire juste.
Exercice 9 sur 9 Justifier ses choix Réussi À revoir
Réponds à ces questions qu'une commission pourrait te poser sur GSB Ordonnances.
- « Pourquoi passez-vous les valeurs en paramètres au lieu de les mettre dans la chaîne SQL ? »
- « Votre méthode de chargement est
async. Qu'est-ce que ça change pour l'utilisateur ? » - « Où est vérifié que deux patients n'ont pas le même numéro de sécurité sociale ? »
- « Pourquoi le formulaire ne contient-il aucune requête SQL ? »
- « Comment sont stockés les mots de passe des utilisateurs ? »
Voir la réponseMasquer la réponse de l'exercice 9
- « Une requête paramétrée envoie les valeurs séparément du texte SQL : elles ne peuvent jamais être interprétées comme du code. C'est la protection contre l'injection SQL, et en plus une apostrophe dans un nom, comme D'Alembert, ne casse plus la requête. »
- « Pendant la requête, le thread de l'interface est libéré grâce à
await: la fenêtre continue de se redessiner et de répondre, au lieu d'afficher “Ne répond pas”. Je désactive le bouton pendant le chargement pour éviter les doubles clics. » - « À trois niveaux : le service métier interroge la base avant d'enregistrer et lève une exception de règle métier, que le formulaire affiche ; et la base a une contrainte
UNIQUE, qui protège même si deux postes enregistrent en même temps. J'ai un test unitaire sur la règle du service. » - « J'ai une architecture en couches : le formulaire appelle un service métier, qui passe par une interface de dépôt implémentée par un DAO MySQL. Changer de base ou passer par une API ne touche pas l'interface, et le métier se teste sans base. »
- « Jamais en clair : je stocke une empreinte bcrypt avec un facteur de coût 12, qui contient un sel aléatoire. À la connexion,
BCrypt.Verifyrecalcule l'empreinte du mot de passe saisi et la compare. Même si la table fuit, les mots de passe ne sont pas directement lisibles. »
Tu avais trouvé ?
La liste de vérification avant l'épreuve
- La solution compile sans erreur ni avertissement, en .NET 10.
- Aucune chaîne de connexion ni mot de passe dans le code ou dans le dépôt Git.
- Toutes les requêtes sont paramétrées ; le compte MySQL de l'application n'est pas
root. - Aucune requête SQL dans les formulaires.
- Les chargements sont asynchrones et les gestionnaires
async voidont untry … catch. - Les saisies sont validées (
ErrorProvider) et les règles vérifiées dans la couche métier. - Les erreurs MySQL courantes (doublon, clé étrangère, serveur injoignable) produisent un message compréhensible.
- Les tests unitaires passent (
dotnet test). - L'application publiée a été lancée sur un poste qui n'a pas servi au développement.
- Tu sais expliquer chaque couche, chaque requête et chaque choix technique.