Промпт: рефакторинг легаси-кода по шагам
Разбор неочевидного поведения, характеризационные тесты и рефакторинг маленькими безопасными шагами. Готовый промт на русском с ответами нейросетей: посмотрите, что выдают GigaChat, ChatGPT, Claude и Gemini, и откройте текст промпта, чтобы запускать его со своими данными.
Текст промпта
Ответы нейросетей ниже получены этим промптом на примере из таблицы. Чтобы скопировать промпт и запускать его со своими данными, откройте доступ ко всем промптам или полный доступ к AI University.
Что подставить в промт
| Поле | Пример |
|---|---|
| {context} | PHP 8.1, интернет-магазин на самописном фреймворке, PHPUnit, функция вызывается из 6 мест |
| {code} | function calcDelivery($order, $city, $isVip = false) { $sum = 0; foreach ($order['items'] as $i) { $sum += $i['price'] * $i['qty']; } if ($city == 'Москва' || $city == 'москва' || $city == 'МСК') { if ($sum > 5000) return 0; $d = 350; } else { $w = 0; foreach ($order['items'] as $i) { $w += $i['weight'] * $i['qty']; } $d = 450 + ($w > 5 ? ($w - 5) * 60 : 0); if ($sum > 15000) $d = $d / 2; } if ($isVip) $d = 0; if (date('N') == 7) $d += 100; return round($d); } |
| {pain} | не покрыть тестами из-за date(), правила по городам скоро расширятся, магические числа, непонятно, должна ли наценка в воскресенье действовать для бесплатной доставки |
Ответы нейросетей на этот промпт
1. Текущая функциональность
Функция calcDelivery рассчитывает стоимость доставки заказа исходя из следующих факторов:
- Сумма заказа (
$sum) формируется умножением цены товара на количество всех товаров в заказе. - Город доставки влияет на цену:
- Если город Москва («Москва», «москва», «МСК»), доставка бесплатная при сумме больше ₽5000, иначе — постоянная цена ₽350.
- Для остальных городов расчет идет следующим образом:
- Стоимость доставки базовая — ₽450 плюс дополнительная плата за вес свыше 5 кг (₽60/кг).
- Если сумма заказа превышает ₽15000, стоимость делится пополам.
- В случае VIP-заказа (
$isVip === true), доставка бесплатна независимо от города и суммы. - Дополнительно, если сегодня воскресенье (согласно встроенной функции даты), добавляется единоразово ₽100 сверху.
Особенности текущего поведения:
- Функция нечувствительна к регистру написания города Москвы ("Москва"/"москва").
- Бесплатная доставка доступна даже при стоимости заказа меньше порога в субботу, если клиент VIP.
- Дополнительную оплату за воскресенье применяют ко всем заказам кроме бесплатных.
- Магические числа нигде не объясняются и используются непосредственно внутри условий.
- Фиксированные значения чисел вроде 5000, 15000, 350, 450 легко запутывают логику расчета.
2. Характеризационные тесты
Тестируемое поведение включает следующие случаи:
- Расчет обычной и бесплатной доставки для разных сумм заказов и городов.
- Учет VIP-статуса клиента.
- Добавление дополнительной платы за доставку в воскресенье.
use PHPUnit\Framework\TestCase;
class DeliveryTest extends TestCase
{
public function testFreeDeliveryInMoscowOverThreshold()
{
// Тест для Москвы, сумма заказа больше 5000 рублей -> бесплатно
$order = ['items' => [['price' => 3000, 'qty' => 1]]];
$this->assertEquals(0, calcDelivery($order, 'Москва'));
}
public function testPaidDeliveryInMoscowUnderThreshold()
{
// Тест для Москвы, сумма заказа меньше 5000 рублей -> платим 350 руб.
$order = ['items' => [['price' => 1000, 'qty' => 1]]];
$this->assertEquals(350, calcDelivery($order, 'Москва'));
}
public function testWeightBasedDeliveryOutsideMoscow()
{
// Стандартный случай вне Москвы, доставка зависит от веса
$order = [
'items' => [
['price' => 1000, 'qty' => 1],
['price' => 2000, 'qty' => 1]
]
];
$expectedDeliveryCost = 450;
$this->assertEquals($expectedDeliveryCost, calcDelivery($order, 'Санкт-Петербург'));
}
public function testHeavyOrderDeliveryOutsideMoscow()
{
// Ситуация, когда общий вес больше порогового (5кг)
$order = [
'items' => [
['price' => 1000, 'qty' => 1, 'weight' => 6],
['price' => 2000, 'qty' => 1, 'weight' => 1]
]
];
$expectedDeliveryCost = 450 + ((6+1)-5)*60; // Вес 7 кг, доплата за превышение нормы
$this->assertEquals($expectedDeliveryCost, calcDelivery($order, 'Краснодар'));
}
public function testDiscountedDeliveryForBigOrdersOutsideMoscow()
{
// Скидочная ситуация для больших заказов вне Москвы
$order = [
'items' => [
['price' => 10000, 'qty' => 1],
['price' => 6000, 'qty' => 1]
]
];
$expectedDeliveryCost = ceil((450)/2); // Цена делится пополам при большой сумме заказа
$this->assertEquals($expectedDeliveryCost, calcDelivery($order, 'Ростов-на-Дону'));
}
public function testSundaySurcharge()
{
// Проверяем работу воскресной надбавки
$originalDateFunction = function () {};
setTimezone('Europe/Moscow');
timecop_freeze(strtotime('next Sunday')); // замораживаем время на ближайшее воскресенье
try {
$order = ['items' => [['price' => 1000, 'qty' => 1]]]; // простой заказ
$expectedDeliveryCost = 450 + 100; // Базовая ставка + надбавка за воскресенье
$this->assertEquals($expectedDeliveryCost, calcDelivery($order, 'Питер'));
} finally {
timecop_return(); // возвращаем оригинальное время
}
}
public function testVIPFreeDelivery()
{
// Бесплатная доставка для VIP-клиентов
$order = ['items' => [['price' => 1000, 'qty' => 1]]];
$this->assertEquals(0, calcDelivery($order, 'Питер', true));
}
}
3. Рефакторинг пошагово
Рефакторинг будет состоять из нескольких этапов, каждый из которых стоит делать отдельным коммитом:
Шаг 1: Изоляция расчёта суммы заказа
Вынесем расчёт общей суммы заказа в отдельную функцию.
function getTotalAmount($order): float {
$total = 0;
foreach ($order['items'] as $item) {
$total += $item['price'] * $item['qty'];
}
return $total;
}
// Использование новой функции
function calcDelivery($order, $city, $isVip = false) {
$sum = getTotalAmount($order);
...
}
Комментарий: Так мы уменьшили дублирование и сделали первый шаг к выделению логики.
Шаг 2: Выделяем подсчёт общего веса
Аналогично создадим отдельную функцию для вычисления суммарного веса заказа.
function getTotalWeight($order): float {
$totalWeight = 0;
foreach ($order['items'] as $item) {
$totalWeight += $item['weight'] * $item['qty'];
}
return $totalWeight;
}
// Использование новой функции
function calcDelivery($order, $city, $isVip = false) {
$sum = getTotalAmount($order);
$w = getTotalWeight($order);
...
}
Комментарий: Теперь логика разделения становится понятнее, и читаемость улучшается.
Шаг 3: Абстрагируем условие города
Заменим проверку строки имени города регуляркой или преобразованием регистра. Это улучшит поддержку новых правил в будущем.
function isMoscow($city): bool {
return strcasecmp(trim($city), 'Москва') === 0 || trim($city) === 'МСК';
}
// Обновляем логику в основной функции
if (isMoscow($city)) {
if ($sum > 5000) return 0;
$d = 350;
} else {
...
}
Комментарий: Логика проверки стала более явной и гибкой.
Шаг 4: Вынос условия бесплатности VIP-заказов
Создаём отдельный блок обработки VIP-статуса, чтобы чётко выделить этот аспект.
function applyVIPStatus($deliveryCost, $isVip): int {
return $isVip ? 0 : $deliveryCost;
}
// Вызываем новую функцию после расчётов
function calcDelivery($order, $city, $isVip = false) {
...
$finalCost = applyVIPStatus($d, $isVip);
return round($finalCost);
}
Комментарий: Теперь проверка статуса VIP-пользователя выполняется централизованно и ясно выражена.
Шаг 5: Заморозка даты через моки для тестов
Для тестирования воскресной наценки используем библиотеку TimeCop (для простоты примера можно использовать заглушку). Нужно обернуть использование текущей даты в функцию, которую можем контролировать в тестах.
function currentDayOfWeek(): string {
return date('N'); // Возвращает номер дня недели
}
// Используем эту функцию
if (currentDayOfWeek() == 7) $d += 100;
Комментарий: Мы вынесли вызов даты в изолированную функцию, теперь её можно менять в тестах.
Шаг 6: Удаление магических чисел
Добавим константы для значений лимитов и базовых тарифов.
define('FREE_DELIVERY_THRESHOLD_MOSCOW', 5000);
define('DELIVERY_COST_MOSCOW', 350);
define('DELIVERY_COST_BASE', 450);
define('WEIGHT_SURCHARGE_RATE', 60);
define('SUNDAY_SURCHARGE', 100);
define('DISCOUNT_THRESHOLD_OUTSIDE_MOSCOW', 15000);
// Используем новые константы везде, где были магические числа
...
Комментарий: Чёткое обозначение используемых величин повысило прозрачность алгоритма.
4. Итоговый код после рефакторинга
<?php
define('FREE_DELIVERY_THRESHOLD_MOSCOW', 5000);
define('DELIVERY_COST_MOSCOW', 350);
define('DELIVERY_COST_BASE', 450);
define('WEIGHT_SURCHARGE_RATE', 60);
define('SUNDAY_SURCHARGE', 100);
define('DISCOUNT_THRESHOLD_OUTSIDE_MOSCOW', 15000);
function getTotalAmount($order): float {
$total = 0;
foreach ($order['items'] as $item) {
$total += $item['price'] * $item['qty'];
}
return $total;
}
function getTotalWeight($order): float {
$totalWeight = 0;
foreach ($order['items'] as $item) {
$totalWeight += $item['weight'] * $item['qty'];
}
return $totalWeight;
}
function isMoscow($city): bool {
return strcasecmp(trim($city), 'Москва') === 0 || trim($city) === 'МСК';
}
function applyVIPStatus($deliveryCost, $isVip): int {
return $isVip ? 0 : $deliveryCost;
}
function currentDayOfWeek(): string {
return date('N');
}
function calcDelivery($order, $city, $isVip = false) {
$sum = getTotalAmount($order);
$w = getTotalWeight($order);
if (isMoscow($city)) {
if ($sum >= FREE_DELIVERY_THRESHOLD_MOSCOW) return 0;
$d = DELIVERY_COST_MOSCOW;
} else {
$extraWeightCharge = max(($w - 5), 0) * WEIGHT_SURCHARGE_RATE;
$d = DELIVERY_COST_BASE + $extraWeightCharge;
if ($sum >= DISCOUNT_THRESHOLD_OUTSIDE_MOSCOW) $d /= 2;
}
if (currentDayOfWeek() == 7 && $d != 0) $d += SUNDAY_SURCHARGE;
return round(applyVIPStatus($d, $isVip));
}
5. Возможные риски и места проверки
После рефакторинга рекомендуется провести дополнительные проверки на следующем этапе:
- Бесплатная доставка в Москве: убедитесь, что при достижении определённого лимита доставка действительно становится бесплатной.
- Дополнительная оплата за вес: проверьте корректность работы ограничения по превышению массы заказа.
- Снижение стоимости доставки для крупных заказов: убедитесь, что скидка применяется правильно для больших покупок.
- Статус VIP-клиента: удостоверьтесь, что бесплатный статус распространяется именно на любые заказы VIP-клиентов.
- Доплата за воскресенье: протестируйте ситуации, когда дополнительный сбор включается/не включается согласно дню недели.
Таким образом, результаты теста помогут убедиться, что никаких неожиданных изменений поведения не произошло.
Ниже — подход именно для безопасного рефакторинга: сначала фиксируем текущее поведение, включая странности, потом маленькими шагами улучшаем читаемость.
1. Что функция делает сейчас
Исходная функция:
function calcDelivery($order, $city, $isVip = false) {
$sum = 0;
foreach ($order['items'] as $i) { $sum += $i['price'] * $i['qty']; }
if ($city == 'Москва' || $city == 'москва' || $city == 'МСК') {
if ($sum > 5000) return 0;
$d = 350;
} else {
$w = 0;
foreach ($order['items'] as $i) { $w += $i['weight'] * $i['qty']; }
$d = 450 + ($w > 5 ? ($w - 5) * 60 : 0);
if ($sum > 15000) $d = $d / 2;
}
if ($isVip) $d = 0;
if (date('N') == 7) $d += 100;
return round($d);
}
Текущее поведение по шагам
- Считает сумму заказа:
$sum += $i['price'] * $i['qty'];
- Если город считается Москвой:
$city == 'Москва' || $city == 'москва' || $city == 'МСК'
то:
- если сумма заказа строго больше
5000, функция сразу возвращает0; - иначе доставка стоит
350.
- Если город не Москва:
- считает общий вес:
$w += $i['weight'] * $i['qty'];
- базовая доставка
450; - если вес строго больше
5, за каждый кг сверх 5 добавляется60; - если сумма заказа строго больше
15000, доставка уменьшается в 2 раза.
- Если
$isVip === true, доставка становится0.
- Если сегодня воскресенье:
date('N') == 7
то к доставке добавляется 100.
- Возвращает округлённое значение:
return round($d);
Неочевидное поведение, которое важно сохранить
1. Для Москвы с суммой больше 5000 функция возвращает 0 сразу
Вот это место:
if ($sum > 5000) return 0;
Из-за раннего return не применяются:
- VIP-логика;
- воскресная наценка.
То есть:
calcDelivery($orderMoreThan5000, 'Москва', true)
вернёт 0 даже в воскресенье.
Это отличается от VIP-бесплатной доставки, где воскресная наценка всё-таки добавляется.
2. VIP-доставка не совсем бесплатная по воскресеньям
Если доставка стала 0 из-за VIP, потом всё равно применяется воскресная наценка:
if ($isVip) $d = 0;
if (date('N') == 7) $d += 100;
То есть в воскресенье VIP может платить 100.
3. Бесплатная доставка для Москвы и VIP работают по-разному
Москва, сумма больше 5000:
return 0;
Воскресная наценка не применяется.
VIP:
$d = 0;
Воскресная наценка применяется.
Это странно, но текущее поведение такое.
4. Город сравнивается через ==, не через ===
Сейчас используется нестрогое сравнение:
$city == 'Москва'
В PHP 8.1 для обычных строк это чаще всего не создаёт проблем, но формально это часть поведения.
5. Распознаются только три варианта Москвы
Москва определяется только так:
'Москва'
'москва'
'МСК'
Например, эти значения Москвой не считаются:
'Москвa' // латинская a
'МОСКВА'
'мск'
'Moscow'
'г. Москва'
' Москва '
6. Пороговые значения строгие
Для Москвы:
$sum > 5000
Сумма ровно 5000 — ещё не бесплатная доставка.
Для остальных городов:
$sum > 15000
Сумма ровно 15000 — скидки 50% нет.
Для веса:
$w > 5
Вес ровно 5 — доплаты нет.
7. Для Москвы вес вообще не используется
Если город Москва, функция не обращается к $i['weight'].
Это значит, что заказ без поля weight может нормально обработаться для Москвы, но сломаться warning-ами для других городов.
8. Для не-Москвы массив items обходится дважды
Один раз для суммы, второй раз для веса.
9. Есть зависимость от текущей даты
date('N')
Это делает тесты нестабильными: в воскресенье функция ведёт себя иначе.
10. Возможные warning-и тоже являются частью текущего поведения
Если нет ключей:
$order['items']
$i['price']
$i['qty']
$i['weight']
PHP выдаст warning/notice. При аккуратном рефакторинге это не надо внезапно менять на исключения или молчаливые дефолты.
2. Характеризационные тесты
Ниже тесты, которые фиксируют текущее поведение. Они не меняют функцию и не требуют новых зависимостей.
Проблема date() решается тем, что ожидаемое значение считается с учётом текущего дня. То есть тесты будут проходить и в воскресенье, и в остальные дни.
Пример для PHPUnit.
<?php
use PHPUnit\Framework\TestCase;
final class CalcDeliveryTest extends TestCase
{
private function sundaySurcharge(): int
{
return date('N') == 7 ? 100 : 0;
}
private function order(array $items): array
{
return [
'items' => $items,
];
}
public function testMoscowOrderBelowFreeThresholdCosts350PlusPossibleSundaySurcharge(): void
{
$order = $this->order([
[
'price' => 1000,
'qty' => 2,
'weight' => 10,
],
]);
$this->assertSame(
350 + $this->sundaySurcharge(),
calcDelivery($order, 'Москва')
);
}
public function testMoscowOrderEqualFreeThresholdIsNotFree(): void
{
$order = $this->order([
[
'price' => 2500,
'qty' => 2,
'weight' => 1,
],
]);
$this->assertSame(
350 + $this->sundaySurcharge(),
calcDelivery($order, 'Москва')
);
}
public function testMoscowOrderAboveFreeThresholdReturnsZeroWithoutSundaySurcharge(): void
{
$order = $this->order([
[
'price' => 5001,
'qty' => 1,
'weight' => 100,
],
]);
$this->assertSame(0, calcDelivery($order, 'Москва'));
}
/**
* @dataProvider moscowCityProvider
*/
public function testRecognizedMoscowCityVariants(string $city): void
{
$order = $this->order([
[
'price' => 100,
'qty' => 1,
'weight' => 1,
],
]);
$this->assertSame(
350 + $this->sundaySurcharge(),
calcDelivery($order, $city)
);
}
public static function moscowCityProvider(): array
{
return [
['Москва'],
['москва'],
['МСК'],
];
}
/**
* @dataProvider nonMoscowCityProvider
*/
public function testUnrecognizedMoscowLikeCitiesAreTreatedAsNonMoscow(string $city): void
{
$order = $this->order([
[
'price' => 100,
'qty' => 1,
'weight' => 1,
],
]);
$this->assertSame(
450 + $this->sundaySurcharge(),
calcDelivery($order, $city)
);
}
public static function nonMoscowCityProvider(): array
{
return [
['МОСКВА'],
['мск'],
['Moscow'],
['г. Москва'],
[' Москва '],
];
}
public function testNonMoscowBaseDeliveryWhenWeightIsExactlyFive(): void
{
$order = $this->order([
[
'price' => 1000,
'qty' => 1,
'weight' => 5,
],
]);
$this->assertSame(
450 + $this->sundaySurcharge(),
calcDelivery($order, 'Санкт-Петербург')
);
}
public function testNonMoscowAddsWeightSurchargeOnlyAboveFiveKg(): void
{
$order = $this->order([
[
'price' => 1000,
'qty' => 1,
'weight' => 7,
],
]);
// 450 + (7 - 5) * 60 = 570
$this->assertSame(
570 + $this->sundaySurcharge(),
calcDelivery($order, 'Казань')
);
}
public function testNonMoscowOrderEqualDiscountThresholdDoesNotGetHalfPrice(): void
{
$order = $this->order([
[
'price' => 15000,
'qty' => 1,
'weight' => 1,
],
]);
$this->assertSame(
450 + $this->sundaySurcharge(),
calcDelivery($order, 'Казань')
);
}
public function testNonMoscowOrderAboveDiscountThresholdGetsHalfPrice(): void
{
$order = $this->order([
[
'price' => 15001,
'qty' => 1,
'weight' => 1,
],
]);
// 450 / 2 = 225
$this->assertSame(
225 + $this->sundaySurcharge(),
calcDelivery($order, 'Казань')
);
}
public function testNonMoscowHalfPriceIsRoundedAfterSundaySurcharge(): void
{
$order = $this->order([
[
'price' => 16000,
'qty' => 1,
'weight' => 6,
],
]);
// Base: 450 + (6 - 5) * 60 = 510
// Discount: 510 / 2 = 255
$this->assertSame(
255 + $this->sundaySurcharge(),
calcDelivery($order, 'Казань')
);
}
public function testVipMoscowBelowThresholdIsZeroPlusPossibleSundaySurcharge(): void
{
$order = $this->order([
[
'price' => 1000,
'qty' => 1,
'weight' => 1,
],
]);
$this->assertSame(
$this->sundaySurcharge(),
calcDelivery($order, 'Москва', true)
);
}
public function testVipNonMoscowIsZeroPlusPossibleSundaySurcharge(): void
{
$order = $this->order([
[
'price' => 1000,
'qty' => 1,
'weight' => 10,
],
]);
$this->assertSame(
$this->sundaySurcharge(),
calcDelivery($order, 'Казань', true)
);
}
public function testVipMoscowAboveThresholdReturnsZeroWithoutSundaySurcharge(): void
{
$order = $this->order([
[
'price' => 6000,
'qty' => 1,
'weight' => 1,
],
]);
$this->assertSame(
0,
calcDelivery($order, 'Москва', true)
);
}
public function testMoscowDoesNotNeedWeightFieldBecauseWeightIsNotRead(): void
{
$order = $this->order([
[
'price' => 1000,
'qty' => 1,
],
]);
$this->assertSame(
350 + $this->sundaySurcharge(),
calcDelivery($order, 'Москва')
);
}
}
Что эти тесты фиксируют
- порог
> 5000для Москвы; - ранний
return 0для Москвы; - воскресную наценку;
- отличие VIP-бесплатной доставки от московской бесплатной доставки;
- распознавание только трёх вариантов Москвы;
- строгие пороги по весу и сумме;
- округление после всех расчётов;
- отсутствие обращения к
weightдля Москвы.
3. План рефакторинга маленькими шагами
Каждый шаг можно закоммитить отдельно. После каждого шага — прогон тестов.
Шаг 1. Отформатировать код без изменения логики
Только раскрыть однострочные блоки.
Было:
foreach ($order['items'] as $i) { $sum += $i['price'] * $i['qty']; }
Стало:
foreach ($order['items'] as $i) {
$sum += $i['price'] * $i['qty'];
}
Коммит:
Format calcDelivery without logic changes
Шаг 2. Вынести магические числа в константы
Например:
const DELIVERY_MOSCOW_BASE = 350;
const DELIVERY_MOSCOW_FREE_THRESHOLD = 5000;
Если проект старый и глобальные константы нежелательны, можно использовать define() с префиксом.
Коммит:
Extract delivery calculation constants
Шаг 3. Вынести расчёт суммы заказа в приватную/вспомогательную функцию
Публичный интерфейс calcDelivery() не меняем.
function calcDeliveryOrderSum($order) { ... }
Лучше дать префикс, чтобы не конфликтовать с другим кодом:
function calcDeliveryCalculateOrderSum($order) { ... }
Коммит:
Extract order sum calculation
Шаг 4. Вынести проверку Москвы
Важно сохранить именно текущие варианты и не начать случайно нормализовать строку.
Не надо пока делать:
mb_strtolower(trim($city))
Потому что это изменит поведение.
Коммит:
Extract Moscow city detection
Шаг 5. Вынести расчёт веса
Только для не-Москвы.
Коммит:
Extract order weight calculation
Шаг 6. Вынести расчёт доставки для Москвы и не-Москвы
Например:
calcDeliveryForMoscow($sum)
calcDeliveryForNonMoscow($order, $sum)
Важно сохранить ранний return 0 для Москвы при сумме больше 5000.
Коммит:
Extract city-specific delivery calculations
Шаг 7. Вынести проверку воскресенья
function calcDeliveryIsSunday()
{
return date('N') == 7;
}
Это не устраняет полностью проблему тестируемости, но локализует зависимость от времени.
Коммит:
Extract Sunday check
Шаг 8. Финальная чистка именований
Переименовать $d, $w, $i внутри новых функций в понятные имена.
Коммит:
Rename delivery calculation variables
4. Итоговый код
Вариант без классов и без изменения публичного интерфейса функции.
<?php
const CALC_DELIVERY_MOSCOW_BASE_PRICE = 350;
const CALC_DELIVERY_MOSCOW_FREE_THRESHOLD = 5000;
const CALC_DELIVERY_NON_MOSCOW_BASE_PRICE = 450;
const CALC_DELIVERY_NON_MOSCOW_INCLUDED_WEIGHT = 5;
const CALC_DELIVERY_NON_MOSCOW_EXTRA_KG_PRICE = 60;
const CALC_DELIVERY_NON_MOSCOW_HALF_PRICE_THRESHOLD = 15000;
const CALC_DELIVERY_SUNDAY_SURCHARGE = 100;
function calcDelivery($order, $city, $isVip = false) {
$sum = calcDeliveryOrderSum($order);
if (calcDeliveryIsMoscow($city)) {
if ($sum > CALC_DELIVERY_MOSCOW_FREE_THRESHOLD) {
return 0;
}
$delivery = CALC_DELIVERY_MOSCOW_BASE_PRICE;
} else {
$delivery = calcDeliveryNonMoscowPrice($order, $sum);
}
if ($isVip) {
$delivery = 0;
}
if (calcDeliveryIsSunday()) {
$delivery += CALC_DELIVERY_SUNDAY_SURCHARGE;
}
return round($delivery);
}
function calcDeliveryOrderSum($order) {
$sum = 0;
foreach ($order['items'] as $item) {
$sum += $item['price'] * $item['qty'];
}
return $sum;
}
function calcDeliveryIsMoscow($city) {
return $city == 'Москва' || $city == 'москва' || $city == 'МСК';
}
function calcDeliveryNonMoscowPrice($order, $sum) {
$weight = calcDeliveryOrderWeight($order);
$delivery = CALC_DELIVERY_NON_MOSCOW_BASE_PRICE;
if ($weight > CALC_DELIVERY_NON_MOSCOW_INCLUDED_WEIGHT) {
$delivery += ($weight - CALC_DELIVERY_NON_MOSCOW_INCLUDED_WEIGHT)
* CALC_DELIVERY_NON_MOSCOW_EXTRA_KG_PRICE;
}
if ($sum > CALC_DELIVERY_NON_MOSCOW_HALF_PRICE_THRESHOLD) {
$delivery = $delivery / 2;
}
return $delivery;
}
function calcDeliveryOrderWeight($order) {
$weight = 0;
foreach ($order['items'] as $item) {
$weight += $item['weight'] * $item['qty'];
}
return $weight;
}
function calcDeliveryIsSunday() {
return date('N') == 7;
}
Почему именно так
calcDelivery()осталась с тем же интерфейсом:
function calcDelivery($order, $city, $isVip = false)
- Не добавлены зависимости.
- Сохранён ранний
return 0для Москвы. - Сохранена воскресная наценка после VIP.
- Сохранено нестрогое сравнение города через
==. - Сохранены строгие пороги
>, а не>=. - Сохранено округление в самом конце.
- Для Москвы по-прежнему не читается
weight.
5. Места, где есть риск не сохранить поведение
1. Глобальные имена функций и констант
Если в проекте уже есть функции:
calcDeliveryOrderSum()
calcDeliveryIsMoscow()
calcDeliveryNonMoscowPrice()
calcDeliveryOrderWeight()
calcDeliveryIsSunday()
или константы с такими именами — будет конфликт.
Что проверить:
- поиск по проекту;
- автозагрузку;
- подключение файлов в тестах и проде.
Если риск есть, лучше использовать более длинный префикс, например:
legacyCalcDeliveryOrderSum()
2. Нестрогое сравнение города
Я сохранил:
$city == 'Москва'
Если заменить на:
$city === 'Москва'
формально поведение может поменяться.
Что проверить:
- какие типы реально приходят в
$cityиз 6 мест вызова; - нет ли объектов с
__toString(); - нет ли
null, чисел, массивов.
3. Поведение в воскресенье
Из-за date('N') тесты всё ещё зависят от текущей даты, хотя ожидаемые значения это учитывают.
Что проверить отдельно:
- прогон тестов в воскресенье;
- прогон тестов в будний день;
- особенно кейсы:
- VIP;
- Москва больше
5000; - Москва меньше/равно
5000.
4. Warning-и на неполных данных
Рефакторинг не добавляет валидацию. Это хорошо для сохранения поведения, но всё равно надо проверить, не изменились ли места чтения полей.
Особенно важно:
- Москва не должна читать
weight; - не-Москва должна читать
weight.
5. Тип возвращаемого значения
round() в PHP возвращает float.
Но в некоторых случаях assertSame(350, round(350)) может отличаться, потому что round(350) вернёт 350.0.
В реальном коде исходная функция тоже возвращала round($d), поэтому поведение сохранено.
В тестах, если хочется быть максимально точным по типам, можно ожидать 350.0, а не 350.
Например:
$this->assertSame(350.0, calcDelivery($order, 'Москва'));
Либо использовать:
$this->assertEquals(350, calcDelivery($order, 'Москва'));
Если в проекте уже завязаны на тип, лучше проверить фактические значения на PHP 8.1.
6. Порядок округления
Сейчас округление происходит после:
- скидки 50%;
- VIP-обнуления;
- воскресной наценки.
Итоговый код сохраняет этот порядок.
Что проверить:
- заказы, где после деления на 2 получается
.5; - например доставка
570 / 2 = 285; - доставка
510 / 2 = 255.
7. Расширение правил по городам
Сейчас проверка города вынесена в отдельную функцию:
function calcDeliveryIsMoscow($city)
Это подготовка к будущему расширению, но пока не надо добавлять новые варианты вроде 'МОСКВА' или trim(), потому что это уже изменение поведения.
Когда бизнес подтвердит новые правила, лучше сделать отдельный коммит с новыми тестами.
1. Что делает функция сейчас (включая неочевидное поведение)
Основная логика:
- Считает сумму заказа
sum = Σ price*qty. - Если город — Москва, при
sum > 5000доставка 0, иначе 350. - Если не Москва — считает вес, базовая стоимость
450 + (вес-5)*60(если вес > 5), приsum > 15000стоимость делится пополам. - VIP обнуляет стоимость.
- В воскресенье
+100. - Результат округляется (
round()).
Неочевидные детали, которые нужно специально сохранить:
- Сравнение города строгое и неполное. Распознаются только
'Москва','москва','МСК'.'МОСКВА','Мск',' москва'— НЕ Москва, посчитаются как «другой город» с весовой формулой. Это похоже на баг, но менять нельзя без согласования с бизнесом. - Ранний
return 0для Москвы приsum > 5000— выполняется до проверки VIP и воскресенья. Значит для такого заказа бесплатная доставка остаётся 0 даже в воскресенье, и VIP тут ничего не меняет (и так 0). - Порядок операций: скидка 50% для не-Москвы считается до VIP и воскресенья; VIP обнуляет до добавления воскресной наценки — то есть VIP-клиент в воскресенье всё равно платит 100 (кроме случая 1 выше, где был ранний return).
date('N')— завязка на реальное время сервера, источник нетестируемости.round()в конце — обычное округление, в т.ч. после деления пополам (может бытьx.5).- Побочных эффектов нет (нет записи в БД/файлы), функция чистая, кроме зависимости от
date().
2. Характеризационные тесты (до любых правок кода)
Поскольку функция зависит от date(), а трогать её мы ещё не имеем права, тест для воскресной наценки вычисляет ожидаемое значение по той же логике (date('N')). Это не «чистый» юнит-тест, но он честно фиксирует поведение as-is.
use PHPUnit\Framework\TestCase;
final class CalcDeliveryCharacterizationTest extends TestCase
{
private function order(array $items): array
{
return ['items' => $items];
}
private function sundaySurcharge(): int
{
return ((int) date('N') === 7) ? 100 : 0;
}
public function test_moscow_cheap_order_base_price(): void
{
$order = $this->order([['price' => 1000, 'qty' => 2, 'weight' => 1]]); // sum=2000
$result = calcDelivery($order, 'Москва');
$this->assertSame(350 + $this->sundaySurcharge(), $result);
}
public function test_moscow_expensive_order_is_free_even_vip_and_sunday(): void
{
$order = $this->order([['price' => 6000, 'qty' => 1, 'weight' => 1]]); // sum=6000 > 5000
$this->assertSame(0, calcDelivery($order, 'Москва'));
$this->assertSame(0, calcDelivery($order, 'Москва', true)); // VIP не важен
}
public function test_moscow_name_variants_treated_equally(): void
{
$order = $this->order([['price' => 1000, 'qty' => 1, 'weight' => 1]]);
$a = calcDelivery($order, 'Москва');
$b = calcDelivery($order, 'москва');
$c = calcDelivery($order, 'МСК');
$this->assertSame($a, $b);
$this->assertSame($b, $c);
}
public function test_moscow_other_casing_is_not_recognized_as_moscow(): void
{
// Фиксируем текущий "баг": 'МОСКВА' считается как другой город.
$order = $this->order([['price' => 1000, 'qty' => 1, 'weight' => 1]]); // weight=1 <5, sum=1000
$result = calcDelivery($order, 'МОСКВА');
$this->assertSame(450 + $this->sundaySurcharge(), $result); // не 350!
}
public function test_other_city_light_order(): void
{
$order = $this->order([['price' => 1000, 'qty' => 1, 'weight' => 2]]); // weight=2, sum=1000
$result = calcDelivery($order, 'Казань');
$this->assertSame(450 + $this->sundaySurcharge(), $result);
}
public function test_other_city_heavy_order_adds_per_kg(): void
{
$order = $this->order([['price' => 1000, 'qty' => 1, 'weight' => 10]]); // weight=10
// 450 + (10-5)*60 = 750
$result = calcDelivery($order, 'Казань');
$this->assertSame(750 + $this->sundaySurcharge(), $result);
}
public function test_other_city_expensive_order_half_price(): void
{
$order = $this->order([['price' => 20000, 'qty' => 1, 'weight' => 10]]); // sum=20000>15000
// (450 + 300) / 2 = 375
$result = calcDelivery($order, 'Казань');
$this->assertSame(375 + $this->sundaySurcharge(), $result);
}
public function test_vip_zeroes_price_but_sunday_surcharge_still_applies(): void
{
$order = $this->order([['price' => 1000, 'qty' => 1, 'weight' => 10]]);
$result = calcDelivery($order, 'Казань', true);
$this->assertSame(0 + $this->sundaySurcharge(), $result);
}
public function test_return_type_is_int_like_and_rounded(): void
{
// проверка, что деление пополам с нечётным числом корректно округляется
$order = $this->order([['price' => 20000, 'qty' => 1, 'weight' => 6]]);
// (450 + 60)/2 = 255 -> целое, добавим ещё кейс с дробным результатом отдельно при желании
$result = calcDelivery($order, 'Казань');
$this->assertIsInt($result + 0); // round() возвращает float, но значение целое
}
}
> Тест на воскресенье стоит ещё продублировать ручным прогоном в CI по cron раз в неделю или смириться с тем, что часть проверки воскресенья «спит» 6 дней из 7 — это и есть причина перейти к шагу с DI времени.
3. План рефакторинга маленькими шагами
Каждый шаг — отдельный коммит, после каждого шага зелёные тесты.
- Шаг 0. Добавить характеризационные тесты выше (ничего не меняя в коде). Коммит:
test: add characterization tests for calcDelivery. - Шаг 1. Добавить необязательный параметр
?\DateTimeInterface $now = nullв конец сигнатуры, внутри заменитьdate('N')на(int) ($now ?? new \DateTimeImmutable())->format('N'). Публичный интерфейс не ломается — все 6 вызовов продолжают работать без изменений. Добавить новые тесты, где воскресенье/будни передаются явно, без зависимости от реальной даты. Коммит:refactor: inject clock into calcDelivery for testability. - Шаг 2. Вынести магические числа в именованные константы (
FREE_DELIVERY_THRESHOLD_MOSCOW,BASE_DELIVERY_MOSCOW,BASE_DELIVERY_OTHER,WEIGHT_FREE_LIMIT,WEIGHT_RATE,DISCOUNT_THRESHOLD_OTHER,SUNDAY_SURCHARGE,SUNDAY=7). Поведение не меняется. Коммит:refactor: extract magic numbers into named constants. - Шаг 3. Выделить приватные хелперы
calcDeliveryOrderSum(),calcDeliveryOrderWeight(),calcDeliveryIsMoscow()— чисто механическое Extract Method, без изменения логики. Коммит:refactor: extract helper functions. - Шаг 4. Вынести список распознаваемых названий Москвы в константу-массив (
MOSCOW_NAMES), используяin_array(..., true)— то есть пока точное сравнение, без нормализации регистра, чтобы не поменять поведение. Это подготовка почвы для расширения списка городов в будущем. Коммит:refactor: extract Moscow name list into constant (no behavior change). - Шаг 5 (отдельный тикет, не в этом PR). Обсудить с бизнесом/продуктом два открытых вопроса и зафиксировать явным тестом, что бы ни решили:
- должна ли наценка за воскресенье действовать на «бесплатную» московскую доставку;
- должно ли сравнение городов быть регистронезависимым (
'МОСКВА','Мск'и т.д.).
Эти изменения — изменение поведения, делать отдельным коммитом с явным упоминанием в PR и обновлением характеризационных тестов до «тестов на новое поведение».
- Шаг 6. Задокументировать все нюансы в PHPDoc (сделано в финальном коде ниже), добавить
declare(strict_types=1)— только после проверки всех 6 мест вызова на соответствие типов (см. пункт 5).
4. Итоговый код (после шагов 1–4, без изменения поведения)
<?php
declare(strict_types=1);
/**
* Стоимость доставки заказа.
*
* ВНИМАНИЕ — поведение сохранено намеренно и зафиксировано тестами:
* - Город "Москва" распознаётся только по точному совпадению с одной из строк
* CalcDeliveryConst::MOSCOW_NAMES. Другие варианты написания (например,
* "МОСКВА") считаются "другим городом" и считаются по весовой формуле.
* - Если это Москва и sum > 5000 — доставка 0, и функция выходит СРАЗУ,
* до применения VIP и воскресной наценки. То есть в воскресенье такая
* "бесплатная" доставка не получает +100.
* - Для остальных случаев VIP обнуляет стоимость, но воскресная наценка
* прибавляется ПОСЛЕ VIP (т.е. VIP-клиент в воскресенье всё равно платит
* надбавку).
* - Скидка 50% для не-Москвы (sum > 15000) считается до VIP и воскресенья.
* - Результат округляется round() в самом конце.
*
* TODO(business): уточнить, должна ли воскресная наценка применяться
* к бесплатной московской доставке и к VIP. Текущее поведение — "нет"
* для первого случая и "да" для второго, см. CalcDeliveryCharacterizationTest.
*
* @param array{items: list<array{price: float, qty: int, weight?: float}>} $order
* @param string $city
* @param bool $isVip
* @param \DateTimeInterface|null $now Точка отсчёта "сегодня" для проверки
* воскресенья. Добавлен только ради тестируемости: по умолчанию
* берётся текущая дата, как и раньше. Существующие вызовы не меняются.
*/
function calcDelivery(array $order, string $city, bool $isVip = false, ?\DateTimeInterface $now = null): int
{
$now ??= new \DateTimeImmutable();
$sum = calcDeliveryOrderSum($order);
if (calcDeliveryIsMoscow($city)) {
if ($sum > CalcDeliveryConst::FREE_DELIVERY_THRESHOLD_MOSCOW) {
// Ранний выход: бесплатная доставка, минуя VIP и воскресную наценку.
return 0;
}
$delivery = (float) CalcDeliveryConst::BASE_DELIVERY_MOSCOW;
} else {
$weight = calcDeliveryOrderWeight($order);
$delivery = CalcDeliveryConst::BASE_DELIVERY_OTHER
+ max(0.0, $weight - CalcDeliveryConst::WEIGHT_FREE_LIMIT) * CalcDeliveryConst::WEIGHT_RATE;
if ($sum > CalcDeliveryConst::DISCOUNT_THRESHOLD_OTHER) {
$delivery /= 2;
}
}
if ($isVip) {
$delivery = 0.0;
}
if ((int) $now->format('N') === CalcDeliveryConst::SUNDAY) {
$delivery += CalcDeliveryConst::SUNDAY_SURCHARGE;
}
return (int) round($delivery);
}
final class CalcDeliveryConst
{
public const FREE_DELIVERY_THRESHOLD_MOSCOW = 5000;
public const BASE_DELIVERY_MOSCOW = 350;
public const BASE_DELIVERY_OTHER = 450;
public const WEIGHT_FREE_LIMIT = 5;
public const WEIGHT_RATE = 60;
public const DISCOUNT_THRESHOLD_OTHER = 15000;
public const SUNDAY = 7;
public const SUNDAY_SURCHARGE = 100;
/**
* Точные варианты написания города "Москва", распознаваемые сейчас.
* ВАЖНО: сравнение строгое (==/in_array strict), регистр имеет значение.
* Расширять список сюда можно, менять семантику сравнения — нельзя
* без отдельного согласования (см. TODO в calcDelivery).
*/
public const MOSCOW_NAMES = ['Москва', 'москва', 'МСК'];
}
function calcDeliveryIsMoscow(string $city): bool
{
return in_array($city, CalcDeliveryConst::MOSCOW_NAMES, true);
}
function calcDeliveryOrderSum(array $order): float
{
$sum = 0.0;
foreach ($order['items'] as $item) {
$sum += $item['price'] * $item['qty'];
}
return $sum;
}
function calcDeliveryOrderWeight(array $order): float
{
$weight = 0.0;
foreach ($order['items'] as $item) {
$weight += $item['weight'] * $item['qty'];
}
return $weight;
}
5. Где не уверен, что поведение сохранилось — что проверить
declare(strict_types=1)и типы параметров (array $order,string $city,bool $isVip). Если хоть одно из 6 мест вызова передаётnullв$city, число вместо строки,stdClass/ArrayObjectвместо массива в$order, или0/1вместоbool— раньше PHP их бы тихо привёл или выдал notice, а сейчас будетTypeError. Нужно пройтись по всем 6 вызовам и проверить типы фактических аргументов перед включением строгой типизации — возможно, стоит сначала замёржить безstrict_typesи типов параметров, добавив их отдельным шагом с отдельными тестами.return (int) round($delivery)vs исходныйreturn round($delivery). Оригинал возвращалfloat(результатround()), я явно привёл кint. Если где-то из 6 вызовов результат используется в строгом сравнении (===) с float или участвует в дальнейших float-вычислениях с дробной частью — поведение может незначимо, но технически отличаться. Проверить все места использования результатаcalcDelivery()на строгие сравнения типов.- Отсутствие ключа
weightв элементах заказа для московских доставок. В оригинале вес не считается для Москвы, поэтому отсутствиеweightтам не аффектит. Я это сохранил (вес считаю только в ветке "не Москва"), но стоит добавить тест на заказ безweightдля Москвы, чтобы зафиксировать явно. - Коллизия имён глобальных функций. Я вынес хелперы (
calcDeliveryIsMoscow,calcDeliveryOrderSum,calcDeliveryOrderWeight) в глобальное пространство имён — нужно проверить, что такие имена ещё не заняты в проекте (поиск по кодовой базе). Безопаснее — обернуть их вfinal classкакprivate staticметоды или поместить всё вnamespace, если это допустимо архитектурно. - Деление
/2с последующимround(). Проверить граничные значения (например,$deliveryровноx.5после деления) на эквивалентность округления до/после рефакторинга — я не менял порядок операций, но стоит явный тест с таким значением, чтобы зафиксировать текущее поведениеround()(half away from zero). \DateTimeImmutableбез явного timezone.new \DateTimeImmutable()использует timezone по умолчанию изdate.timezoneв php.ini — то же самое, что использовалdate('N')раньше (обе функции берут дефолтную таймзону сервера). Поведение должно совпасть, но стоит явно проверить, что в проекте не установлен кастомный timezone черезdate_default_timezone_set()где-то в другом месте запроса, который мог бы повлиять на старыйdate(), но не повлияет на новыйDateTimeImmutable(на практике влияет одинаково, т.к. оба читают один и тот же default timezone — но лучше перепроверить на staging).
Отличная задача! Рефакторинг легаси — это искусство, и мы подойдем к нему максимально аккуратно.
1. Что делает функция сейчас (включая неочевидное поведение и побочные эффекты)
Функция calcDelivery рассчитывает стоимость доставки заказа на основе его содержимого, города доставки, VIP-статуса клиента и текущего дня недели.
Основная логика:
- Расчет общей суммы заказа: Суммируются
price * qtyдля всех товаров в заказе. - Логика для Москвы (и ее вариаций):
- Если город — 'Москва', 'москва' или 'МСК'.
- Если сумма заказа
> 5000, доставка бесплатна (0). - Иначе, базовая стоимость доставки
350. - Логика для других городов:
- Расчитывается общий вес заказа: Суммируются
weight * qtyдля всех товаров. - Базовая стоимость доставки
450. - Если общий вес
> 5, добавляется(вес - 5) * 60. - Если сумма заказа
> 15000, стоимость доставки делится на 2. - VIP-статус:
- Если
$isVipистинно, доставка бесплатна (0), перезаписывая все предыдущие расчеты. - Наценка в воскресенье:
- Если текущий день недели — воскресенье (7), к стоимости доставки добавляется 100.
- Округление:
- Итоговая стоимость доставки всегда округляется до ближайшего целого числа.
Неочевидное поведение и побочные эффекты, которые нужно сохранить:
- Регистронезависимость для Москвы: 'Москва', 'москва', 'МСК' обрабатываются одинаково.
- Приоритет бесплатной доставки:
- Бесплатная доставка для Москвы при
sum > 5000имеет приоритет над базовой стоимостью Москвы, но не имеет приоритета над VIP-статусом или воскресной наценкой. - Бесплатная доставка для VIP-клиентов (
$isVip = true) имеет наивысший приоритет, обнуляя все предыдущие расчеты, кроме воскресной наценки. - Ключевой момент: Если
$isVipистинно,$dстановится0. Затем, если воскресенье,$dстановится100. Это означает, что VIP-клиенты платят 100 в воскресенье, если их доставка должна была быть бесплатной. Это очень неочевидное поведение и, возможно, баг, но мы должны его сохранить на первом этапе. - Порядок применения скидок/наценок:
- Скидка за сумму заказа (
sum > 15000) применяется после расчета весовой надбавки для не-Москвы. - VIP-скидка применяется после всех региональных расчетов.
- Воскресная наценка применяется после VIP-скидки.
- Магические числа: Все числа (5000, 350, 15000, 450, 5, 60, 2, 100) являются частью логики и должны быть сохранены в поведении.
date('N'): Функцияdate()является побочным эффектом, который делает функцию непредсказуемой и нетестируемой. Ее нужно изолировать.- Повторный перебор
$order['items']: Для не-Москвы массивitemsперебирается дважды (один раз для суммы, второй для веса). Это неэффективно, но не является критичным побочным эффектом, который нужно сохранять, если это не влияет на производительность в критических местах. В данном случае, это просто неэффективность, которую можно устранить.
2. Характеризационные тесты
Мы используем PHPUnit. Для изоляции date() нам потребуется мокирование. Самый простой способ — обернуть date() в отдельный класс или функцию, которую можно будет мокать. Но для начала, чтобы зафиксировать текущее поведение, мы можем просто передавать день недели как аргумент в тестовую функцию, а затем, при рефакторинге, инкапсулировать это.
Предполагаем, что у нас есть базовый тестовый класс:
<?php
use PHPUnit\Framework\TestCase;
// Предполагаем, что функция calcDelivery находится в глобальной области видимости
// или в каком-то файле, который подключается перед тестами.
require_once 'path/to/your/calcDelivery.php';
class CalcDeliveryTest extends TestCase
{
// Вспомогательная функция для создания тестового заказа
private function createOrder(array $items): array
{
return ['items' => $items];
}
// Вспомогательная функция для вызова calcDelivery с заданным днем недели
// Это временное решение для тестов, чтобы обойти date('N')
// В реальном коде мы будем мокать date() или передавать его через DI
private function callCalcDeliveryWithDay(array $order, string $city, bool $isVip, int $dayOfWeek): int
{
// Временно переопределяем функцию date() для теста
// В реальном проекте лучше использовать библиотеку для мокирования глобальных функций
// или рефакторинг, чтобы date() не вызывалась напрямую.
// Для демонстрации характера поведения, это подойдет.
// Более правильный способ: использовать runkit_function_redefine или uopz_redefine
// Но они требуют специфических расширений.
// Поэтому, для начала, мы будем *предполагать*, что функция date()
// может быть мокнута или что мы передаем день недели.
// Для характеризационных тестов, мы можем сделать так:
// (Это не идеальный способ, но позволяет зафиксировать поведение)
// Если бы calcDelivery была в классе, мы бы мокнули метод.
// Так как это функция, мы можем временно изменить ее поведение для теста.
// Но это сложно и не рекомендуется.
// Лучше всего, если бы функция принимала день недели как аргумент,
// или мы бы использовали обертку над date().
// Для характеризационных тестов, мы можем просто передавать день недели
// и учитывать, что оригинальная функция его не принимает.
// Поэтому, для начала, мы будем тестировать без мокирования date(),
// но будем помнить об этом при рефакторинге.
// Для характеризационных тестов, мы просто выбираем день недели,
// который не является воскресеньем, чтобы избежать наценки,
// или воскресенье, чтобы ее проверить.
// Для характеризационных тестов, мы просто будем вызывать функцию
// и учитывать, что date('N') будет возвращать текущий день.
// Чтобы сделать тесты воспроизводимыми, мы можем выбрать конкретный день недели
// для запуска тестов, или использовать мокирование.
// Для простоты, пока не будем мокать date() напрямую в тестах,
// но будем учитывать его влияние.
// Для характеризационных тестов, мы можем использовать следующий подход:
// Создадим временную функцию-обертку, которая будет передавать день недели.
// Это не меняет оригинальную функцию, но позволяет тестировать ее поведение.
// Это сложный момент для характеризационных тестов с глобальной функцией.
// Самый простой способ - это запустить тесты в определенный день недели,
// или использовать библиотеку для мокирования глобальных функций.
// Для целей характеризации, я буду писать тесты, которые *предполагают*
// определенный день недели, и буду явно указывать это в названии теста.
// Для того, чтобы сделать тесты воспроизводимыми, нам нужно мокнуть date().
// Для PHPUnit, это не так просто для глобальных функций без расширений.
// Поэтому, для характеризационных тестов, мы будем использовать
// временную обертку, которая позволяет передать день недели.
// Это не меняет оригинальную функцию, но позволяет нам тестировать ее.
// В реальном проекте, я бы использовал uopz или runkit для мокирования date()
// или рефакторинг, чтобы date() принималась как зависимость.
// Для этого упражнения, я буду использовать временную обертку.
// Временная обертка для тестирования
$originalDateFunction = 'date';
if (function_exists('uopz_set_return')) {
uopz_set_return('date', function ($format) use ($dayOfWeek) {
if ($format === 'N') {
return $dayOfWeek;
}
return call_user_func($originalDateFunction, $format);
}, true);
} else {
// Если uopz нет, то тесты на воскресенье будут зависеть от реального дня недели.
// Это не идеально, но для характеризационных тестов допустимо,
// если мы явно это указываем.
// Для этого упражнения, я буду предполагать, что uopz доступен.
// Если нет, то тесты с воскресеньем будут "флаки".
}
$result = calcDelivery($order, $city, $isVip);
if (function_exists('uopz_unset_return')) {
uopz_unset_return('date');
}
return $result;
}
// --- Тесты для Москвы ---
public function testMoscowFreeDeliveryForLargeOrder()
{
$order = $this->createOrder([
['price' => 1000, 'qty' => 6, 'weight' => 1], // Сумма 6000
]);
// Не воскресенье, не VIP
$this->assertEquals(0, $this->callCalcDeliveryWithDay($order, 'Москва', false, 1));
}
public function testMoscowBasicDeliveryForSmallOrder()
{
$order = $this->createOrder([
['price' => 1000, 'qty' => 4, 'weight' => 1], // Сумма 4000
]);
// Не воскресенье, не VIP
$this->assertEquals(350, $this->callCalcDeliveryWithDay($order, 'Москва', false, 1));
}
public function testMoscowCaseInsensitiveCityMatch()
{
$order = $this->createOrder([
['price' => 1000, 'qty' => 4, 'weight' => 1],
]);
$this->assertEquals(350, $this->callCalcDeliveryWithDay($order, 'москва', false, 1));
$this->assertEquals(350, $this->callCalcDeliveryWithDay($order, 'МСК', false, 1));
}
// --- Тесты для других городов ---
public function testOtherCityBasicDeliveryNoWeightSurcharge()
{
$order = $this->createOrder([
['price' => 100, 'qty' => 1, 'weight' => 1], // Сумма 100, Вес 1
]);
// Не воскресенье, не VIP
$this->assertEquals(450, $this->callCalcDeliveryWithDay($order, 'СПБ', false, 1));
}
public function testOtherCityWithWeightSurcharge()
{
$order = $this->createOrder([
['price' => 100, 'qty' => 1, 'weight' => 10], // Сумма 100, Вес 10
]);
// 450 + (10 - 5) * 60 = 450 + 5 * 60 = 450 + 300 = 750
$this->assertEquals(750, $this->callCalcDeliveryWithDay($order, 'СПБ', false, 1));
}
public function testOtherCityHalfPriceForVeryLargeOrder()
{
$order = $this->createOrder([
['price' => 10000, 'qty' => 2, 'weight' => 1], // Сумма 20000, Вес 2
]);
// База 450, вес не влияет. Сумма > 15000, 450 / 2 = 225
$this->assertEquals(225, $this->callCalcDeliveryWithDay($order, 'СПБ', false, 1));
}
public function testOtherCityHalfPriceWithWeightSurcharge()
{
$order = $this->createOrder([
['price' => 10000, 'qty' => 2, 'weight' => 10], // Сумма 20000, Вес 10
]);
// База 450 + (10-5)*60 = 750. Сумма > 15000, 750 / 2 = 375
$this->assertEquals(375, $this->callCalcDeliveryWithDay($order, 'СПБ', false, 1));
}
// --- Тесты для VIP ---
public function testVipDeliveryIsFreeUnlessSunday()
{
$order = $this->createOrder([
['price' => 100, 'qty' => 1, 'weight' => 1], // Любой заказ
]);
// VIP, не воскресенье
$this->assertEquals(0, $this->callCalcDeliveryWithDay($order, 'Москва', true, 1));
$this->assertEquals(0, $this->callCalcDeliveryWithDay($order, 'СПБ', true, 1));
}
public function testVipDeliveryCosts100OnSunday()
{
$order = $this->createOrder([
['price' => 100, 'qty' => 1, 'weight' => 1], // Любой заказ
]);
// VIP, воскресенье
$this->assertEquals(100, $this->callCalcDeliveryWithDay($order, 'Москва', true, 7));
$this->assertEquals(100, $this->callCalcDeliveryWithDay($order, 'СПБ', true, 7));
}
// --- Тесты для воскресенья ---
public function testMoscowBasicDeliveryOnSunday()
{
$order = $this->createOrder([
['price' => 1000, 'qty' => 4, 'weight' => 1], // Сумма 4000
]);
// Москва, не VIP, воскресенье. 350 + 100 = 450
$this->assertEquals(450, $this->callCalcDeliveryWithDay($order, 'Москва', false, 7));
}
public function testMoscowFreeDeliveryOnSundayBecomes100()
{
$order = $this->createOrder([
['price' => 1000, 'qty' => 6, 'weight' => 1], // Сумма 6000
]);
// Москва, не VIP, воскресенье. 0 + 100 = 100 (это то самое неочевидное поведение)
$this->assertEquals(100, $this->callCalcDeliveryWithDay($order, 'Москва', false, 7));
}
public function testOtherCityBasicDeliveryOnSunday()
{
$order = $this->createOrder([
['price' => 100, 'qty' => 1, 'weight' => 1], // Сумма 100, Вес 1
]);
// СПБ, не VIP, воскресенье. 450 + 100 = 550
$this->assertEquals(550, $this->callCalcDeliveryWithDay($order, 'СПБ', false, 7));
}
public function testOtherCityHalfPriceWithWeightSurchargeOnSunday()
{
$order = $this->createOrder([
['price' => 10000, 'qty' => 2, 'weight' => 10], // Сумма 20000, Вес 10
]);
// База 450 + (10-5)*60 = 750. Сумма > 15000, 750 / 2 = 375. Воскресенье: 375 + 100 = 475
$this->assertEquals(475, $this->callCalcDeliveryWithDay($order, 'СПБ', false, 7));
}
// --- Тесты на округление ---
public function testRoundingBehavior()
{
// Создадим ситуацию, где $d будет нецелым числом
$order = $this->createOrder([
['price' => 10000, 'qty' => 2, 'weight' => 1], // Сумма 20000, Вес 1
]);
// СПБ, не VIP, не воскресенье. База 450. Сумма > 15000, 450 / 2 = 225.
// Если бы было 451 / 2 = 225.5, то округлилось бы до 226.
// Сейчас все числа целые, поэтому округление не меняет ничего.
// Чтобы протестировать округление, нужно чтобы $d было нецелым.
// Это возможно только если $d / 2 дает нецелое число.
// Например, если $d = 451, то $d / 2 = 225.5, round(225.5) = 226.
// Текущий код не генерирует нецелых чисел до round(), кроме деления на 2.
// Если $d = 450, то $d/2 = 225.
// Если $d = 451, то $d/2 = 225.5, round(225.5) = 226.
// Чтобы получить 451, нужно чтобы $d = 450 + (w-5)*60 = 451.
// (w-5)*60 = 1. Это невозможно, т.к. (w-5) целое.
// Значит, $d всегда будет целым до деления на 2.
// Поэтому, округление работает только для .5 случаев после деления.
// Например, 450/2 = 225. 452/2 = 226.
// Если бы $d было 451, то 451/2 = 225.5, округлилось бы до 226.
// Но $d всегда четное или нечетное, если 450 + (w-5)*60.
// 450 - четное. (w-5)*60 - всегда четное.
// Значит, $d всегда четное до деления на 2.
// Следовательно, $d/2 всегда целое.
// Это означает, что `round()` сейчас не делает ничего, кроме как возвращает целое число,
// которое уже является целым.
// Это важный вывод! `round()` сейчас избыточен.
// Но мы должны его сохранить, потому что в будущем могут появиться нецелые значения.
// Для характеризационного теста:
$orderOddSum = $this->createOrder([
['price' => 10000, 'qty' => 1.5, 'weight' => 1], // Сумма 15000, но нечетная
]);
// Если бы qty могло быть float, то sum могло бы быть float.
// Но `qty` обычно integer.
// Предполагаем, что `qty` и `price` всегда целые.
// Тогда `sum` всегда целое. `weight` тоже.
// Значит, `d` всегда целое.
// `d / 2` может быть `.5` только если `d` нечетное.
// $d = 450 + ($w > 5 ? ($w - 5) * 60 : 0);
// 450 - четное. ($w - 5) * 60 - всегда четное (60 - четное).
// Значит, $d всегда четное.
// Следовательно, $d / 2 всегда целое.
// Вывод: `round($d)` сейчас **не меняет значение $d**.
// Это важное наблюдение для рефакторинга!
// Мы все равно должны сохранить `round()`, так как это часть контракта.
// Тест на округление будет просто подтверждать, что оно возвращает целое.
$this->assertIsInt($this->callCalcDeliveryWithDay($order, 'СПБ', false, 1));
}
}
Важное замечание по тестам: Мокирование date() для глобальной функции в PHPUnit без расширений типа uopz или runkit довольно сложно. Для характеризационных тестов, я использовал подход с uopz_set_return для демонстрации. Если uopz недоступен, то тесты, зависящие от воскресенья, будут "флаки" (нестабильными), и их придется запускать в воскресенье, чтобы проверить этот кейс, или временно модифицировать функцию для тестирования (что не рекомендуется для характеризационных тестов). Для реального рефакторинга, первым делом будет изоляция date().
3. План рефакторинга маленькими шагами
Каждый шаг должен быть небольшим, атомарным и сохранять поведение. После каждого шага запускаем тесты.
Шаг 0: Подготовка (если не сделано)
- Убедиться, что тесты из раздела 2 проходят.
- Установить
uopzили аналогичное расширение для мокирования глобальных функций, если это возможно. Если нет, принять к сведению, что тесты на воскресенье будут зависеть от реального дня недели.
Шаг 1: Изоляция date('N')
- Цель: Сделать функцию тестируемой и предсказуемой, убрав прямую зависимость от глобальной функции
date(). - Действие:
- Создать новый класс
SystemClock(илиDateTimeProvider) с методомgetCurrentDayOfWeek(): int. - Внутри
SystemClock::getCurrentDayOfWeek()вызватьdate('N'). - Изменить
calcDelivery, чтобы она принимала экземплярSystemClock(или его интерфейс) в качестве аргумента (Dependency Injection). - Внутри функции использовать
$clock->getCurrentDayOfWeek(). - Обновить вызовы
calcDeliveryв 6 местах, передаваяnew SystemClock(). - Обновить тесты, чтобы они могли мокать
SystemClock::getCurrentDayOfWeek().
Шаг 2: Выделение расчета суммы и веса заказа
- Цель: Устранить дублирование кода и улучшить читаемость.
- Действие:
- Создать две вспомогательные приватные функции (или методы, если функция будет в классе) внутри
calcDelivery(или рядом):calculateOrderSum(array $order): floatиcalculateOrderWeight(array $order): float. - Перенести логику расчета суммы и веса в эти функции.
- Заменить циклы в
calcDeliveryна вызовы этих новых функций.
Шаг 3: Нормализация города
- Цель: Устранить дублирование проверок города и сделать логику более гибкой для будущих расширений.
- Действие:
- Создать вспомогательную приватную функцию
normalizeCity(string $city): string. - Внутри
normalizeCityпривести город к единому формату (например,mb_strtolowerи замена 'мск' на 'москва'). - В начале
calcDeliveryвызвать$normalizedCity = normalizeCity($city);. - Заменить все проверки
$city == 'Москва' || $city == 'москва' || $city == 'МСК'на$normalizedCity == 'москва'.
Шаг 4: Извлечение магических чисел в константы
- Цель: Улучшить читаемость и облегчить будущие изменения правил.
- Действие:
- Определить константы для всех магических чисел (например,
DELIVERY_THRESHOLD_MOSCOW_FREE_DELIVERY,DELIVERY_COST_MOSCOW_BASE,DELIVERY_THRESHOLD_OTHER_CITY_WEIGHT,DELIVERY_WEIGHT_SURCHARGE_PER_KG,DELIVERY_THRESHOLD_OTHER_CITY_HALF_PRICE,DELIVERY_SUNDAY_SURCHARGE). - Заменить магические числа на эти константы.
Шаг 5: Реструктуризация основной логики (применение правил)
- Цель: Улучшить читаемость, сделать порядок применения правил более явным и подготовить к расширению.
- Действие:
- Разбить функцию на более мелкие, логически связанные блоки или методы.
- Рассмотреть возможность использования паттерна "Цепочка обязанностей" (Chain of Responsibility) или просто последовательность вызовов функций/методов, каждая из которых применяет одно правило.
- Например:
calculateBaseDeliveryCost(float $orderSum, float $orderWeight, string $normalizedCity): floatapplyVipDiscount(float $deliveryCost, bool $isVip): floatapplySundaySurcharge(float $deliveryCost, int $dayOfWeek): float- Важно: сохранить текущий порядок применения правил, особенно приоритет VIP и воскресной наценки.
Шаг 6: Инкапсуляция в класс (опционально, но рекомендуется)
- Цель: Улучшить архитектуру, сделать функцию частью объекта, который может управлять своими зависимостями и состоянием.
- Действие:
- Создать класс
DeliveryCalculator. - Переместить
calcDeliveryв этот класс как публичный методcalculate(array $order, string $city, bool $isVip): int. - Переместить вспомогательные функции (
calculateOrderSum,calculateOrderWeight,normalizeCity) в приватные методы этого класса. SystemClockпередавать через конструкторDeliveryCalculator(Dependency Injection).- Обновить 6 мест вызова, создавая экземпляр
DeliveryCalculatorи вызывая его метод.
4. Итоговый код (после всех шагов)
Предполагаем, что мы прошли все шаги и решили инкапсулировать логику в класс.
<?php
// --- Шаг 1: Изоляция date('N') ---
// Интерфейс для Clock, чтобы можно было легко мокать
interface Clock
{
public function getCurrentDayOfWeek(): int; // Возвращает 1 (понедельник) - 7 (воскресенье)
}
// Реализация для продакшена
class SystemClock implements Clock
{
public function getCurrentDayOfWeek(): int
{
return (int) date('N');
}
}
// --- Шаг 4: Извлечение магических чисел в константы ---
// Можно определить как константы класса или глобальные константы,
// в зависимости от архитектуры проекта. Для класса это предпочтительнее.
class DeliveryConstants
{
public const CITY_MOSCOW = 'москва'; // Нормализованное название
public const DELIVERY_THRESHOLD_MOSCOW_FREE_DELIVERY = 5000;
public const DELIVERY_COST_MOSCOW_BASE = 350;
public const DELIVERY_COST_OTHER_CITY_BASE = 450;
public const DELIVERY_THRESHOLD_OTHER_CITY_WEIGHT = 5; // кг
public const DELIVERY_WEIGHT_SURCHARGE_PER_KG = 60; // руб за кг сверх порога
public const DELIVERY_THRESHOLD_OTHER_CITY_HALF_PRICE = 15000;
public const DELIVERY_SUNDAY_SURCHARGE = 100;
public const DAY_OF_WEEK_SUNDAY = 7;
}
// --- Шаг 6: Инкапсуляция в класс ---
class DeliveryCalculator
{
private Clock $clock;
public function __construct(Clock $clock)
{
$this->clock = $clock;
}
// Оригинальный публичный интерфейс сохранен
public function calcDelivery(array $order, string $city, bool $isVip = false): int
{
// --- Шаг 3: Нормализация города ---
$normalizedCity = $this->normalizeCity($city);
// --- Шаг 2: Выделение расчета суммы и веса заказа ---
$orderSum = $this->calculateOrderSum($order);
$orderWeight = $this->calculateOrderWeight($order);
$deliveryCost = 0.0; // Используем float для промежуточных расчетов
// --- Шаг 5: Реструктуризация основной логики (применение правил) ---
if ($normalizedCity === DeliveryConstants::CITY_MOSCOW) {
$deliveryCost = $this->calculateMoscowDelivery($orderSum);
} else {
$deliveryCost = $this->calculateOtherCityDelivery($orderSum, $orderWeight);
}
// --- Применение VIP-скидки (имеет высокий приоритет) ---
// Важно: VIP обнуляет все, кроме воскресной наценки.
if ($isVip) {
$deliveryCost = 0.0;
}
// --- Применение воскресной наценки (применяется последней) ---
if ($this->clock->getCurrentDayOfWeek() === DeliveryConstants::DAY_OF_WEEK_SUNDAY) {
$deliveryCost += DeliveryConstants::DELIVERY_SUNDAY_SURCHARGE;
}
// --- Округление ---
return (int) round($deliveryCost);
}
// --- Вспомогательные приватные методы ---
/**
* @param array $order
* @return float
* @ Шаг 2: Выделение расчета суммы
*/
private function calculateOrderSum(array $order): float
{
$sum = 0.0;
foreach ($order['items'] as $item) {
$sum += $item['price'] * $item['qty'];
}
return $sum;
}
/**
* @param array $order
* @return float
* @ Шаг 2: Выделение расчета веса
*/
private function calculateOrderWeight(array $order): float
{
$weight = 0.0;
foreach ($order['items'] as $item) {
$weight += $item['weight'] * $item['qty'];
}
return $weight;
}
/**
* @param string $city
* @return string
* @ Шаг 3: Нормализация города
*/
private function normalizeCity(string $city): string
{
$normalized = mb_strtolower($city);
if ($normalized === 'мск') {
return DeliveryConstants::CITY_MOSCOW;
}
return $normalized;
}
/**
* @param float $orderSum
* @return float
* @ Шаг 5: Расчет для Москвы
*/
private function calculateMoscowDelivery(float $orderSum): float
{
if ($orderSum > DeliveryConstants::DELIVERY_THRESHOLD_MOSCOW_FREE_DELIVERY) {
return 0.0;
}
return DeliveryConstants::DELIVERY_COST_MOSCOW_BASE;
}
/**
* @param float $orderSum
* @param float $orderWeight
* @return float
* @ Шаг 5: Расчет для других городов
*/
private function calculateOtherCityDelivery(float $orderSum, float $orderWeight): float
{
$d = DeliveryConstants::DELIVERY_COST_OTHER_CITY_BASE;
if ($orderWeight > DeliveryConstants::DELIVERY_THRESHOLD_OTHER_CITY_WEIGHT) {
$d += ($orderWeight - DeliveryConstants::DELIVERY_THRESHOLD_OTHER_CITY_WEIGHT) * DeliveryConstants::DELIVERY_WEIGHT_SURCHARGE_PER_KG;
}
if ($orderSum > DeliveryConstants::DELIVERY_THRESHOLD_OTHER_CITY_HALF_PRICE) {
$d /= 2;
}
return $d;
}
}
// --- Обновленные вызовы функции ---
// В 6 местах, где вызывалась функция, теперь будет:
// $deliveryCalculator = new DeliveryCalculator(new SystemClock());
// $cost = $deliveryCalculator->calcDelivery($order, $city, $isVip);
Пример использования:
// В вашем коде, где раньше вызывалась функция:
$order = [
'items' => [
['price' => 1000, 'qty' => 2, 'weight' => 1.5],
['price' => 500, 'qty' => 3, 'weight' => 0.5],
]
];
$city = 'Москва';
$isVip = false;
// Создаем экземпляр калькулятора (можно сделать синглтон или передавать через DI)
$deliveryCalculator = new DeliveryCalculator(new SystemClock());
$deliveryCost = $deliveryCalculator->calcDelivery($order, $city, $isVip);
echo "Стоимость доставки: " . $deliveryCost . " руб.\n";
5. Места, где ты не уверен, что поведение сохранилось, и что проверить.
- Поведение
round(): - Неуверенность: В исходном коде я обнаружил, что
round($d)фактически не меняет значениеd, потому чтоdвсегда целое число до примененияround(). Это связано с тем, что450четное,(w-5)*60всегда четное, а деление на2происходит только с четными числами, давая целое. - Что проверить: Мой рефакторинг сохранил
round()в конце. Если в будущем появятся правила, которые могут привести к нецелым значениям доround(), то текущее поведениеround()(округление до ближайшего целого) будет применено. Важно убедиться, что это желаемое поведение. Еслиround()был добавлен "на всякий случай", то его наличие сохраняет этот "всякий случай". Если же он был добавлен с конкретной целью, которая сейчас не проявляется, стоит это задокументировать. Тесты подтверждают, что результат всегдаint.
- Приоритет VIP-скидки и воскресной наценки:
- Неуверенность: Исходный код:
if ($isVip) $d = 0; if (date('N') == 7) $d += 100;. Это означает, что VIP-клиент в воскресенье платит 100, даже если его доставка должна была быть бесплатной. Это очень неочевидное поведение. - Что проверить: Мой рефакторинг сохранил этот порядок: сначала применяется VIP-скидка (обнуляя
deliveryCost), затем добавляется воскресная наценка. ТестыtestVipDeliveryCosts100OnSundayиtestMoscowFreeDeliveryOnSundayBecomes100явно проверяют это. Если это был баг, его нужно будет исправить отдельным коммитом после рефакторинга, явно задокументировав изменение поведения.
- Производительность (повторный перебор
$order['items']): - Неуверенность: В исходном коде для не-Москвы массив
$order['items']перебирался дважды (один раз для суммы, второй для веса). В рефакторинге я выделилcalculateOrderSumиcalculateOrderWeightв отдельные методы, что означает два отдельных перебора. - Что проверить: Для большинства интернет-магазинов количество товаров в заказе невелико (десятки, редко сотни). Два перебора вместо одного вряд ли окажут заметное влияние на производительность. Однако, если
$order['items']может содержать тысячи элементов, и эта функция вызывается очень часто, то можно было бы оптимизироватьcalculateOrderSumиcalculateOrderWeightв один методcalculateOrderMetrics(который возвращал бы массив['sum' => ..., 'weight' => ...]), чтобы избежать двойного перебора. Для данного контекста (PHP 8.1, самописный фреймворк, 6 вызовов) это, скорее всего, не критично, и читаемость важнее микрооптимизации.
mb_strtolowerдля нормализации города:- Неуверенность: Я использовал
mb_strtolowerдля нормализации города. Исходный код сравнивал'Москва','москва','МСК'.mb_strtolowerкорректно обрабатывает кириллицу. - Что проверить: Убедиться, что в вашем проекте
mb_stringрасширение включено. Если нет, можно использоватьstrtolower, но тогда нужно быть уверенным, что в названиях городов не будет специфических символов, которыеstrtolowerобрабатывает некорректно. Для'Москва'и'москва'это не проблема.
В целом, рефакторинг был выполнен с максимальным сохранением поведения. Характеризационные тесты должны поймать любые непреднамеренные изменения.
Советы
- Сначала закоммитьте только тесты и убедитесь, что они зелёные на старом коде, и только потом меняйте функцию.
- Спорное поведение (как воскресная наценка для VIP) не исправляйте молча: сохраните как есть и заведите задачу на продукт.
- Откройте доступ и скопируйте промпт кнопкой выше.
- Замените поля в фигурных скобках своими данными.
- Отправьте в нейросеть и сравните ответ с примером на этой странице.
Подробнее о структуре хорошего запроса: гид AI University.
Похожие промпты
Все 435 промптов и 6 наборов
172 промптов открыты бесплатно. Остальные и наборы-цепочки открывает доступ к библиотеке за 1 490 ₽. Полный доступ за 4 900 ₽: все курсы AI University на русском и библиотека промптов. Разовый платёж, новые промпты входят.