Към съдържанието
wannacode

Упражнение: разхвърлян клас, изчистен стъпка по стъпка

Накратко: Упражнение по refactoring: клас за наем на тротинетки се изчиства на пет малки стъпки (имена, магически числа, пари, въпрос отделно от командата, планове), а тестовете остават зелени.

Проблем

Тротинетките, които никой не смее да пипне

Фирма за тротинетки под наем таксува всяко возене с класа RideMgr. Работи от три години. Методът calc смята цената по плана на клиента (на минута, студент или месечна карта), маха таксата за отключване от четвъртото возене за деня, слага нощна добавка, трупа оборота и праща бележка:

export class RideMgr {
constructor() {
this.c = new Map();
this.s = 0;
this.out = [];
}
// Цената: 1 € за отключване и 0,15 € на минута.
// Нощем цената се вдига с 50%.
calc(to, t, m, f) {
this.c.set(to, (this.c.get(to) ?? 0) + 1);
let p = 0;
if (t === "payg") {
p = 1 + m * 0.2;
} else if (t === "student") {
p = (1 + m * 0.2) * 0.8;
} else if (t === "monthly") {
if (m > 45) {
p = (m - 45) * 0.2;
}
}
if (this.c.get(to) > 3 && t !== "monthly") {
p = p - 1;
}
if (f) {
p = p + 0.5;
}
this.s = this.s + p;
this.out.push(`${to}: ${m} мин, ${p.toFixed(2)} €`);
return p;
}
}

Следващия месец идват две промени:

  • фирмен план: без такса за отключване, 0,18 € на минута, без отстъпка за четвъртото возене;
  • цената преди края: приложението показва колко ще струва возенето, докато то още тече.

Втората не става с този клас. Ето какво се случва, ако приложението извика calc, за да покаже цената, и после пак, за да вземе парите:

const rides = new RideMgr();
const shown = rides.calc("ana@example.com", "payg", 10, false);
console.log(`Цена преди края: ${shown.toFixed(2)} €`);
const charged = rides.calc("ana@example.com", "payg", 10, false);
console.log(`Взето: ${charged.toFixed(2)} €`);
console.log(`Бележки: ${rides.out.length}`);
console.log(`Оборот: ${rides.s.toFixed(2)} €`);
Изход
Цена преди края: 3.00 €
Взето: 3.00 €
Бележки: 2
Оборот: 6.00 €

Едно возене, две бележки и 6 € оборот. calc звучи като въпрос, а брои, трупа и праща.

В урока няма нова идея. Той е упражнение: класът минава през инструментите от модула един по един, на малки стъпки, а след всяка стъпка пускаш тестовете.

Виж го

Стъпка 0: мрежата

Първо мрежата, както в урока за тестовете. Девет теста заковават какво прави класът днес: цената по всеки план, нощта, четвъртото возене, бележката и оборота.

import { test, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { RideMgr } from "./rides.js";
const EMAIL = "ana@example.com";
let rides;
beforeEach(() => {
rides = new RideMgr();
});
function ride(plan, minutes, night) {
return money(rides.calc(EMAIL, plan, minutes, night));
}
function money(amount) {
return amount.toFixed(2);
}
test("на минута", () => {
assert.equal(ride("payg", 10, false), "3.00");
});
test("студент", () => {
assert.equal(ride("student", 10, false), "2.40");
});
test("месечна карта до 45 мин", () => {
assert.equal(ride("monthly", 30, false), "0.00");
});
test("месечна карта над 45 мин", () => {
assert.equal(ride("monthly", 60, false), "3.00");
});
test("нощем", () => {
assert.equal(ride("payg", 10, true), "3.50");
});
test("четвъртото возене без отключване", () => {
ride("payg", 10, false);
ride("payg", 10, false);
ride("payg", 10, false);
assert.equal(ride("payg", 10, false), "2.00");
});
test("студент на четвъртото возене (така е днес)", () => {
// Така е днес: отстъпката е целият 1 €, а не 0,80 €.
ride("student", 10, false);
ride("student", 10, false);
ride("student", 10, false);
assert.equal(ride("student", 10, false), "1.40");
});
test("бележката", () => {
ride("payg", 10, false);
assert.deepEqual(rides.out, ["ana@example.com: 10 мин, 3.00 €"]);
});
test("оборотът", () => {
ride("payg", 10, false);
ride("student", 10, false);
assert.equal(money(rides.s), "5.40");
});
Резултат
ОК бележката
ОК месечна карта до 45 мин
ОК месечна карта над 45 мин
ОК на минута
ОК нощем
ОК оборотът
ОК студент
ОК студент на четвъртото возене (така е днес)
ОК четвъртото возене без отключване
Тестове: 9, минали: 9, паднали: 0

Тестовете викат класа само през помощника ride. Когато в следващите стъпки формата на класа се смени, ще се сменят той и извикванията, а очакваните стойности никога.

Единият тест е странен. Студентът плаща 80% от цената, значи и отключването му струва 0,80 €. На четвъртото возене обаче класът маха целия 1 €, и студентът плаща 1,40 € вместо 1,60 €. Грешка ли е, или подарък? Не решаваш ти и не решаваш сега. Тестът заковава какво е днес, с коментар, а въпросът отива при собственика на продукта. Докато чистиш, поведението не се сменя, дори когато изглежда грешно.

По-точно, за любопитните

Сумите се сравняват като текст с две цифри ("2.40"), както ги вижда клиентът. Сравнение с число щеше да се препъне в малките двоични грешки на number:

console.log((1 + 10 * 0.2) * 0.8);
Изход
2.4000000000000004

А в стъпка 3 типът на сумите ще се смени. Текстът с две цифри е един и същ преди и след.

Виж го

Стъпка 1: имената и флагът

t, m, f, p, c и s стават думи (имената). Най-важното име е на метода: той не смята, а приключва возенето. Честното име е finishRide, и страничният ефект вече се вижда от името. Класът става RideBilling.

export class RideBilling {
constructor() {
this.ridesToday = new Map();
this.revenue = 0;
this.receipts = [];
}
// Цената: 1 € за отключване и 0,15 € на минута.
// Нощем цената се вдига с 50%.
finishRide(email, plan, minutes, { night = false } = {}) {
this.ridesToday.set(email, (this.ridesToday.get(email) ?? 0) + 1);
let price = 0;
if (plan === "payg") {
price = 1 + minutes * 0.2;
} else if (plan === "student") {
price = (1 + minutes * 0.2) * 0.8;
} else if (plan === "monthly") {
if (minutes > 45) {
price = (minutes - 45) * 0.2;
}
}
if (this.ridesToday.get(email) > 3 && plan !== "monthly") {
price = price - 1;
}
if (night) {
price = price + 0.5;
}
this.revenue = this.revenue + price;
this.receipts.push(`${email}: ${minutes} мин, ${price.toFixed(2)} €`);
return price;
}
}

Остава флагът (функциите). true при извикването не казва нищо. Нощта е данни за возенето, а не избор на друга работа, затова не става две функции. Извикването трябва да казва какво значи:

В JavaScript нощта идва в обект с опции, { night: true }, а денят е без него.
Преди
function ride(plan, minutes, night) {
return money(rides.calc(EMAIL, plan, minutes, night));
}
test("нощем", () => {
assert.equal(ride("payg", 10, true), "3.50");
});
След
function ride(plan, minutes, options) {
return money(billing.finishRide(EMAIL, plan, minutes, options));
}
test("нощем", () => {
assert.equal(ride("payg", 10, { night: true }), "3.50");
});
Тестовете
ОК бележката
ОК месечна карта до 45 мин
ОК месечна карта над 45 мин
ОК на минута
ОК нощем
ОК оборотът
ОК студент
ОК студент на четвъртото возене (така е днес)
ОК четвъртото возене без отключване
Тестове: 9, минали: 9, паднали: 0
Във VS Code преименуването е F2. За полетата на обекта (this.s) редакторът не винаги знае кой обект е кой, затова тестовете тук казват повече, отколкото в Java или C#.

Виж го

Стъпка 2: числата и коментарът, който лъже

1.0, 0.2, 0.8, 45, 3 и 0.5 стават константи с имена (магическо число → константа).

Коментарът над метода казва 0,15 € на минута и 50% нощна надценка. Кодът взима 0,20 € и добавя 0,50 €. Кое е вярно? Тестовете заковаха какво прави кодът, а клиентите плащат по него от години. Коментарът лъже (коментарите) и се трие. Константите казват същото и не могат да остареят отделно от кода.

const UNLOCK_FEE = 1;
const PER_MINUTE = 0.2;
const STUDENT_SHARE = 0.8;
const MONTHLY_FREE_MINUTES = 45;
const PAID_UNLOCKS_PER_DAY = 3;
const NIGHT_SURCHARGE = 0.5;
export class RideBilling {
constructor() {
this.ridesToday = new Map();
this.revenue = 0;
this.receipts = [];
}
finishRide(email, plan, minutes, { night = false } = {}) {
this.ridesToday.set(email, (this.ridesToday.get(email) ?? 0) + 1);
let price = 0;
if (plan === "payg") {
price = UNLOCK_FEE + minutes * PER_MINUTE;
} else if (plan === "student") {
price = (UNLOCK_FEE + minutes * PER_MINUTE) * STUDENT_SHARE;
} else if (plan === "monthly") {
if (minutes > MONTHLY_FREE_MINUTES) {
price = (minutes - MONTHLY_FREE_MINUTES) * PER_MINUTE;
}
}
if (this.ridesToday.get(email) > PAID_UNLOCKS_PER_DAY && plan !== "monthly") {
price = price - UNLOCK_FEE;
}
if (night) {
price = price + NIGHT_SURCHARGE;
}
this.revenue = this.revenue + price;
this.receipts.push(`${email}: ${minutes} мин, ${price.toFixed(2)} €`);
return price;
}
}
Тестовете
ОК бележката
ОК месечна карта до 45 мин
ОК месечна карта над 45 мин
ОК на минута
ОК нощем
ОК оборотът
ОК студент
ОК студент на четвъртото возене (така е днес)
ОК четвъртото возене без отключване
Тестове: 9, минали: 9, паднали: 0

Тестовете не се пипат: нито името, нито извикванията са се сменили.

Константите са на ниво модул, над класа: така ги вижда и всеки друг код във файла, а в стъпка 5 и плановете.

Пробвай

Намери грешката

Колега прави същата стъпка, но вярва на коментара и го оставя:

Избери реда с грешката.

По-точно, за любопитните
Тестовете
ОК месечна карта до 45 мин
ПАДА бележката
ПАДА месечна карта над 45 мин
ПАДА на минута
ПАДА нощем
ПАДА оборотът
ПАДА студент
ПАДА студент на четвъртото возене (така е днес)
ПАДА четвъртото возене без отключване
Тестове: 9, минали: 1, паднали: 8

Виж го

Стъпка 3: парите

Парите в дробно число са примитиви вместо типове (миризмите). Днес това не дава грешен цент в нито един тест, защото всяка сума се закръгля чак при печатане. Затова стъпката е refactoring: изходът е същият. Но оборотът е сбор от хиляди такива суми, и там малките грешки се трупат.

В JavaScript десетичен тип няма, затова парите стават цели центове. Имената казват мерната единица (_CENTS), студентската цена е 80 процента от центовете, закръглени с Math.round, а функцията euros превръща центовете в евро чак при печатане.
const UNLOCK_FEE_CENTS = 100;
const PER_MINUTE_CENTS = 20;
const STUDENT_PERCENT = 80;
const MONTHLY_FREE_MINUTES = 45;
const PAID_UNLOCKS_PER_DAY = 3;
const NIGHT_SURCHARGE_CENTS = 50;
function euros(cents) {
return (cents / 100).toFixed(2);
}
export class RideBilling {
constructor() {
this.ridesToday = new Map();
this.revenue = 0;
this.receipts = [];
}
finishRide(email, plan, minutes, { night = false } = {}) {
this.ridesToday.set(email, (this.ridesToday.get(email) ?? 0) + 1);
let price = 0;
if (plan === "payg") {
price = UNLOCK_FEE_CENTS + minutes * PER_MINUTE_CENTS;
} else if (plan === "student") {
price = Math.round(((UNLOCK_FEE_CENTS + minutes * PER_MINUTE_CENTS) * STUDENT_PERCENT) / 100);
} else if (plan === "monthly") {
if (minutes > MONTHLY_FREE_MINUTES) {
price = (minutes - MONTHLY_FREE_MINUTES) * PER_MINUTE_CENTS;
}
}
if (this.ridesToday.get(email) > PAID_UNLOCKS_PER_DAY && plan !== "monthly") {
price = price - UNLOCK_FEE_CENTS;
}
if (night) {
price = price + NIGHT_SURCHARGE_CENTS;
}
this.revenue = this.revenue + price;
this.receipts.push(`${email}: ${minutes} мин, ${euros(price)} €`);
return price;
}
}
В тестовете се сменя само помощникът, който превръща сумата в текст:
function money(cents) {
return (cents / 100).toFixed(2);
}
Тестовете
ОК бележката
ОК месечна карта до 45 мин
ОК месечна карта над 45 мин
ОК на минута
ОК нощем
ОК оборотът
ОК студент
ОК студент на четвъртото возене (така е днес)
ОК четвъртото возене без отключване
Тестове: 9, минали: 9, паднали: 0

Пробвай

Попълни

Цената на минута в типа за пари на езика:

const PER_MINUTE_CENTS = ;

Виж го

Стъпка 4: въпросът отделно от командата

Сега промяната, която чакаше: цената преди края трябва да е въпрос, който не сменя нищо (команда или въпрос). Изчисляването се изважда в метод price (Extract Method от урока за refactoring). finishRide остава команда: пита за цената, после брои, трупа и праща.

Колега го прави така. Броячът отива след цената, защото возенето се брои, когато е приключило:

Какво казват деветте теста?

price(email, plan, minutes, { night = false } = {}) {
let price = 0;
if (plan === "payg") {
price = UNLOCK_FEE_CENTS + minutes * PER_MINUTE_CENTS;
} else if (plan === "student") {
price = Math.round(((UNLOCK_FEE_CENTS + minutes * PER_MINUTE_CENTS) * STUDENT_PERCENT) / 100);
} else if (plan === "monthly") {
if (minutes > MONTHLY_FREE_MINUTES) {
price = (minutes - MONTHLY_FREE_MINUTES) * PER_MINUTE_CENTS;
}
}
if ((this.ridesToday.get(email) ?? 0) > PAID_UNLOCKS_PER_DAY && plan !== "monthly") {
price = price - UNLOCK_FEE_CENTS;
}
if (night) {
price = price + NIGHT_SURCHARGE_CENTS;
}
return price;
}
finishRide(email, plan, minutes, { night = false } = {}) {
const price = this.price(email, plan, minutes, { night });
this.ridesToday.set(email, (this.ridesToday.get(email) ?? 0) + 1);
this.revenue = this.revenue + price;
this.receipts.push(`${email}: ${minutes} мин, ${euros(price)} €`);
return price;
}
Отговор
По-точно, за любопитните
Тестовете
ОК бележката
ОК месечна карта до 45 мин
ОК месечна карта над 45 мин
ОК на минута
ОК нощем
ОК оборотът
ОК студент
ПАДА студент на четвъртото возене (така е днес): 2.40 !== 1.40
ПАДА четвъртото возене без отключване: 3.00 !== 2.00
Тестове: 9, минали: 7, паднали: 2

Виж го

Стъпка 4, втори опит

Червеното сочи последната стъпка. Не търсиш грешката в целия клас: връщаш стъпката и я правиш пак. Редът на страничните ефекти беше скрита част от поведението, и знаеха за нея само тестовете.

price смята кое поред ще е возенето:

price(email, plan, minutes, { night = false } = {}) {
const rideOfDay = (this.ridesToday.get(email) ?? 0) + 1;
let price = 0;
if (plan === "payg") {
price = UNLOCK_FEE_CENTS + minutes * PER_MINUTE_CENTS;
} else if (plan === "student") {
price = Math.round(((UNLOCK_FEE_CENTS + minutes * PER_MINUTE_CENTS) * STUDENT_PERCENT) / 100);
} else if (plan === "monthly") {
if (minutes > MONTHLY_FREE_MINUTES) {
price = (minutes - MONTHLY_FREE_MINUTES) * PER_MINUTE_CENTS;
}
}
if (rideOfDay > PAID_UNLOCKS_PER_DAY && plan !== "monthly") {
price = price - UNLOCK_FEE_CENTS;
}
if (night) {
price = price + NIGHT_SURCHARGE_CENTS;
}
return price;
}
finishRide(email, plan, minutes, { night = false } = {}) {
const price = this.price(email, plan, minutes, { night });
this.ridesToday.set(email, (this.ridesToday.get(email) ?? 0) + 1);
this.revenue = this.revenue + price;
this.receipts.push(`${email}: ${minutes} мин, ${euros(price)} €`);
return price;
}
Тестовете
ОК бележката
ОК месечна карта до 45 мин
ОК месечна карта над 45 мин
ОК на минута
ОК нощем
ОК оборотът
ОК студент
ОК студент на четвъртото возене (така е днес)
ОК четвъртото возене без отключване
Тестове: 9, минали: 9, паднали: 0

Тестовете пак не се пипат: те викат finishRide, а то прави същото като преди.

Целият клас след стъпка 4
const UNLOCK_FEE_CENTS = 100;
const PER_MINUTE_CENTS = 20;
const STUDENT_PERCENT = 80;
const MONTHLY_FREE_MINUTES = 45;
const PAID_UNLOCKS_PER_DAY = 3;
const NIGHT_SURCHARGE_CENTS = 50;
function euros(cents) {
return (cents / 100).toFixed(2);
}
export class RideBilling {
constructor() {
this.ridesToday = new Map();
this.revenue = 0;
this.receipts = [];
}
price(email, plan, minutes, { night = false } = {}) {
const rideOfDay = (this.ridesToday.get(email) ?? 0) + 1;
let price = 0;
if (plan === "payg") {
price = UNLOCK_FEE_CENTS + minutes * PER_MINUTE_CENTS;
} else if (plan === "student") {
price = Math.round(((UNLOCK_FEE_CENTS + minutes * PER_MINUTE_CENTS) * STUDENT_PERCENT) / 100);
} else if (plan === "monthly") {
if (minutes > MONTHLY_FREE_MINUTES) {
price = (minutes - MONTHLY_FREE_MINUTES) * PER_MINUTE_CENTS;
}
}
if (rideOfDay > PAID_UNLOCKS_PER_DAY && plan !== "monthly") {
price = price - UNLOCK_FEE_CENTS;
}
if (night) {
price = price + NIGHT_SURCHARGE_CENTS;
}
return price;
}
finishRide(email, plan, minutes, { night = false } = {}) {
const price = this.price(email, plan, minutes, { night });
this.ridesToday.set(email, (this.ridesToday.get(email) ?? 0) + 1);
this.revenue = this.revenue + price;
this.receipts.push(`${email}: ${minutes} мин, ${euros(price)} €`);
return price;
}
}

Виж го

Стъпка 5: плановете

Остава веригата от if по текста на плана (дълга верига от if). Всеки нов план е нов клон, а "monthly" се проверява на две места. Всеки план става клас, който знае цената си и отстъпката за четвъртото возене. Това е Replace Conditional with Polymorphism от урока за refactoring:

export class PayAsYouGo {
price(minutes) {
return UNLOCK_FEE_CENTS + minutes * PER_MINUTE_CENTS;
}
loyaltyDiscount() {
return UNLOCK_FEE_CENTS;
}
}
export class Student {
price(minutes) {
return Math.round((new PayAsYouGo().price(minutes) * STUDENT_PERCENT) / 100);
}
loyaltyDiscount() {
// Така е днес: целият 1 €, макар че отключването на студента струва 0,80 €.
return UNLOCK_FEE_CENTS;
}
}
export class Monthly {
price(minutes) {
return Math.max(0, minutes - MONTHLY_FREE_MINUTES) * PER_MINUTE_CENTS;
}
loyaltyDiscount() {
return 0;
}
}

Проверката за месечната карта изчезва: тя просто връща 0 отстъпка. Странното поведение на студента вече е на един ред, с коментар, който казва защо е така (добрият коментар от урока за коментарите).

const UNLOCK_FEE_CENTS = 100;
const PER_MINUTE_CENTS = 20;
const STUDENT_PERCENT = 80;
const MONTHLY_FREE_MINUTES = 45;
const PAID_UNLOCKS_PER_DAY = 3;
const NIGHT_SURCHARGE_CENTS = 50;
function euros(cents) {
return (cents / 100).toFixed(2);
}
export class RideBilling {
constructor() {
this.ridesToday = new Map();
this.revenue = 0;
this.receipts = [];
}
price(email, plan, minutes, { night = false } = {}) {
const rideOfDay = (this.ridesToday.get(email) ?? 0) + 1;
let price = plan.price(minutes);
if (rideOfDay > PAID_UNLOCKS_PER_DAY) {
price = price - plan.loyaltyDiscount();
}
if (night) {
price = price + NIGHT_SURCHARGE_CENTS;
}
return price;
}
finishRide(email, plan, minutes, { night = false } = {}) {
const price = this.price(email, plan, minutes, { night });
this.ridesToday.set(email, (this.ridesToday.get(email) ?? 0) + 1);
this.revenue = this.revenue + price;
this.receipts.push(`${email}: ${minutes} мин, ${euros(price)} €`);
return price;
}
}
Тук няма интерфейс: всеки план просто има методите price и loyaltyDiscount, а класът за таксуването ги вика (duck typing). Константите остават на ниво модул.

В тестовете извикванията подават обект вместо текст:

assert.equal(ride("payg", 10, { night: true }), "3.50");
assert.equal(ride(new PayAsYouGo(), 10, { night: true }), "3.50");
Тестовете
ОК бележката
ОК месечна карта до 45 мин
ОК месечна карта над 45 мин
ОК на минута
ОК нощем
ОК оборотът
ОК студент
ОК студент на четвъртото возене (така е днес)
ОК четвъртото возене без отключване
Тестове: 9, минали: 9, паднали: 0

Това е Strategy от модула за шаблоните: правилото е отделен обект, а таксуването само пита.

Виж го

Двете промени сега

Фирменият план е нов клас. Класът за таксуването не се пипа (отворено за разширение):

const BUSINESS_PER_MINUTE_CENTS = 18;
export class Business {
price(minutes) {
return minutes * BUSINESS_PER_MINUTE_CENTS;
}
loyaltyDiscount() {
return 0;
}
}

Цената преди края е въпросът price. Колкото пъти и да го викаш, нищо не се брои и не тръгва бележка:

const billing = new RideBilling();
const payg = new PayAsYouGo();
billing.finishRide("ana@example.com", payg, 10);
billing.finishRide("ana@example.com", payg, 5);
billing.finishRide("ana@example.com", payg, 12, { night: true });
console.log(`Цена преди края: ${(billing.price("ana@example.com", payg, 8) / 100).toFixed(2)} €`);
console.log(`Цена преди края: ${(billing.price("ana@example.com", payg, 8) / 100).toFixed(2)} €`);
console.log(`Взето: ${(billing.finishRide("ana@example.com", payg, 8) / 100).toFixed(2)} €`);
console.log(`Фирмата, 12 мин: ${(billing.finishRide("office@example.com", new Business(), 12) / 100).toFixed(2)} €`);
console.log(`Бележки: ${billing.receipts.length}`);
Изход
Цена преди края: 1.60 €
Цена преди края: 1.60 €
Взето: 1.60 €
Фирмата, 12 мин: 2.16 €
Бележки: 5

Пет бележки: три возения преди прегледа, четвъртото и това на фирмата. Двата прегледа не оставят следа, а показват точно колкото после се взима.

Идеята

Преди и след

Миризма Стъпка Урок
нищо не пази поведението 0: тестове, които заковават днешното мрежата
имена от една буква, calc, който не казва, че праща 1: Rename имената
true при извикването 1: аргумент, който казва какво значи функциите
магически числа 2: константи с имена имената
коментар, който лъже 2: изтрит коментарите
пари в дробно число 3: тип за пари миризмите
въпрос със скрит страничен ефект, дълъг метод 4: Extract Method, команда и въпрос функциите, refactoring
верига от if по плана като текст 5: Replace Conditional with Polymorphism миризмите, refactoring, SOLID

Класът стана по-дълъг, не по-къс. Но всяко правило е на едно място и с име, а промените от началото са по един клас.

Денят на возенията, пуснат с класа отпреди стъпка 1 и с класа след стъпка 5, печата едно и също ред по ред. Тук и двете програми се пускат при всяка проверка на сайта.

Преди
const rides = new RideMgr();
rides.calc("ana@example.com", "payg", 10, false);
rides.calc("ivan@example.com", "student", 25, false);
rides.calc("ana@example.com", "payg", 5, false);
rides.calc("maria@example.com", "monthly", 60, true);
rides.calc("ana@example.com", "payg", 12, true);
rides.calc("ana@example.com", "payg", 8, false);
for (const receipt of rides.out) {
console.log(receipt);
}
console.log(`Оборот: ${rides.s.toFixed(2)} €`);
След
const billing = new RideBilling();
const payg = new PayAsYouGo();
billing.finishRide("ana@example.com", payg, 10);
billing.finishRide("ivan@example.com", new Student(), 25);
billing.finishRide("ana@example.com", payg, 5);
billing.finishRide("maria@example.com", new Monthly(), 60, { night: true });
billing.finishRide("ana@example.com", payg, 12, { night: true });
billing.finishRide("ana@example.com", payg, 8);
for (const receipt of billing.receipts) {
console.log(receipt);
}
console.log(`Оборот: ${(billing.revenue / 100).toFixed(2)} €`);
Изход
ana@example.com: 10 мин, 3.00 €
ivan@example.com: 25 мин, 4.80 €
ana@example.com: 5 мин, 2.00 €
maria@example.com: 60 мин, 3.50 €
ana@example.com: 12 мин, 3.90 €
ana@example.com: 8 мин, 1.60 €
Оборот: 18.80 €

Идеята

Къде да спреш

Спираш, когато промените, които идват, станат лесни. Тук двете промени са един клас и един ред. Затова някои стъпки съзнателно не са направени:

  • бележката в свой клас. Текстът на бележката е втора причина класът да се сменя (една причина за промяна). Днес той не се сменя. Щом маркетингът започне да го пипа всеки месец, става отделен клас (задача 1);
  • изпращането зад интерфейс. Бележките са списък, а не истински имейл. Когато дойде истинският имейл, тестът не бива да го праща, и тогава идва абстракцията (зависимост от абстракция);
  • поведението на студента. То е промяна в поведението, не изчистване. Прави се отделно, със свой тест, когато продуктът реши.

И правилото от урока за refactoring: или сменяш формата, или поведението, никога и двете в една стъпка.

Упражнение

Продължи сам

Реши задачите в редактора си, като тръгнеш от класа след стъпка 5. След всяка стъпка пусни деветте теста.

1. Бележката в свой клас. Изнеси текста на бележката и списъка с изпратените в клас ReceiptBook. Класът за таксуването само му казва „изпрати“. Тестовете не се пипат.

Едно възможно решение
class ReceiptBook {
constructor() {
this.sent = [];
}
send(email, minutes, price) {
this.sent.push(`${email}: ${minutes} мин, ${euros(price)} €`);
}
}
const UNLOCK_FEE_CENTS = 100;
const PER_MINUTE_CENTS = 20;
const STUDENT_PERCENT = 80;
const MONTHLY_FREE_MINUTES = 45;
const PAID_UNLOCKS_PER_DAY = 3;
const NIGHT_SURCHARGE_CENTS = 50;
function euros(cents) {
return (cents / 100).toFixed(2);
}
export class RideBilling {
constructor() {
this.ridesToday = new Map();
this.revenue = 0;
this.receiptBook = new ReceiptBook();
}
price(email, plan, minutes, { night = false } = {}) {
const rideOfDay = (this.ridesToday.get(email) ?? 0) + 1;
let price = plan.price(minutes);
if (rideOfDay > PAID_UNLOCKS_PER_DAY) {
price = price - plan.loyaltyDiscount();
}
if (night) {
price = price + NIGHT_SURCHARGE_CENTS;
}
return price;
}
finishRide(email, plan, minutes, { night = false } = {}) {
const price = this.price(email, plan, minutes, { night });
this.ridesToday.set(email, (this.ridesToday.get(email) ?? 0) + 1);
this.revenue = this.revenue + price;
this.receiptBook.send(email, minutes, price);
return price;
}
get receipts() {
return this.receiptBook.sent;
}
}
Тестовете
ОК бележката
ОК месечна карта до 45 мин
ОК месечна карта над 45 мин
ОК на минута
ОК нощем
ОК оборотът
ОК студент
ОК студент на четвъртото возене (така е днес)
ОК четвъртото возене без отключване
Тестове: 9, минали: 9, паднали: 0

Програмата за деня от картата „Преди и след“ печата с този клас същия изход.

2. Планът от текста. Планът идва от базата като текст ("payg"). Напиши таблицата, която го превръща в обект. Какво правеше старият клас с непознат план? Закови го с тест върху стария клас, преди да решиш какво да прави новият.

3. Нощта от часа. Приложението вече не знае кога е нощ, а подава времето на тръгване. Правилото „от 22:00 до 6:00“ трябва да живее на едно място. Напиши първо тестовете за 21:59, 22:00, 5:59 и 6:00.

4. Студентът. Продуктът реши: на четвъртото возене студентът получава 0,80 €, а не 1 €. Това е промяна в поведението: първо смени очакваното в теста и го виж червен, после смени един ред в плана.

Тест

Провери се

1. Тестът за студента на четвъртото возене очаква 1.40 €, а „правилното“ изглежда 1.60 €. Какво правиш, докато чистиш класа?
2. Коментарът казва 0,15 € на минута, а кодът взима 0,20 €. На кое вярваш?
3. Защо тестовете викат класа през един помощник, а не директно?
4. Първият опит за стъпка 4 направи два теста червени. Какво правиш?
5. След изчистването фирменият план е един нов клас, а класът за таксуването не се пипа. Кой принцип е това?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·