DEV Community

Dakota Huang
Dakota Huang

Posted on

Freeze Aging Labels Before One Status Extract

Freeze Aging Labels Before One Status Extract

Pin current aging labels before you trust a cleanup diff. Extract one status rule only after those labels stay identical. A wider split hides label bugs inside unrelated edits.

Do not start this refactor with a broad cleanup. Record the labels, cents, and stderr lines first. Then move one function and stop the edit.

Why this cut stays small

A mixed diff is hard to review under time pressure. Reviewers cannot see which hunk changed a bucket. A one-function extract keeps that question small.

The inherited function also prints a debug line. That print is observed behavior, not harmless noise. Tests should fail if the line moves or vanishes.

The function you inherit

The sample below is a worked fixture, not a production trace. It mixes status labels, cent rounding, and a stderr print. Read the listing as unexecuted example code only.

# aging.py — unexecuted example, not a production trace
import sys

def build_aging_row(amount, days_overdue):
    cents = int(round(float(amount) * 100))
    if days_overdue is None or days_overdue <= 0:
        label = "current"
    elif days_overdue <= 30:
        label = "1-30"
    elif days_overdue <= 60:
        label = "31-60"
    elif days_overdue <= 90:
        label = "61-90"
    else:
        label = "90+"
    print(f"aging {label} {cents}", file=sys.stderr)
    return {"cents": cents, "label": label}
Enter fullscreen mode Exit fullscreen mode

Missing days become the label current in this fixture. Negative days follow that same branch today too. Leave both paths untouched during this status extract.

Rounding stays inside the caller for this pass. The status rule is the only code that moves. That boundary is the entire point of the cut.

What the golden file must hold

Freeze three fields for every pinned fixture row. Freeze the label string and the cent integer. Freeze the exact stderr line, including its trailing newline.

Do not assert the finance rule you wish were true. Assert the values this interpreter emits today. A later ticket can change those values on purpose.

Inputs worth pinning

Cover the bucket edges and the missing-day path. Use a plain amount so float noise stays out. Store outputs in a golden file after one local capture.

The table lists inputs and the reason for each row. It does not list cents, because those come from capture. Hand-typed cents are how golden files go stale.

amount days_overdue why this row exists
10 0 non-positive days map to current
10 None missing days stay on the current path
10 30 last day inside the 1-30 bucket
10 31 first day inside the 31-60 bucket
10 60 last day inside the 31-60 bucket
10 61 first day inside the 61-90 bucket
10 90 last day inside the 61-90 bucket
10 91 first day inside the 90+ bucket
0 -1 negative days stay on the current path

Skip live ledgers, clocks, and network calls here. Those inputs will not replay on a second run. This lesson needs rows you can execute twice.

Steps to seal, then extract

1. Put the fixture in one folder

Keep the module and the test side by side. Do not import the real billing package yet. A tiny folder makes the diff easy to read.

Name the files so the review stays obvious. The tree below is the whole fixture for this pass. Keep production billing code outside this folder.

aging.py
aging_cases.py
capture_aging.py
test_aging_characterize.py
golden_aging.json
Enter fullscreen mode Exit fullscreen mode

Share one case list between capture and compare. Two copied lists will drift on the next edit. The helper below is the single source for rows.

# aging_cases.py — unexecuted example
import contextlib
import io

CASES = [
    {"amount": "10", "days_overdue": 0},
    {"amount": "10", "days_overdue": None},
    {"amount": "10", "days_overdue": 30},
    {"amount": "10", "days_overdue": 31},
    {"amount": "10", "days_overdue": 60},
    {"amount": "10", "days_overdue": 61},
    {"amount": "10", "days_overdue": 90},
    {"amount": "10", "days_overdue": 91},
    {"amount": "0", "days_overdue": -1},
]

def collect(build_row):
    found = []
    for case in CASES:
        buf = io.StringIO()
        with contextlib.redirect_stderr(buf):
            row = build_row(case["amount"], case["days_overdue"])
        found.append({
            "amount": case["amount"],
            "days_overdue": case["days_overdue"],
            "cents": row["cents"],
            "label": row["label"],
            "stderr": buf.getvalue(),
        })
    return found
Enter fullscreen mode Exit fullscreen mode

2. Capture outputs before any edit

Run the capture once on your own machine. Commit the golden file beside the compare test. Do not hand-type observed cents from memory later.

# capture_aging.py — unexecuted example
import json
from pathlib import Path
import aging
from aging_cases import collect

def main():
    path = Path("golden_aging.json")
    payload = collect(aging.build_aging_row)
    path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
    print(path.name)

if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode
python capture_aging.py
python -m json.tool golden_aging.json > /dev/null
Enter fullscreen mode Exit fullscreen mode

Check that the sealed file is valid JSON. A parser check is not a behavior check. It only proves the next compare can load the seal.

The redirect above is a Unix shell example. Use your shell's null device if the path differs.

3. Compare on every later run

The compare test reads the golden file and fails on drift. It must not rewrite the file during compare. A quiet rewrite would hide a bad extract.

# test_aging_characterize.py — unexecuted example
import json
from pathlib import Path
import aging
from aging_cases import collect

def test_aging_matches_golden():
    path = Path("golden_aging.json")
    assert path.exists(), "run capture_aging.py before compare"
    expected = json.loads(path.read_text(encoding="utf-8"))
    found = collect(aging.build_aging_row)
    assert found == expected
Enter fullscreen mode Exit fullscreen mode

Install pytest in that folder before the compare run. A green run means the sealed rows still match. It does not mean the labels match a finance policy.

python -m pytest test_aging_characterize.py -q
Enter fullscreen mode Exit fullscreen mode

Keep those two judgments in separate reviews. Policy checks belong with an intended label change. This file only guards the seal you just wrote.

4. Write the extract as a proposal

Move only the branch that picks the label. Call the new helper from the old function. Leave rounding, printing, and return keys in place.

# proposed aging.py — unexecuted proposal, not a measured result
import sys

def label_for_days(days_overdue):
    if days_overdue is None or days_overdue <= 0:
        return "current"
    if days_overdue <= 30:
        return "1-30"
    if days_overdue <= 60:
        return "31-60"
    if days_overdue <= 90:
        return "61-90"
    return "90+"

def build_aging_row(amount, days_overdue):
    cents = int(round(float(amount) * 100))
    label = label_for_days(days_overdue)
    print(f"aging {label} {cents}", file=sys.stderr)
    return {"cents": cents, "label": label}
Enter fullscreen mode Exit fullscreen mode

Treat the helper listing as a proposal, not a measured result. Run the compare test before you trust the move. Reject the patch if any golden row changes.

5. Reject diffs that do extra work

Read the diff before you read any explanation. Count the functions that the patch actually touches. Accept one new helper and one call site only.

git diff --unified=3 -- aging.py
Enter fullscreen mode Exit fullscreen mode

Golden row changes mean behavior moved, so reject them. Stderr changes mean the print drifted, so reject them. A new dependency means the cut grew, so reject it.

diff signal decision reason
golden rows differ reject behavior moved, not only structure
stderr text differs reject the print is part of the seal
rounding expression changes reject that edit is a second refactor
missing-day branch changes reject a bug fix belongs in a later ticket
new dependency added reject this extract needs no new package
only the label helper is added accept this is the smallest safe cut

A model often repairs the missing-day path while extracting. That extra repair fails the written decision table. The table outranks a fluent explanation of the fix.

6. Stop after the green compare

Do not extract rounding in the same change. Do not delete the stderr print in this pass. One green extract is the end of this ticket.

Open a second ticket for intended behavior changes. That ticket should edit the golden file on purpose. Reviewers can then see the intended label shift.

Where a free model can help

You can ask a free model to draft the helper. Give it a short constraint, and demand a diff. Do not ask it to rewrite the module as an essay.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode free model access can draft that constrained extract. You can run the same pytest command on the free server option.

Use either path only on this fixture, which holds no secrets. Treat the prompt text as a sketch, not a product contract. Availability here is an operator-supplied claim for this draft.

Extract label_for_days from build_aging_row.
Do not change rounding, prints, keys, or None handling.
Do not add dependencies.
Return a unified diff only.
Enter fullscreen mode Exit fullscreen mode

It is not a promise about quotas, hardware, or duration. Paste the returned diff back into your tree. Run the compare test on a machine you control.

A green server run is a hint, not a release decision. Server images can differ from your laptop interpreter. Re-run the compare where you will merge the patch.

Limits of this gate

Characterization tests lock today's behavior, including known mistakes. They are not a proof of correct finance rules. They also do not measure thread safety or speed.

Python 3 round uses half-to-even for exact ties. This fixture avoids tie amounts so that rule stays idle. See the round docs for that tie rule.

The golden file belongs to the interpreter that sealed it. A different build can still surprise you on other amounts. That is why these rows use the amount string 10.

Do not upload customer rows to a free server. The sample has no names, accounts, or secret keys. A real ledger extract would be the wrong payload.

Free model access can change or end later. This workflow still works with a local editor alone. The tests, not the tool, hold the safety line.

Who should skip this pass

Skip this pass if the ticket must change labels now. Write a failing policy test for the new rule. Then edit the golden file inside that same review.

Skip it when inputs include clocks, randomness, or live HTTP. Seal those edges first, or the compare will flap. Flapping tests teach people to ignore red runs.

Skip it if you cannot explain the missing-day path. Preserving a mystery is not a safe cut. Read that branch, pin it, and only then move it.

What to do next

After this extract stays green, stop the cleanup. Queue a separate change for rounding or missing days. Each change should update the golden file in clear view.

The merge rule is small enough to repeat on the next module. Pin the rows, move one rule, and compare again. Reject any diff that quietly does a second job.

If you already have that free model access, use it here. Let it draft the helper, then let the compare test decide. Skip the tool when you cannot read the resulting diff.

Top comments (0)