À propos de ce cours
Chapitre 16 — La programmation orientée objet · 16.8 TP 16 : Gérer un panier d'achat en POO
• concevoir un ensemble de classes qui collaborent ;
• appliquer l'encapsulation, les getters/setters, les membres statiques ;
• utiliser l'héritage, le polymorphisme et la composition ;
• valider son travail avec une batterie de tests automatiques.
Présentation
Une boutique en ligne a besoin du « moteur » de son panier d'achat : gérer des produits physiques et numériques, des quantités, des frais de livraison, des codes promo et le calcul du total. Vous allez écrire ce moteur en POO.
Le cahier des charges ci-dessous décrit l'interface publique attendue (ce que les autres développeurs pourront utiliser). Tout le reste vous appartient : à vous de choisir les champs privés, les méthodes privées et l'organisation du code. Un fichier de 39 tests automatiques vous dit à tout moment ce qui fonctionne. Aucune solution n'est donnée.

Figure 1 — Cahier des charges : les classes et leur interface publique.
Mise en place
⬇ Télécharger les fichiers de départ du TP (tp16-depart.zip)
tp16-panier/
├── package.json ← { "type": "module" }
├── tp16-tests.js ← tests fournis : NE PAS MODIFIER
└── src/
└── index.js ← ré-exporte vos classes (voir chapitre 17)
src/index.js (départ)
// =====================================================================
// TP 16 — Point d'entrée : ré-exportez ici vos classes au fur et à mesure.
// Exemple, une fois produit.js écrit :
// export { Produit, ProduitPhysique, ProduitNumerique } from "./produit.js";
// =====================================================================
tp16-tests.js (tests fournis)
// =====================================================================
// TP 16 — Tests automatiques (NE PAS MODIFIER)
// Exécution : node tp16-tests.js
// Votre code doit exporter ses classes depuis src/index.js
// =====================================================================
const M = await import("./src/index.js").catch((e) => {
console.log("Impossible de charger src/index.js :", e.message);
process.exit(1);
});
const { Produit, ProduitPhysique, ProduitNumerique, LignePanier, CodePromo, Panier } = M;
let ok = 0, total = 0;
function test(nom, fn) {
total++;
try {
fn();
ok++;
console.log(" ✓", nom);
} catch (e) {
console.log(" ✗", nom, "→", e.message);
}
}
function egal(obtenu, attendu) {
if (obtenu !== attendu) throw new Error(`attendu ${JSON.stringify(attendu)}, obtenu ${JSON.stringify(obtenu)}`);
}
function leve(fn, TypeErreur) {
try { fn(); } catch (e) {
if (TypeErreur && !(e instanceof TypeErreur)) throw new Error(`erreur ${TypeErreur.name} attendue, ${e.constructor.name} obtenue`);
return;
}
throw new Error("une erreur était attendue");
}
function partie(titre, classe, fn) {
console.log(`\n${titre}`);
if (typeof classe !== "function") { total++; console.log(" ✗ classe non exportée par src/index.js"); return; }
fn();
}
partie("Partie 1 — Produit", Produit, () => {
const p = new Produit("CLV-01", "Clavier", 50);
test("prixTTC avec TVA 19 % par défaut", () => egal(p.prixTTC, 59.5));
test("TVA personnalisée", () => egal(new Produit("X", "Pain", 10, 0.07).prixTTC, 10.7));
test("toString()", () => egal(String(p), "CLV-01 — Clavier : 59.50 DT"));
test("modifier prixHT met à jour prixTTC", () => { p.prixHT = 60; egal(p.prixTTC, 71.4); });
test("prix négatif refusé (RangeError)", () => leve(() => { p.prixHT = -1; }, RangeError));
test("prix non numérique refusé (RangeError)", () => leve(() => new Produit("Y", "Y", "dix"), RangeError));
test("référence en lecture seule", () => { try { p.reference = "AUTRE"; } catch {} egal(p.reference, "CLV-01"); });
test("champs internes invisibles (encapsulation)", () => egal(JSON.stringify(Object.keys(p)), "[]"));
});
partie("Partie 2 — Héritage", ProduitPhysique, () => {
const livre = new ProduitPhysique("LIV-01", "Livre JS", 40, 1.2);
const ebook = ProduitNumerique && new ProduitNumerique("EBK-01", "E-book JS", 20, 8);
test("un ProduitPhysique est un Produit", () => egal(livre instanceof Produit, true));
test("frais de livraison = 5 + 2 × poids", () => egal(livre.fraisLivraison, 7.4));
test("poids nul refusé (RangeError)", () => leve(() => new ProduitPhysique("Z", "Z", 1, 0), RangeError));
test("produit numérique : livraison gratuite", () => egal(ebook.fraisLivraison, 0));
test("produit numérique : lien de téléchargement", () => egal(ebook.lienTelechargement, "https://telechargement.exemple/EBK-01"));
test("produit numérique : toString redéfini", () => egal(String(ebook), "EBK-01 — E-book JS : 23.80 DT [numérique]"));
test("Produit de base : frais 0", () => egal(new Produit("A", "A", 1).fraisLivraison, 0));
});
partie("Partie 3 — LignePanier", LignePanier, () => {
const l = new LignePanier(new ProduitPhysique("CLV-01", "Clavier", 50, 0.8), 2);
test("totalTTC = prixTTC × quantité", () => egal(l.totalTTC, 119));
test("frais de la ligne = frais unitaires × quantité", () => egal(l.fraisLivraison, 13.2));
test("quantité 0 refusée", () => leve(() => { l.quantite = 0; }, RangeError));
test("quantité 1.5 refusée", () => leve(() => { l.quantite = 1.5; }, RangeError));
});
partie("Partie 4 — Panier", Panier, () => {
const clavier = new ProduitPhysique("CLV-01", "Clavier", 50, 0.8);
const livre = new ProduitPhysique("LIV-01", "Livre JS", 40, 1.2);
const ebook = new ProduitNumerique("EBK-01", "E-book JS", 20, 8);
const panier = new Panier();
test("panier vide au départ", () => { egal(panier.estVide, true); egal(panier.total, 0); });
test("ajouter un objet qui n'est pas un Produit est refusé (TypeError)", () => leve(() => panier.ajouter({ nom: "faux" }), TypeError));
panier.ajouter(clavier).ajouter(livre).ajouter(ebook).ajouter(clavier);
test("même produit ajouté deux fois → une seule ligne, quantité 2", () => { egal(panier.lignes.length, 3); egal(panier.nombreArticles, 4); });
test("sous-total TTC", () => egal(panier.sousTotalTTC, 190.4));
test("frais de livraison (somme des lignes)", () => egal(panier.fraisLivraison, 20.6));
test("total", () => egal(panier.total, 211));
test("lignes ne donne pas accès à l'intérieur du panier", () => { panier.lignes.length = 0; egal(panier.lignes.length, 3); });
test("modifierQuantite", () => { panier.modifierQuantite("CLV-01", 1); egal(panier.nombreArticles, 3); });
test("modifierQuantite à 0 retire la ligne", () => { panier.modifierQuantite("EBK-01", 0); egal(panier.lignes.length, 2); });
test("modifierQuantite d'un produit absent → erreur", () => leve(() => panier.modifierQuantite("XXX", 2)));
test("retirer renvoie true puis false", () => { egal(panier.retirer("LIV-01"), true); egal(panier.retirer("LIV-01"), false); });
});
partie("Partie 5 — Codes promo et livraison gratuite", CodePromo, () => {
const panier = new Panier();
panier.ajouter(new ProduitPhysique("CLV-01", "Clavier", 50, 0.8), 2)
.ajouter(new ProduitPhysique("LIV-01", "Livre JS", 40, 1.2))
.ajouter(new ProduitNumerique("EBK-01", "E-book JS", 20, 8));
const promo = new CodePromo("bienvenue10", 10, 100);
test("code mis en majuscules", () => egal(promo.code, "BIENVENUE10"));
test("pourcentage hors limites refusé", () => leve(() => new CodePromo("X", 150), RangeError));
test("code refusé sous le montant minimum", () => leve(() => new Panier().appliquerCode(promo)));
test("remise de 10 %", () => { panier.appliquerCode(promo); egal(panier.remise, 19.04); });
test("total avec remise et livraison", () => egal(panier.total, 191.96));
test("livraison gratuite à partir de 200 DT", () => {
panier.ajouter(new Produit("SRS-01", "Souris", 20));
egal(panier.fraisLivraison, 0);
egal(panier.total, 192.78);
});
test("constante statique du seuil", () => egal(Panier.SEUIL_LIVRAISON_GRATUITE, 200));
test("vider() supprime aussi le code", () => { panier.vider(); egal(panier.remise, 0); egal(panier.estVide, true); });
});
partie("Partie 6 — Récapitulatif", Panier, () => {
const panier = new Panier().ajouter(new Produit("SRS-01", "Souris", 20), 3);
test("recapitulatif()", () => egal(panier.recapitulatif(),
"3 × Souris = 71.40 DT\nSous-total : 71.40 DT\nRemise : -0.00 DT\nLivraison : 0.00 DT\nTOTAL : 71.40 DT"));
});
const TOTAL_TESTS = 39;
console.log(`\nRésultat : ${ok} / ${TOTAL_TESTS} tests réussis${ok === TOTAL_TESTS ? " — bravo, le panier est complet !" : ""}`);
Lancez node tp16-tests.js dès le départ : vous devez obtenir Résultat : 0 / 39 tests réussis. Votre objectif : 39 / 39.
Un test qui échoue affiche ce qui était attendu et ce qui a été obtenu. Lisez ce message avant de modifier votre code. Ne modifiez jamais les tests pour les faire passer : c'est le code qui doit s'adapter au cahier des charges.
🛠 TP · Partie 1 — La classe Produit
Mission : Écrivez src/produit.js avec la classe Produit et exportez-la depuis src/index.js.
Questions pour avancer (répondez dans votre compte rendu, puis appliquez dans votre projet) :
- Quels champs déclarez-vous privés ? Comment l'extérieur pourra-t-il lire la référence et le nom sans pouvoir les modifier ?
prixHTdoit pouvoir être modifié, mais jamais avec une valeur négative ou non numérique. Quel mécanisme de la leçon 16.3 utilisez-vous ? Le constructeur doit-il passer par lui ?prixTTCdoit être arrondi au centime. Où placez-vous la fonction d'arrondi pour pouvoir la réutiliser dans les autres classes ?- Le test « champs internes invisibles » vérifie que
Object.keys(produit)est vide. Qu'est-ce que cela impose ? String(produit)doit donner « CLV-01 — Clavier : 59.50 DT ». Quelle méthode faut-il écrire pour queString()l'utilise ? Comment obtenir toujours deux décimales ?
Je vérifie / je rends :
- ☐ Les 8 tests de la partie 1 passent.
🛠 TP · Partie 2 — L'héritage : produits physiques et numériques
Mission : Ajoutez ProduitPhysique et ProduitNumerique.
Questions pour avancer (répondez dans votre compte rendu, puis appliquez dans votre projet) :
- Que doit faire le constructeur de
ProduitPhysiqueen premier ? Dans quel ordre vérifiez-vous le poids et appelez-vous le parent ? fraisLivraisonexiste dansProduit(0) et dansProduitPhysique(5 + 2 × poids). Comment s'appelle ce mécanisme ? Pourquoi est-il utile queProduitait aussi ce getter ?- Pour
toString()deProduitNumerique, comment ajoutez-vous « [numérique] » sans recopier le code du parent ? - Dans
ProduitPhysique, pouvez-vous lire le champ privé du prix déclaré dansProduit? Comment faites-vous si vous en avez besoin ?
Je vérifie / je rends :
- ☐ Les 7 tests de la partie 2 passent.
🛠 TP · Partie 3 — La ligne de panier
Mission : Écrivez LignePanier, qui associe un produit et une quantité.
Questions pour avancer (répondez dans votre compte rendu, puis appliquez dans votre projet) :
- Une ligne contient un produit : héritage ou composition ? Justifiez avec la leçon 16.7.
- La quantité doit être un entier supérieur ou égal à 1. Quelle méthode de
Numbervérifie qu'une valeur est entière ? - Comment
fraisLivraisonde la ligne obtient-il les frais unitaires sans savoir si le produit est physique ou numérique ? Comment s'appelle ce principe ?
Je vérifie / je rends :
- ☐ Les 4 tests de la partie 3 passent.
🛠 TP · Partie 4 — Le panier
Mission : Écrivez la classe Panier: ajout, retrait, modification des quantités et calcul des totaux.
Questions pour avancer (répondez dans votre compte rendu, puis appliquez dans votre projet) :
- Quelle structure privée choisissez-vous pour stocker les lignes : un tableau ou une
Mapdont la clé est la référence du produit ? Comparez pour « ajouter le même produit deux fois » et « retirer par référence ». ajouterdoit refuser tout ce qui n'est pas unProduit(ou un de ses enfants). Quel opérateur utilisez-vous ? Accepte-t-il unProduitNumerique? Pourquoi ?ajouterdoit renvoyer le panier pour permettrepanier.ajouter(a).ajouter(b). Que renvoie-t-elle ?- Le test « lignes ne donne pas accès à l'intérieur du panier » vide le tableau renvoyé par
panier.lignes. Pourquoi votre panier ne doit-il pas être touché ? Que doit renvoyer le getter ? sousTotalTTC,fraisLivraisonettotal: faut-il les stocker et les mettre à jour à chaque modification, ou les calculer à la demande ? Quel choix évite les incohérences ?- Vérifiez à la main le sous-total du test : 2 claviers à 59,50 DT, un livre à 47,60 DT et un e-book à 23,80 DT. Faites de même pour les frais de livraison.
Je vérifie / je rends :
- ☐ Les 11 tests de la partie 4 passent.
🛠 TP · Partie 5 — Codes promo et livraison gratuite
Mission : Écrivez CodePromo, puis ajoutez au panier la remise et la livraison gratuite.
Questions pour avancer (répondez dans votre compte rendu, puis appliquez dans votre projet) :
- Où doit se trouver la règle « le code exige un montant minimum » : dans
CodePromoou dansPanier? Et le calcul de la remise ? - Le seuil de livraison gratuite (200 DT) est le même pour tous les paniers. Comment le déclarer ? Comment y accéder depuis une méthode du panier ?
- On applique un code avec un panier de 190 DT, puis on retire des articles et le sous-total tombe à 80 DT. Que doit valoir la remise ? Votre code le gère-t-il ?
- Dans le test « livraison gratuite », l'ajout d'une souris fait passer le sous-total au-dessus de 200 DT. Calculez le nouveau total à la main avant de lancer les tests.
- Pourquoi
vider()doit-il aussi oublier le code promo ?
Je vérifie / je rends :
- ☐ Les 8 tests de la partie 5 passent.
🛠 TP · Partie 6 — Récapitulatif et bilan
Mission : Écrivez recapitulatif() et vérifiez que les 39 tests passent.
Questions pour avancer (répondez dans votre compte rendu, puis appliquez dans votre projet) :
- Quelles méthodes et quels getters déjà écrits réutilisez-vous pour produire le récapitulatif ? Avez-vous dû recalculer quelque chose ?
- Faites la liste de toutes les notions du chapitre que vous avez utilisées (classe, getter, setter, statique, #, extends, super, redéfinition, polymorphisme, composition) et indiquez pour chacune où elle apparaît dans votre code.
- Quelle classe a été la plus difficile à écrire ? Pourquoi ?
Je vérifie / je rends :
- ☐
Résultat : 39 / 39 tests réussis.
🛠 TP · Partie 7 — Bonus : sauvegarde et interface
Mission : Rendez le panier persistant et utilisable dans une page web.
Questions pour avancer (répondez dans votre compte rendu, puis appliquez dans votre projet) :
JSON.stringify(panier)donne{}. Pourquoi ? ÉcriveztoJSON()et une fabrique statiquePanier.depuisJSON(texte). Comment savoir s'il faut recréer unProduitPhysiqueou unProduitNumerique?- Sauvegardez le panier dans le
localStorageà chaque modification, et rechargez-le au démarrage de la page. - Créez une page avec un catalogue de 6 produits, des boutons « Ajouter », le tableau du panier (avec + et −), un champ « code promo » et le total. Dans quels fichiers mettez-vous l'affichage, pour que vos classes restent testables dans Node.js ?
- Ajoutez un nouveau type
ProduitFrais(livraison 15 DT, impossible si la quantité dépasse 5). Quelles classes existantes avez-vous dû modifier ?
Je vérifie / je rends :
- ☐ La page fonctionnelle et la réponse à la question 4.
Grille d'évaluation indicative
| Critère | Points |
|---|---|
| Tests automatiques réussis (39 tests, au prorata) | 10 |
| Encapsulation : champs privés, aucune donnée modifiable de l'extérieur | 3 |
| Héritage et polymorphisme bien utilisés (aucun test sur le type dans le panier) | 3 |
| Organisation : un fichier par classe, code lisible et commenté | 2 |
| Compte rendu : réponses aux questions et vérifications à la main | 2 |
| Total (bonus partie 7 : jusqu'à +3) | 20 |
← 16.7 Polymorphisme et composition