Skip to content

refactor: extract-method hygiene pass on 3 large methods (finding 8) - #33

Merged
petercorke merged 8 commits into
mainfrom
chore/extract-method-hygiene
Aug 2, 2026
Merged

refactor: extract-method hygiene pass on 3 large methods (finding 8)#33
petercorke merged 8 commits into
mainfrom
chore/extract-method-hygiene

Conversation

@petercorke

Copy link
Copy Markdown
Owner

Summary

Conservative, behavior-preserving extract-method refactor on the large methods flagged in the code review, plus real test coverage added ahead of each risky change rather than after.

Image.__init__ (ImageCore.py, was 379 lines): extracted _infer_dtype (staticmethod, pure) and _reshape_to_size. Added 22 new tests (TestImageInferDtype, TestImageReshapeToSize) covering every branch/combination — real gaps found: no test for dtype=False, no test for either of the two "color plane count mismatch" ValueError paths. That coverage made it safe to then remove a genuinely dead self.colororder is not None branch (self._colororder is always None at that point in construction — confirmed via the property getter and by checking nothing sets it earlier) and its now-unused color_dict parameter.

Image.__getitem__ (ImageCore.py, was 180 lines): extracted the fixdims/lenkey nested closures — captured zero outer variables, lowest-risk extraction in this PR. Added 3 tests closing real gaps: a stepped-slice case (im[::2, ::3], exercises the n // step arithmetic branch) and both "invalid slice" ValueError paths had zero coverage before.

Camera.plot (Camera.py, was 175 lines): had zero test coverage at all before this PR. Added characterization tests first (artist-count assertions for shape="frustum", shape="camera", frame=True), then extracted _plot_frustum/_plot_camera_icon. Found and fixed a real test-isolation bug while getting these to pass in the full suite: plot()'s ax=None path reuses whatever matplotlib considers the "current" 3D axes, so a figure left open by an unrelated earlier test was silently reused, making artist counts meaningless (40 instead of 3) — fixed with plt.close("all") in setUp/tearDown.

ImageProcessing.threshold: no extraction, per plan — the "196 lines" is mostly docstring; the real executable body is ~86 lines and already linear/well-scoped.

Histogram.plot (ImageWholeFeatures.py, was ~420 lines): only style="overlay"'s error path had a test before this PR — added a happy-path test (style="overlay", filled=True on a 3-plane histogram) first, then extracted _compute_plot_series, _histogram_plane_stats (removes real duplication — was a nested closure called from both the stack and overlay branches), and _resolve_overlay_colors (mutates self.colordict as a side effect — preserved exactly, documented rather than silently dropped). Deliberately did not extract the stack-loop body or the cursor sub-block (nested on_move() closure capturing ~8 outer locals) — extraction there would just relocate the long-parameter-list smell into explicit arguments, not fix anything.

Test plan

  • Every extraction verified behavior-preserving: pre-existing and newly-added tests pass with byte-identical assertions/artist-counts before and after each change
  • Full suite: 847 passed (807 baseline + 40 new tests across this PR), 15 skipped, no regressions, stable across repeated runs

Image.__init__ was 379 lines doing several unrelated jobs. Extracted
two self-contained pieces:

- _infer_dtype(image, dtype): staticmethod, pure function -- resolves
  the constructor's dtype= argument (auto-detect smallest int type,
  inherit via dtype=True, validate an explicit dtype, or reject
  dtype=False). Zero risk: no self access at all.
- _reshape_to_size(self, image, size): the size= constructor argument
  handling -- reshaping 1D/oddly-oriented 2D input into the right
  (height, width, planes) shape.

Added comprehensive test coverage first (TestImageInferDtype,
TestImageReshapeToSize in test_image_core.py -- 22 new tests, one per
branch/combination), since this code had real coverage gaps: no test
for dtype=False, no test for either of the two "color plane count
mismatch" ValueError paths in the reshape logic. All 22 pass against
the extracted code.

That coverage made a follow-up safe: _reshape_to_size had a
`self.colororder is not None` branch that's provably dead code --
self._colororder is set to None at the very start of __init__ and
never touched before this point, so the branch can never run.
Confirmed via the getter (`return self._colororder`, no other logic)
and by grepping for any other write to self._colororder earlier in the
method -- none. Removed it and the now-unused color_dict parameter it
was the only consumer of.

Verified: full suite 840 passed (818 baseline + 22 new tests), 15
skipped, no regressions -- including immediately after the dead-code
removal, confirming it was a true no-op as expected.
The nested fixdims()/lenkey() closures captured zero outer variables
-- pure functions of their own parameters -- the lowest-risk
extraction of this whole hygiene pass. Pulled out as _lenkey
(staticmethod) and _fixdims (classmethod, calls cls._lenkey), matching
the _infer_dtype/_plane_stats staticmethod pattern already used
elsewhere in this class. Minor bonus: no longer recreates a closure on
every __getitem__ call.

Also closed real coverage gaps found while verifying this: no test
exercised a stepped slice (im[::2, ::3], the n // step arithmetic
branch in _lenkey, as opposed to the span-1 shortcut), and neither of
the two "invalid slice" ValueError paths (wrong number of slice
elements, or a key type that's neither int/str/tuple/list) had any
test at all.

Full suite: 843 passed (840 + 3 new tests), 15 skipped, no regressions.
CameraBase.plot() had zero test coverage before this -- adding it
first, ahead of an upcoming extract-method refactor, so there's an
actual before/after baseline instead of "trust me it still works".
Asserts on artist counts (ax.collections/ax.lines) for shape="frustum",
shape="camera", and frame=True, not deep rendering correctness.

Found and fixed a real test-isolation bug while getting these to pass
in the full suite: plot()'s ax=None path reuses whatever matplotlib
considers the "current" 3D axes (spatialmath.base.graphics.axes_logic
docs: "checks for a match with the passed axes ax or the current
axes"). Without closing figures in setUp, a figure left open by an
unrelated, earlier test in the suite got silently reused, making the
artist-count assertions meaningless (40 collections instead of 3,
passed in isolation, failed in the full run). Added plt.close("all")
to both setUp and tearDown.

Full suite: 846 passed (843 + 3 new tests), 15 skipped, no
regressions, stable across repeated runs.
CameraBase.plot() mixed a matplotlib-version-compat shim, two
independent icon-drawing paths (frustum vs camera+cylinder), and a
pose-frame overlay in one 175-line method. Extracted the two
self-contained icon-drawing paths as _plot_frustum and
_plot_camera_icon; left the compat shim (try/finally around
ax.add_collection3d) and the frame overlay in plot() itself, since
those aren't independent sub-tasks.

Preserved verbatim rather than "cleaned up": an unused local variable
(`a = 3  # length of axis line segments` in the camera-icon branch,
never referenced) -- out of scope for a behavior-preserving extraction.

Verified behavior-preserving: the characterization tests added in the
previous commit (which had zero coverage before this refactor started)
show byte-identical artist counts before and after this extraction.
Full suite: 846 passed, 15 skipped, no regressions.
The only existing overlay-style test (test_hist_sorted_overlay_raises)
covers the error path only -- nothing exercised actual overlay
rendering (color assignment, filled polygons) before this. Adding
ahead of an upcoming extract-method refactor of plot(), same reasoning
as the Camera.plot() characterization tests: real coverage before
touching the code, not after.

Full suite: 847 passed (846 + 1 new test), 15 skipped, no regressions.
Histogram.plot() was ~420 lines. Extracted the three low-closure-
capture pieces:

- _compute_plot_series(type, samescale): the type-dispatch block that
  computes y/maxy/ylabel1/ylabel2. Pure computation over self.h/
  self.pdf/self.cf/self.cdf. The `if filled is None: filled = True`
  side effect that lived inside the "frequency" branch stays in plot()
  itself as a one-line follow-up, since it mutates a variable used
  well beyond this block.
- _histogram_plane_stats(counts): was a nested plane_stats() closure,
  called from both the stack and overlay branches (real duplication
  removed, not just relocated).
- _resolve_overlay_colors(n, colors): per-plane color assignment for
  style="overlay". Mutates self.colordict as a side effect when unset
  -- preserved exactly, documented in the new method's docstring
  rather than silently dropped.

Deliberately NOT extracted (per the plan for this hygiene pass): the
stack-loop body, the cursor sub-block (nested on_move() closure
capturing ~8 outer locals), or splitting stack/overlay into top-level
methods -- all have heavy closure capture where extraction would just
relocate the long-parameter-list smell into explicit arguments.

Verified behavior-preserving: the 5 plot-related tests (including the
two added in the previous two commits specifically to cover this
method before touching it) pass with byte-identical assertions before
and after. Full suite: 847 passed, 15 skipped, no regressions, stable
across repeated runs.
@codacy-production

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 4 high

Alerts:
⚠ 4 issues (≤ 0 issues of at least minor severity)

Results:
4 new issues

Category Results
ErrorProne 4 high

View in Codacy

🟢 Metrics 40 complexity · 0 duplication

Metric Results
Complexity 40
Duplication 0

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@petercorke
petercorke merged commit 44cb97c into main Aug 2, 2026
29 of 32 checks passed
@github-actions github-actions Bot mentioned this pull request Aug 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant