The biggest structural problem isn't test coverage — it's that `layoutBlocks`... — 4 Rounds — converged
claude-code — ~/WebstormProjects/echo
Claude Code
~/WebstormProjects/echo
Round 1: The biggest structural problem isn't test coverage — it's that `layoutBlocks` takes a full `PDFDocument` when it only needs two methods: `widthOfString` and `heightOfString`.

The biggest structural problem isn't test coverage — it's that layoutBlocks takes a full PDFDocument when it only needs two methods: widthOfString and heightOfString. Everything else in layoutBlock is pure computation. That PDFDocument dependency is the root cause of all the mock brittleness.

The fix is a TextMetrics interface:

``typescript interface TextMetrics { widthOfString(text: string): number; heightOfString(text: string, options?: { lineBreak?: boolean; width?: number }): number; } ``

Extract font-setting into the metrics implementation, not the caller. Then layoutBlocks signature becomes:

``typescript layoutBlocks(metrics: TextMetrics, blocks: HtmlBlock[], options: CoverPageHtmlRendererOptions): LaidOutBlock[] ``

In production, wrap the real PDFDocument. In tests, pass a calibrated implementation — one that actually models fontSize × characterCount consistently, not the current mock where trackFontSize is an afterthought opt-in.

This isn't a "nice to have" refactor. The current trackFontSize flag is proof the mock is already mis-specified: the default behavior (constant height regardless of fontSize) is wrong, and tests that don't opt in to trackFontSize are silently not testing what they claim. Every new heading test that passes without trackFontSize: true is a test that would pass even if effectiveOptions was never plumbed through.

The extraction also makes the public contract explicit: layoutBlocks is a pure layout function that transforms blocks into positioned runs given font metrics. That's a meaningful semantic boundary, and the current signature obscures it.

On property-based testing: it's worth doing for the parseHtmlToBlocks pipeline (pure, no mock needed), specifically for whitespace normalization invariants. The current tests are example-based and miss edge cases like mixed-BOM text or zero-width joiners. But for layout, property tests against a fake metrics implementation still only prove internal consistency — not that the layout is correct against real fonts. The metrics interface extraction solves that more directly by making "real metrics in integration, deterministic metrics in unit tests" the natural pattern.

›› bypass permissions on◐ medium · /effort
~/WebstormProjects/echo
>_ Codex (CLI)
directory: ~/WebstormProjects/echo
Round 2: I agree with the dependency inversion, but your `TextMetrics` shape is...

I agree with the dependency inversion, but your TextMetrics shape is under-specified and would preserve the same class of bugs.

layoutBlock does not just need “a thing that measures text.” It needs measurements as a function of style and fontSize. In the current code, width/height vary by bold/italic font selection and by heading-scaled options.fontSize. An interface like widthOfString(text) plus hidden mutable font state in the adapter just recreates PDFKit’s implicit state machine in a different wrapper, which means tests can still pass with the wrong font or wrong size plumbed through.

The boundary should be explicit and stateless, e.g.:

``ts interface TextMeasurer { measureWidth(text: string, style: HtmlInlineStyle, fontSize: number): number; measureHeight(style: HtmlInlineStyle, fontSize: number, width: number): number; } ``

Then layoutBlocks(measurer, blocks, options) is actually pure, cache keys stay honest, and the heading bug class becomes directly testable. With that interface, deterministic “golden value” assertions on LaidOutBlock are worth doing because you can assert exact fontSize, textHeight, xOffset, wrapping, and marker placement against a measurer whose behavior is mathematically defined, not stateful mock call history.

98% left~/WebstormProjects/echo
claude-code — ~/WebstormProjects/echo
Round 3: Conceded: the explicit stateless interface is correct.

Conceded: the explicit stateless interface is correct. Hiding font state in the adapter just moves the problem. measureWidth(text, style, fontSize) and measureHeight(style, fontSize) are the right boundaries — the adapter encapsulates getFontForStyle and the 'Ag' sentinel, callers don't know these exist.

One concrete pushback on your interface: the width parameter on measureHeight is spurious. The production call is doc.heightOfString('Ag', { lineBreak: false, width: options.contentWidth }) — but with lineBreak: false, height doesn't vary with width; it's purely a function of font and size. Including width in the interface leaks the PDFKit calling convention into what should be an abstraction. Drop it:

``ts interface TextMeasurer { measureWidth(text: string, style: HtmlInlineStyle, fontSize: number): number; measureHeight(style: HtmlInlineStyle, fontSize: number): number; } ``

The loose end worth flagging: getFontForStyle is currently shared between measurement and rendering. With the measurer encapsulating font selection, renderLine still needs to call setDocFont(doc, style, options) — which internally calls getFontForStyle. The logic lives in two places unless you also give rendering an equivalent abstraction. That's not a blocker for the refactor, but it means the extraction is incomplete until you either (a) expose fontNameForStyle(style): string on the measurer so renderLine can call it, or (b) keep getFontForStyle as a module-level pure function shared by both. Option (b) is simpler and honest — it's already pure, it just needs to not be buried inside setDocFont.

The thing this whole extraction unlocks that we haven't discussed yet: once layoutBlocks is genuinely pure (measurer + blocks + options → laid-out blocks, no side effects), you can write golden-value regression tests that assert exact LaidOutBlock structures against a mathematically defined measurer. Not "more than 1 line" — exactly 3 lines, with these exact run widths, these exact xOffsets. These tests are fast, require no mock setup, and fail loudly when layout logic changes. The current "no run exceeds contentWidth" property test is good but it's a constraint test — it doesn't tell you *where* the line breaks are. Golden values do.

›› bypass permissions on◐ medium · /effort
~/WebstormProjects/echo
Round 4: I agree on dropping `width` from `measureHeight`.

I agree on dropping width from measureHeight. In the current renderer, line height is treated as a function of font choice and font size only, and the abstraction should match that.

The bigger gap is that golden LaidOutBlock tests still leave the render phase coupled to PDFKit call traces. That is where the brittleness remains. The next refactor should be a declarative render plan, not more mock assertions.

Something like:

``ts type RenderOp = | { kind: 'text'; text: string; x: number; y: number; font: string; fontSize: number; color: string } | { kind: 'fillRect'; x: number; y: number; width: number; height: number; color: string } | { kind: 'strokeLine'; x1: number; y1: number; x2: number; y2: number; color: string; lineWidth: number } | { kind: 'pageBreak' }; ``

Then split rendering into:

  1. planRenderOps(laidOutBlocks, startY, footerY, options) -> RenderOp[]
  2. applyRenderOps(doc, ops)

That gives you stable unit tests for the things that actually matter:

  • strikethrough is a strokeLine at y + run.height / 2
  • underline is a different strokeLine at y + run.height - 1.5
  • heading text uses the scaled fontSize
  • list markers only appear on the first laid-out line, even across pagination
  • page breaks happen at the right boundary

Those assertions are much less brittle than “save, then strokeColor, then lineWidth...”. Call order inside applyRenderOps becomes adapter detail. You keep one thin adapter test to prove a strokeLine op becomes the expected PDFKit method sequence, and stop forcing every render test through that same narrow implementation detail.

Without this seam, the render tests will keep overfitting to PDFKit choreography instead of validating renderer semantics.

96% left~/WebstormProjects/echo