DEV Community

Dakota Huang
Dakota Huang

Posted on

Extract Rounding Only After the Row Contract Is Green

A messy normalizer should not be split blind. Characterization tests must lock today's outputs before any split. Only then is one helper extract reasonably safe.

Clean structure is not the same as a safe change. A shorter function can still change totals, order, or inputs. Those three shifts are defects if callers still rely on them.

This walkthrough uses one messy row normalizer as the specimen. The goal is preservation, not a better pricing policy. Treat every fixture result as a contract, not a suggestion.

What the specimen actually does

The listing below is a worked example for you to run. It is not a report of a production incident or benchmark. Copy it into a scratch repository before you edit anything.

# normalize_rows.py — before the extract
def normalize_rows(rows):
    cleaned = []
    for row in rows:
        name = str(row.get("name", "")).strip()
        raw_qty = row.get("qty", "0")
        qty = int(str(raw_qty).strip() or "0")
        if not name and qty == 0:
            continue
        raw_price = row.get("price", "0")
        price = float(str(raw_price).strip() or "0")
        total = round(qty * price, 2)
        cleaned.append({"name": name.lower(), "qty": qty, "total": total})
        row["seen"] = True
    cleaned.sort(key=lambda item: item["name"])
    return cleaned
Enter fullscreen mode Exit fullscreen mode

Four behaviors matter more than line count here. The function drops a blank name when quantity is zero. It lowercases names, sorts them, and mutates kept rows.

Money math rounds the product to two decimal places. Missing price text becomes zero after a strip. Empty quantity text also becomes zero, not an error.

Pin those choices even when the policy looks wrong. A later change can fix policy in a separate commit. This commit only proves the split did not drift.

Dropped rows are not marked, because continue runs first. Kept rows gain a seen flag after the total is stored. That order is part of the contract, not an accident.

1. Freeze the function before tests

Start by freezing the function body in version control. Do not rename fields before the first green run. A rename hides whether the extract changed behavior.

Track the file hash or the commit id beside the test. You need a clear baseline when a later diff looks small. Small diffs can still move money and mutation.

2. Write one assertion per contract

Each test should assert one observable contract only. Prefer direct asserts over a vague snapshot of everything. Snapshots hide which key changed when a diff fails.

# tests/test_normalize_rows.py
import pytest

from normalize_rows import normalize_rows


def test_sorts_by_lowercased_name():
    rows = [
        {"name": "Beta", "qty": "1", "price": "2.00"},
        {"name": "alpha", "qty": "2", "price": "1.50"},
    ]
    result = normalize_rows(rows)
    assert [item["name"] for item in result] == ["alpha", "beta"]


def test_drops_blank_name_with_zero_qty_and_skips_seen():
    rows = [{"name": "  ", "qty": "", "price": "9"}]
    assert normalize_rows(rows) == []
    assert "seen" not in rows[0]


def test_keeps_blank_name_when_qty_present():
    rows = [{"name": "", "qty": "2", "price": "1.25"}]
    result = normalize_rows(rows)
    assert result == [{"name": "", "qty": 2, "total": 2.5}]
    assert rows[0]["seen"] is True


def test_rounds_exact_product_to_two_places():
    rows = [{"name": "Nia", "qty": "3", "price": "1.25"}]
    result = normalize_rows(rows)
    assert result[0]["qty"] == 3
    assert result[0]["total"] == 3.75


def test_stable_order_for_equal_lowercased_names():
    rows = [
        {"name": "sam", "qty": "1", "price": "1.00"},
        {"name": "Sam", "qty": "2", "price": "1.00"},
    ]
    result = normalize_rows(rows)
    assert [item["qty"] for item in result] == [1, 2]


def test_non_numeric_qty_raises_value_error():
    rows = [{"name": "Ada", "qty": "x", "price": "1"}]
    with pytest.raises(ValueError):
        normalize_rows(rows)
Enter fullscreen mode Exit fullscreen mode

Include the dropped-row case that never sets seen. Include equal names so stable sort order stays pinned. Include a non-numeric quantity that must raise ValueError.

The Python docs guarantee that list.sort is stable. Read the sorting HOWTO for the documented stability guarantee. Pin equal-name order only on a runtime with that guarantee.

Do not assert private local names or loop counts. Those details can change during a pure extract. Assert returned rows, source mutation, and exceptions only.

3. Run the baseline before any edit

Install pytest if that module is not already present. Use the environment you already trust for this repo. Do not mix this run with an unrelated global upgrade.

mkdir -p scratch_repo/tests
python -m pip install pytest
# Place normalize_rows.py and tests/test_normalize_rows.py first.
cd scratch_repo
PYTHONPATH=. python -m pytest tests/test_normalize_rows.py -q --tb=short
Enter fullscreen mode Exit fullscreen mode

Run the new tests before any extraction work starts. A red baseline means the oracle does not match code. Fix the assertion or the fixture, not the production path.

Save the green command in the commit message body. Reviewers need the exact command, not a vague note. Re-run that same command after the helper extract.

Record pass count only after you run it yourself. This article does not claim a measured pass rate. The expected result is a green run on unchanged code.

4. Pick the smallest safe change

The smallest safe change here is a pure total helper. It should compute the rounded product and nothing else. Leave sorting, dropping, and mutation in the caller.

Do not combine the extract with a bug fix. A dropped mutation and a new helper are two changes. Split them so a failure has one obvious cause.

Signal Next step Do not proceed if
No characterization tests Write oracles from current code You intend to edit production first
Tests fail on unchanged code Correct the oracle or the fixture The failure is a live incident
Tests pass and the formula is pure Extract only the rounded product The diff touches sort or mutation
Desired totals differ from the oracle Open a behavior-change task You still label the work a refactor
Draft rewrites the loop body Reject it and request a smaller diff You cannot explain every changed line

Use the table as a gate, not as a style guide. If a row says stop, stop the refactor for now. Open a separate task when the desired rule differs.

5. Extract only the rounding expression

Move only the rounding expression into a new function. Keep the call at the same point in the loop. Pass numbers in, and return one number out.

def _rounded_product(qty, price):
    return round(qty * price, 2)


def normalize_rows(rows):
    cleaned = []
    for row in rows:
        name = str(row.get("name", "")).strip()
        raw_qty = row.get("qty", "0")
        qty = int(str(raw_qty).strip() or "0")
        if not name and qty == 0:
            continue
        raw_price = row.get("price", "0")
        price = float(str(raw_price).strip() or "0")
        total = _rounded_product(qty, price)
        cleaned.append({"name": name.lower(), "qty": qty, "total": total})
        row["seen"] = True
    cleaned.sort(key=lambda item: item["name"])
    return cleaned
Enter fullscreen mode Exit fullscreen mode

The name _rounded_product is enough for this extract. A broader name invites extra behavior into the diff. Keep the helper private until a second caller exists.

Re-run the same pytest command on the new tree. A green run means the pinned contracts still hold. Restore the file and shrink the change immediately.

6. Let a free model draft, not decide

A coding model can draft tests faster than a tired reader. It cannot be the oracle for behavior you must preserve.

MonkeyCode provides free model access and a free server option. Disclosure: This article was prepared as part of MonkeyCode's product outreach. Those are the only product claims this method relies on.

No model name, quota, hardware, or uptime is assumed here. Use them only to draft tests and to run the suite. Availability here is not a promise about future limits.

Steps for a bounded draft

Paste the frozen function plus the contracts listed above. Request only pytest functions with literal expected values. Reject any reply that edits the normalizer in that step.

Draft pytest characterization tests only.
Do not edit normalize_rows.
Pin lowercased sort, dropped blank rows, the seen flag,
stable equal-name order, and ValueError on qty text x.
Use literal expected values. The reviewer recomputes every total.
Enter fullscreen mode Exit fullscreen mode

Compute each total yourself before accepting an assertion. Check sort order, dropped rows, and the seen flag. Add a case the draft missed if mutation is absent.

Run the suite locally, or use the free server option. Keep secrets, customer rows, and tokens out of the prompt. A remote run still needs the same local review bar.

Require a green run on unchanged production code first. Only then request a one-helper extract with tests untouched. If the reply changes tests, discard it and ask again.

Diff the result and read every changed line. Accept the helper only when the same suite stays green. If output shifts, restore the branch and shrink the ask.

7. Reject unsafe diffs with a checklist

Reject a diff that sorts by a different key. Reject a diff that stops writing the seen flag. Reject a diff that changes rounding or blank-row rules.

Reject a diff that adds network calls or file writes. Reject a diff you cannot explain in two sentences. Small and unexplained is still an unsafe change.

# Rejected sketch. Do not apply this diff.
def normalize_rows(rows):
    cleaned = []
    for row in rows:
        name = str(row.get("name", "")).strip().lower()
        qty = int(str(row.get("qty", "0")).strip() or "0")
        if not name and qty == 0:
            continue
        price = float(str(row.get("price", "0")).strip() or "0")
        total = float(f"{qty * price:.2f}")
        cleaned.append({"name": name, "qty": qty, "total": total})
    cleaned.sort(key=lambda item: (item["total"], item["name"]))
    return cleaned
Enter fullscreen mode Exit fullscreen mode

The rejected sketch changes the rounding path and the sort. It also drops the seen flag from every source row. Neither one belongs in a preservation-only extract here.

These exact fixtures can still match on the total alone. Sort order and the seen flag are what catch this sketch. That is why one total assert is not enough.

Limits you should state in the review

Characterization tests preserve bugs as well as features. They do not decide whether the pricing policy is right. Float behavior beyond these fixtures is still unpinned.

A free model may miss in-place mutation and sort order. That gap is why hand-checked oracles stay mandatory. Free access does not imply a quota, chip, or duration.

The free server option is an isolated run location only. This guide does not measure latency, cost, or uptime. Do not publish numbers you did not measure yourself.

This example was not executed inside the article text. Run the files before you treat the asserts as confirmed. If a float differs, switch the fixture to a stable decimal.

Who should skip this approach

Skip this approach when you must change behavior today. A hotfix is not a preservation refactor in disguise. Write a failing target test for the new rule instead.

Skip it when the function deletes files or calls services. Characterization without stubs can damage shared real systems. Isolate those effects before you capture an oracle.

Skip it if you will merge a model diff unread. An unread helper is a behavior change you did not choose. Skip it when your tree has no test runner at all.

An unrun assertion is a comment with extra syntax. Install the runner first, then lock the row contract. After that, the extract is a small mechanical step.

What to merge

The safe split is the one the oracle still accepts. Extract one pure helper, then stop for that commit. Leave policy repairs for a later, explicit change.

If you already use MonkeyCode, draft assertions with the free model. Run them on the free server only after a local read. Keep the merge decision with the person who owns the bug.

Top comments (0)