Avez-vous une question? (216) 97 656 803 hajjriadh@gmail.com
JavaScript

TP 16 : Gérer un panier d'achat en POO

Gérer un panier d'achat en POO. Dans cette leçon du cours JavaScript, vous apprendrez à : concevoir un ensemble de classes qui collaborent ; appliquer l'encapsulation, les getters/setters, les membres...

0 chapitres 0 QCM Accès gratuit
Dans cet article

À propos de ce cours

Gérer un panier d'achat en POO. Dans cette leçon du cours JavaScript, vous apprendrez à : 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.

Chapitre 16 — La programmation orientée objet · 16.8 TP 16 : Gérer un panier d'achat en POO

🎯 Objectifs de la leçon
• 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.

Diagramme des classes du panier d'achat
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.

ℹ️ À retenir
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) :

  1. 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 ?
  2. prixHT doit 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 ?
  3. prixTTC doit être arrondi au centime. Où placez-vous la fonction d'arrondi pour pouvoir la réutiliser dans les autres classes ?
  4. Le test « champs internes invisibles » vérifie que Object.keys(produit) est vide. Qu'est-ce que cela impose ?
  5. String(produit) doit donner « CLV-01 — Clavier : 59.50 DT ». Quelle méthode faut-il écrire pour que String() 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) :

  1. Que doit faire le constructeur de ProduitPhysique en premier ? Dans quel ordre vérifiez-vous le poids et appelez-vous le parent ?
  2. fraisLivraison existe dans Produit (0) et dans ProduitPhysique (5 + 2 × poids). Comment s'appelle ce mécanisme ? Pourquoi est-il utile que Produit ait aussi ce getter ?
  3. Pour toString() de ProduitNumerique, comment ajoutez-vous « [numérique] » sans recopier le code du parent ?
  4. Dans ProduitPhysique, pouvez-vous lire le champ privé du prix déclaré dans Produit? 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) :

  1. Une ligne contient un produit : héritage ou composition ? Justifiez avec la leçon 16.7.
  2. La quantité doit être un entier supérieur ou égal à 1. Quelle méthode de Number vérifie qu'une valeur est entière ?
  3. Comment fraisLivraison de 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) :

  1. Quelle structure privée choisissez-vous pour stocker les lignes : un tableau ou une Map dont la clé est la référence du produit ? Comparez pour « ajouter le même produit deux fois » et « retirer par référence ».
  2. ajouter doit refuser tout ce qui n'est pas un Produit (ou un de ses enfants). Quel opérateur utilisez-vous ? Accepte-t-il un ProduitNumerique? Pourquoi ?
  3. ajouter doit renvoyer le panier pour permettre panier.ajouter(a).ajouter(b). Que renvoie-t-elle ?
  4. 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 ?
  5. sousTotalTTC, fraisLivraison et total: faut-il les stocker et les mettre à jour à chaque modification, ou les calculer à la demande ? Quel choix évite les incohérences ?
  6. 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) :

  1. Où doit se trouver la règle « le code exige un montant minimum » : dans CodePromo ou dans Panier? Et le calcul de la remise ?
  2. 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 ?
  3. 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 ?
  4. 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.
  5. 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) :

  1. Quelles méthodes et quels getters déjà écrits réutilisez-vous pour produire le récapitulatif ? Avez-vous dû recalculer quelque chose ?
  2. 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.
  3. 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) :

  1. JSON.stringify(panier) donne {}. Pourquoi ? Écrivez toJSON() et une fabrique statique Panier.depuisJSON(texte). Comment savoir s'il faut recréer un ProduitPhysique ou un ProduitNumerique?
  2. Sauvegardez le panier dans le localStorage à chaque modification, et rechargez-le au démarrage de la page.
  3. 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 ?
  4. 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èrePoints
Tests automatiques réussis (39 tests, au prorata)10
Encapsulation : champs privés, aucune donnée modifiable de l'extérieur3
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 main2
Total (bonus partie 7 : jusqu'à +3)20

← 16.7 Polymorphisme et composition