From cd50699ac0469e763389893e6c01576a710ff946 Mon Sep 17 00:00:00 2001 From: Tim Oliver Date: Wed, 27 May 2026 11:54:48 +0900 Subject: [PATCH 01/18] Add design spec for minimumWidth, label-wrap fix, and tests --- ...2026-05-27-rounded-button-sizing-design.md | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-27-rounded-button-sizing-design.md diff --git a/docs/superpowers/specs/2026-05-27-rounded-button-sizing-design.md b/docs/superpowers/specs/2026-05-27-rounded-button-sizing-design.md new file mode 100644 index 0000000..9d96023 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-rounded-button-sizing-design.md @@ -0,0 +1,128 @@ +# TORoundedButton — `minimumWidth`, label-wrap fix, and test coverage + +**Date:** 2026-05-27 +**Status:** Approved +**Branch:** `alert-fixes` + +## Context + +`TORoundedButton` is consumed by a separate alert library (`UIAlertViewController`), +which surfaced two regressions plus a desire for far stronger test coverage. + +Note: `spm/TORoundedButton.m` and `spm/include/TORoundedButton.h` are **symlinks** to +`TORoundedButton/TORoundedButton.{m,h}`. There is one real source file per pair — edits +to the canonical files cover the SPM package automatically. + +Tests run via `xcodebuild test` on the `TORoundedButtonTests` scheme against an iOS 26.2 +simulator (iPhone 16e). `TORoundedButtonExampleTests/TORoundedButtonExampleTests.m` is +already a member of that target. + +## Goals + +1. Re-introduce a `minimumWidth` property that reports the smallest width the button can + be while fitting its content — now padding-aware. +2. Fix a visual glitch where the title label wraps to two lines even when there is enough + horizontal room. +3. Add comprehensive unit-test coverage. + +## Non-goals + +- No new public sizing API beyond `minimumWidth` (e.g. no `minimumHorizontalPadding`, + no corner-radius-aware padding). `YAGNI` — `contentInset` is the agreed padding source. +- `setTextFont:` not always calling `setNeedsLayout` is a known separate latent issue and + is explicitly out of scope. + +## Design + +### 1. `minimumWidth` (readonly, padding-aware) + +Re-add to the public header: + +```objc +/// The smallest width this button can be while still fitting its content on a single +/// line, including the horizontal `contentInset` padding. +@property (nonatomic, readonly) CGFloat minimumWidth; +``` + +Implementation reuses the existing, tested `sizeThatFits:` path rather than a parallel +calculation: + +```objc +- (CGFloat)minimumWidth { + return [self sizeThatFits:CGSizeMake(, CGFLOAT_MAX)].width; +} +``` + +Behaviour: + +- Returns single-line natural content width **plus** `contentInset.left + contentInset.right`. +- Tracks `text`, `attributedText`, `textFont`, `textPointSize`, and `contentInset` + automatically. +- Works for custom `contentView`s via the same `sizeThatFits:` dispatch. + +Implementation detail: pass a **large finite** width (not literal `CGFLOAT_MAX`) into the +content/label measurement to avoid Core Text overflow edge cases when `sizeThatFits:` +subtracts the horizontal padding from the incoming width. + +### 2. Title-label two-line wrap fix + +Root cause: `layoutSubviews` calls `[_titleLabel sizeToFit]` against the label's *current* +frame. With `numberOfLines = 0`, a stale/too-narrow starting width makes +`sizeThatFits:` wrap the text to two lines even when the button is wide enough. + +Fix (surgical seed) — in `layoutSubviews`, before the existing `sizeToFit`: + +```objc +if (!_titleLabel) { return; } +CGRect labelFrame = _titleLabel.frame; +labelFrame.size = contentBounds.size; // seed with the available content room +_titleLabel.frame = labelFrame; +[_titleLabel sizeToFit]; // now wraps only when genuinely needed +``` + +`contentBounds` is the inset-adjusted rect already computed at the top of `layoutSubviews`. +Seeding with the real available width means the label wraps only when the text truly does +not fit, preserving intended multi-line behaviour for genuinely narrow buttons. + +Rejected alternative (B): rewrite `layoutSubviews` to drive the label frame from +`sizeThatFits:` like any other content. More principled but a larger change with higher +regression risk for a published library. + +### 3. Test coverage + +Expand `TORoundedButtonExampleTests/TORoundedButtonExampleTests.m` in place (already a +target member — no `.pbxproj` changes). Organise with `#pragma mark` groups: + +- **Init & defaults:** all four initializers (`init`, `initWithFrame:`, `initWithText:`, + `initWithContentView:`), default property values, iOS 26 `cornerConfiguration` vs + `cornerRadius`. +- **Text & appearance:** `text` / `attributedText` round-trips, `textColor`, `textFont`, + `textPointSize`, `contentInset`. +- **Sizing:** `minimumWidth` relationships and `sizeThatFits:` / `sizeToFit`. +- **Glitch regression:** put the title label in a too-narrow state, force layout at an + adequate width, assert the label stays a single line. Must fail before the fix and pass + after. +- **Behavior:** `tappedHandler` + delegate fire on tap, `enabled` alpha, + `overrideContentView` swap/restore, `backgroundStyle` (solid / blur / glass). + +Testing conventions: + +- **Relational assertions, not hard-coded pixels.** Compare against a freshly measured + reference `UILabel` (same font/text) so tests survive Dynamic Type and font-metric + differences across simulators. E.g. `minimumWidth ≈ referenceLabelWidth + + contentInset.left + contentInset.right`; `minimumWidth` grows with longer text; grows + with larger `contentInset`. +- **Private title label access via KVC:** `UILabel *label = [button valueForKey:@"titleLabel"];` + (KVC resolves to the `_titleLabel` ivar). Pragmatic for same-project XCTest; used only + where introspection is required (the glitch regression test). +- Drive layout in tests with `setNeedsLayout` + `layoutIfNeeded`. + +## Acceptance criteria + +- `minimumWidth` returns content single-line width + horizontal `contentInset`, and the + relational sizing tests pass. +- The glitch regression test fails on the pre-fix code and passes on the fixed code. +- All new tests pass via the CI command: + `xcodebuild -project TORoundedButtonExample.xcodeproj -scheme TORoundedButtonTests + -destination 'platform=iOS Simulator,name=iPhone 16e,OS=26.2' clean test`. +- CHANGELOG updated (re-added `minimumWidth`, label-wrap fix). From ef291bc9029224f64365180d024ee5551cf4a6dd Mon Sep 17 00:00:00 2001 From: Tim Oliver Date: Wed, 27 May 2026 12:03:27 +0900 Subject: [PATCH 02/18] Add implementation plan for sizing fixes and tests --- .../plans/2026-05-27-rounded-button-sizing.md | 540 ++++++++++++++++++ 1 file changed, 540 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-27-rounded-button-sizing.md diff --git a/docs/superpowers/plans/2026-05-27-rounded-button-sizing.md b/docs/superpowers/plans/2026-05-27-rounded-button-sizing.md new file mode 100644 index 0000000..c05130d --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-rounded-button-sizing.md @@ -0,0 +1,540 @@ +# TORoundedButton Sizing & Test Coverage Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Re-add a padding-aware `minimumWidth` property, fix the title-label two-line wrap glitch, and add comprehensive unit tests. + +**Architecture:** `minimumWidth` is a readonly property that delegates to the existing `sizeThatFits:` path at an unconstrained width, so it returns single-line content width + horizontal `contentInset`. The wrap glitch is fixed by seeding the title label's frame with the available content width before `sizeToFit` in `layoutSubviews`, so it wraps only when genuinely too narrow. Tests are added to the existing XCTest file and use relational assertions against a freshly-measured reference label. + +**Tech Stack:** Objective-C, UIKit, XCTest, `xcodebuild`. + +--- + +## Orientation (read once before starting) + +- **One real source file.** `spm/TORoundedButton.m` and `spm/include/TORoundedButton.h` are symlinks to `TORoundedButton/TORoundedButton.m` and `TORoundedButton/TORoundedButton.h`. Edit only the `TORoundedButton/` copies. +- **Tests** live in `TORoundedButtonExampleTests/TORoundedButtonExampleTests.m`, which is already a member of the `TORoundedButtonTests` scheme/target. Adding methods to this file needs **no** `.pbxproj` changes. +- **Private members** (`_titleLabel`, `_containerView`, `_backgroundView`) are reached from tests via KVC, e.g. `[button valueForKey:@"titleLabel"]` — KVC resolves key `titleLabel` to the `_titleLabel` ivar. +- **Run a single test:** + ```bash + xcodebuild test \ + -project TORoundedButtonExample.xcodeproj \ + -scheme TORoundedButtonTests \ + -destination 'platform=iOS Simulator,name=iPhone 16e,OS=26.2' \ + -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/ + ``` + Output ends in `** TEST SUCCEEDED **` or `** TEST FAILED **`. +- **Run the whole suite (CI command):** + ```bash + xcodebuild -project TORoundedButtonExample.xcodeproj -scheme TORoundedButtonTests \ + -destination 'platform=iOS Simulator,name=iPhone 16e,OS=26.2' clean test + ``` +- If the `iPhone 16e` / `OS=26.2` simulator is not installed locally, run `xcrun simctl list devices available` and substitute an installed device/OS in the `-destination`. +- **Observation, not in scope:** the header comment for `init`/`initWithText:` says "50 tall" but the code uses `52`. Tests below assert the real value (`52`). Leave the comment alone. + +--- + +## File Structure + +- **Modify** `TORoundedButton/TORoundedButton.h` — declare the `minimumWidth` property. +- **Modify** `TORoundedButton/TORoundedButton.m` — implement `minimumWidth`; seed the label frame in `layoutSubviews`. +- **Modify** `TORoundedButtonExampleTests/TORoundedButtonExampleTests.m` — add a test delegate class, a reference-label helper, and all new tests. +- **Modify** `CHANGELOG.md` — record the re-added property and the fix. + +--- + +## Task 1: Fix the title-label two-line wrap glitch (TDD) + +**Files:** +- Test: `TORoundedButtonExampleTests/TORoundedButtonExampleTests.m` +- Modify: `TORoundedButton/TORoundedButton.m` (`layoutSubviews`, currently lines ~293-300) + +- [ ] **Step 1: Add the reference-label helper and the failing regression test** + +Add this helper and test inside `@implementation TORoundedButtonExampleTests` (e.g. just below the existing `testButtonInteraction`): + +```objc +#pragma mark - Helpers + +/// A standalone label configured like the button's private title label, +/// for measuring expected sizes without coupling tests to exact pixel values. +- (UILabel *)referenceLabelForButton:(TORoundedButton *)button { + UILabel *titleLabel = [button valueForKey:@"titleLabel"]; + UILabel *reference = [[UILabel alloc] init]; + reference.font = titleLabel.font; + reference.text = titleLabel.text; + return reference; +} + +#pragma mark - Layout Regression + +- (void)testTitleLabelDoesNotWrapWhenButtonIsWideEnough { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Reasonably Long Title"]; + button.frame = CGRectMake(0, 0, 400, 52); // easily wide enough for one line + + UILabel *titleLabel = [button valueForKey:@"titleLabel"]; + + // Natural single-line height (a fresh label defaults to numberOfLines == 1). + UILabel *reference = [self referenceLabelForButton:button]; + [reference sizeToFit]; + CGFloat singleLineHeight = reference.frame.size.height; + + // Force the buggy precondition: a too-narrow label frame before layout. + CGRect narrow = titleLabel.frame; + narrow.size.width = 10.0; // narrower than the longest word + titleLabel.frame = narrow; + + [button setNeedsLayout]; + [button layoutIfNeeded]; + + XCTAssertEqualWithAccuracy(titleLabel.frame.size.height, singleLineHeight, 2.0, + @"Title label wrapped to multiple lines despite adequate button width"); +} +``` + +- [ ] **Step 2: Run the test and verify it FAILS** + +Run: +```bash +xcodebuild test -project TORoundedButtonExample.xcodeproj -scheme TORoundedButtonTests \ + -destination 'platform=iOS Simulator,name=iPhone 16e,OS=26.2' \ + -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testTitleLabelDoesNotWrapWhenButtonIsWideEnough +``` +Expected: `** TEST FAILED **` — the label's height is ~2 lines because `layoutSubviews` calls `sizeToFit` against the width-10 frame. + +- [ ] **Step 3: Apply the fix in `layoutSubviews`** + +In `TORoundedButton/TORoundedButton.m`, replace: + +```objc + // Lay out the title label + if (!_titleLabel) { return; } + [_titleLabel sizeToFit]; +``` + +with: + +```objc + // Lay out the title label + if (!_titleLabel) { return; } + // Seed the label with the available content width first; otherwise a stale, + // too-narrow frame makes sizeToFit wrap the text onto multiple lines even + // when there is plenty of horizontal room. + CGRect labelFrame = _titleLabel.frame; + labelFrame.size = contentBounds.size; + _titleLabel.frame = labelFrame; + [_titleLabel sizeToFit]; +``` + +- [ ] **Step 4: Run the test and verify it PASSES** + +Run the same command as Step 2. +Expected: `** TEST SUCCEEDED **`. + +- [ ] **Step 5: Commit** + +```bash +git add TORoundedButton/TORoundedButton.m TORoundedButtonExampleTests/TORoundedButtonExampleTests.m +git commit -m "Fix title label wrapping to two lines on small frames + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Task 2: Re-add the `minimumWidth` property (TDD) + +**Files:** +- Test: `TORoundedButtonExampleTests/TORoundedButtonExampleTests.m` +- Modify: `TORoundedButton/TORoundedButton.h` (after the `tappedHandler` property, ~line 135) +- Modify: `TORoundedButton/TORoundedButton.m` (after `sizeThatFits:`, ~line 331) + +- [ ] **Step 1: Write the failing tests** + +Add to `TORoundedButtonExampleTests.m` under a new pragma mark: + +```objc +#pragma mark - Sizing + +- (void)testMinimumWidthIncludesContentInset { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Hello"]; + UILabel *reference = [self referenceLabelForButton:button]; + CGFloat textWidth = [reference sizeThatFits:CGSizeMake(1.0e6, 1.0e6)].width; + CGFloat expected = textWidth + button.contentInset.left + button.contentInset.right; + XCTAssertEqualWithAccuracy(button.minimumWidth, expected, 1.0); +} + +- (void)testMinimumWidthGrowsWithLongerText { + TORoundedButton *shortButton = [[TORoundedButton alloc] initWithText:@"Hi"]; + TORoundedButton *longButton = [[TORoundedButton alloc] initWithText:@"A much longer button title"]; + XCTAssertGreaterThan(longButton.minimumWidth, shortButton.minimumWidth); +} + +- (void)testMinimumWidthGrowsWithContentInset { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Hello"]; + CGFloat before = button.minimumWidth; + button.contentInset = UIEdgeInsetsMake(15, 40, 15, 40); + CGFloat after = button.minimumWidth; + // Horizontal inset went from 15+15 (30) to 40+40 (80): a 50pt increase. + XCTAssertEqualWithAccuracy(after - before, 50.0, 1.0); +} +``` + +- [ ] **Step 2: Run the tests and verify they FAIL (compile error)** + +Run: +```bash +xcodebuild test -project TORoundedButtonExample.xcodeproj -scheme TORoundedButtonTests \ + -destination 'platform=iOS Simulator,name=iPhone 16e,OS=26.2' \ + -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testMinimumWidthIncludesContentInset +``` +Expected: build failure — `property 'minimumWidth' not found on object of type 'TORoundedButton *'`. + +- [ ] **Step 3: Declare the property in the header** + +In `TORoundedButton/TORoundedButton.h`, replace: + +```objc +/// A callback handler triggered each time the button is tapped. +@property (nonatomic, copy) void (^tappedHandler)(void); +``` + +with: + +```objc +/// A callback handler triggered each time the button is tapped. +@property (nonatomic, copy) void (^tappedHandler)(void); + +/// The smallest width this button can be while still fitting its content on a single +/// line, including the horizontal `contentInset` padding. Useful for external layout +/// systems (such as alert controllers) sizing themselves around the button. +@property (nonatomic, readonly) CGFloat minimumWidth; +``` + +- [ ] **Step 4: Implement the property** + +In `TORoundedButton/TORoundedButton.m`, find the end of `sizeThatFits:`: + +```objc + newSize.width += horizontalPadding; + newSize.height += verticalPadding; + return newSize; +} +``` + +and insert immediately after it: + +```objc + +- (CGFloat)minimumWidth { + // Measure at a large but finite size so the content lays out on a single line + // without tripping Core Text overflow handling (which a literal CGFLOAT_MAX can, + // once sizeThatFits: subtracts the horizontal padding from it). + return [self sizeThatFits:(CGSize){1.0e6, 1.0e6}].width; +} +``` + +- [ ] **Step 5: Run the tests and verify they PASS** + +Run: +```bash +xcodebuild test -project TORoundedButtonExample.xcodeproj -scheme TORoundedButtonTests \ + -destination 'platform=iOS Simulator,name=iPhone 16e,OS=26.2' \ + -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testMinimumWidthIncludesContentInset \ + -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testMinimumWidthGrowsWithLongerText \ + -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testMinimumWidthGrowsWithContentInset +``` +Expected: `** TEST SUCCEEDED **`. + +- [ ] **Step 6: Commit** + +```bash +git add TORoundedButton/TORoundedButton.h TORoundedButton/TORoundedButton.m TORoundedButtonExampleTests/TORoundedButtonExampleTests.m +git commit -m "Re-add padding-aware minimumWidth property + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Task 3: Characterization tests — init, defaults, text & appearance + +These document existing behavior, so they should pass against current code on first run. If any fails, stop and investigate — it's a real bug. + +**Files:** +- Test: `TORoundedButtonExampleTests/TORoundedButtonExampleTests.m` + +- [ ] **Step 1: Add the tests** + +Add under a new pragma mark: + +```objc +#pragma mark - Init & Defaults + +- (void)testInitUsesDefaultFrameSize { + TORoundedButton *button = [[TORoundedButton alloc] init]; + XCTAssertEqualWithAccuracy(button.bounds.size.width, 288.0, 0.001); + XCTAssertEqualWithAccuracy(button.bounds.size.height, 52.0, 0.001); +} + +- (void)testInitWithTextSetsText { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Confirm"]; + XCTAssertEqualObjects(button.text, @"Confirm"); +} + +- (void)testInitWithContentViewSetsContentView { + UIView *custom = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 100, 40)]; + TORoundedButton *button = [[TORoundedButton alloc] initWithContentView:custom]; + XCTAssertEqualObjects(button.contentView, custom); +} + +#pragma mark - Text & Appearance + +- (void)testTextRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] init]; + button.text = @"Hello"; + XCTAssertEqualObjects(button.text, @"Hello"); +} + +- (void)testAttributedTextRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] init]; + NSAttributedString *attributed = [[NSAttributedString alloc] initWithString:@"Styled"]; + button.attributedText = attributed; + XCTAssertEqualObjects(button.attributedText.string, @"Styled"); +} + +- (void)testTextColorRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + button.textColor = [UIColor redColor]; + XCTAssertEqualObjects(button.textColor, [UIColor redColor]); +} + +- (void)testTextFontRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + UIFont *font = [UIFont systemFontOfSize:22.0]; + button.textFont = font; + XCTAssertEqualObjects(button.textFont, font); +} + +- (void)testTextPointSizeSetsFontSize { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + button.textPointSize = 28.0; + XCTAssertEqualWithAccuracy(button.textFont.pointSize, 28.0, 0.001); +} + +- (void)testContentInsetRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + button.contentInset = UIEdgeInsetsMake(10, 20, 10, 20); + XCTAssertTrue(UIEdgeInsetsEqualToEdgeInsets(button.contentInset, UIEdgeInsetsMake(10, 20, 10, 20))); +} +``` + +- [ ] **Step 2: Run the new tests and verify they PASS** + +Run: +```bash +xcodebuild test -project TORoundedButtonExample.xcodeproj -scheme TORoundedButtonTests \ + -destination 'platform=iOS Simulator,name=iPhone 16e,OS=26.2' \ + -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testInitUsesDefaultFrameSize \ + -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testInitWithTextSetsText \ + -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testInitWithContentViewSetsContentView \ + -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testTextRoundTrip \ + -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testAttributedTextRoundTrip \ + -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testTextColorRoundTrip \ + -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testTextFontRoundTrip \ + -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testTextPointSizeSetsFontSize \ + -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testContentInsetRoundTrip +``` +Expected: `** TEST SUCCEEDED **`. + +- [ ] **Step 3: Commit** + +```bash +git add TORoundedButtonExampleTests/TORoundedButtonExampleTests.m +git commit -m "Add init, default, and appearance tests + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Task 4: Characterization tests — interaction, state & background style + +**Files:** +- Test: `TORoundedButtonExampleTests/TORoundedButtonExampleTests.m` + +- [ ] **Step 1: Add a test delegate class** + +At the top of the file, after the `#import` lines and before `@interface TORoundedButtonExampleTests`: + +```objc +@interface TORoundedButtonTestDelegate : NSObject +@property (nonatomic, assign) NSInteger tapCount; +@property (nonatomic, weak) TORoundedButton *lastButton; +@end + +@implementation TORoundedButtonTestDelegate +- (void)roundedButtonDidTap:(TORoundedButton *)button { + self.tapCount += 1; + self.lastButton = button; +} +@end +``` + +- [ ] **Step 2: Add the behavior tests** + +Add under a new pragma mark in `@implementation TORoundedButtonExampleTests`: + +```objc +#pragma mark - Behavior + +- (void)testTappedHandlerFiresOnTouchUpInside { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Tap"]; + __block NSInteger count = 0; + button.tappedHandler = ^{ count += 1; }; + [button sendActionsForControlEvents:UIControlEventTouchUpInside]; + XCTAssertEqual(count, 1); +} + +- (void)testDelegateReceivesTap { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Tap"]; + TORoundedButtonTestDelegate *delegate = [TORoundedButtonTestDelegate new]; + button.delegate = delegate; + [button sendActionsForControlEvents:UIControlEventTouchUpInside]; + XCTAssertEqual(delegate.tapCount, 1); + XCTAssertEqualObjects(delegate.lastButton, button); +} + +- (void)testDisabledReducesContainerAlpha { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + UIView *container = [button valueForKey:@"containerView"]; + XCTAssertEqualWithAccuracy(container.alpha, 1.0, 0.001); + button.enabled = NO; + XCTAssertEqualWithAccuracy(container.alpha, 0.4, 0.001); + button.enabled = YES; + XCTAssertEqualWithAccuracy(container.alpha, 1.0, 0.001); +} + +- (void)testOverrideContentViewHidesAndRestoresContentView { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + UIView *override = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 50, 50)]; + + button.overrideContentView = override; + XCTAssertEqualObjects(button.overrideContentView, override); + XCTAssertTrue(button.contentView.hidden); + XCTAssertEqualObjects(override.superview, [button valueForKey:@"containerView"]); + + button.overrideContentView = nil; + XCTAssertNil(button.overrideContentView); + XCTAssertFalse(button.contentView.hidden); +} + +- (void)testBackgroundStyleBlurUsesVisualEffectView { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + button.backgroundStyle = TORoundedButtonBackgroundStyleBlur; + XCTAssertEqual(button.backgroundStyle, TORoundedButtonBackgroundStyleBlur); + UIView *backgroundView = [button valueForKey:@"backgroundView"]; + XCTAssertTrue([backgroundView isKindOfClass:[UIVisualEffectView class]]); +} + +- (void)testSizeToFitResizesButtonToMinimumWidth { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + button.frame = CGRectMake(0, 0, 1000, 200); + [button sizeToFit]; + XCTAssertEqualWithAccuracy(button.bounds.size.width, button.minimumWidth, 1.0); + XCTAssertGreaterThan(button.bounds.size.height, 0.0); +} +``` + +- [ ] **Step 3: Run the new tests and verify they PASS** + +Run: +```bash +xcodebuild test -project TORoundedButtonExample.xcodeproj -scheme TORoundedButtonTests \ + -destination 'platform=iOS Simulator,name=iPhone 16e,OS=26.2' \ + -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testTappedHandlerFiresOnTouchUpInside \ + -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testDelegateReceivesTap \ + -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testDisabledReducesContainerAlpha \ + -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testOverrideContentViewHidesAndRestoresContentView \ + -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testBackgroundStyleBlurUsesVisualEffectView \ + -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testSizeToFitResizesButtonToMinimumWidth +``` +Expected: `** TEST SUCCEEDED **`. (If `testBackgroundStyleBlurUsesVisualEffectView` fails, inspect `_makeBackgroundViewWithStyle:` — the assertion documents the real return type; adjust the assertion to match observed behavior rather than forcing it.) + +- [ ] **Step 4: Commit** + +```bash +git add TORoundedButtonExampleTests/TORoundedButtonExampleTests.m +git commit -m "Add interaction, state, and background-style tests + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Task 5: Update CHANGELOG and verify the full suite + +**Files:** +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Add CHANGELOG entries** + +In `CHANGELOG.md`, replace: + +``` +x.y.z Release Notes (yyyy-MM-dd) +============================================================= + +2.0.0 Release Notes (2026-01-22) +``` + +with: + +``` +x.y.z Release Notes (yyyy-MM-dd) +============================================================= + +### Added + +* Re-added the `minimumWidth` property, now including the horizontal `contentInset` padding, to help external layout systems size the button around its content. + +### Fixed + +* An issue where the title label could wrap onto two lines after `sizeToFit` was called against an already-narrow frame. + +2.0.0 Release Notes (2026-01-22) +``` + +- [ ] **Step 2: Run the full suite and verify it PASSES** + +Run: +```bash +xcodebuild -project TORoundedButtonExample.xcodeproj -scheme TORoundedButtonTests \ + -destination 'platform=iOS Simulator,name=iPhone 16e,OS=26.2' clean test +``` +Expected: `** TEST SUCCEEDED **` with all tests (the original 2 plus the new ones) passing. + +- [ ] **Step 3: Commit** + +```bash +git add CHANGELOG.md +git commit -m "Update CHANGELOG for minimumWidth and label-wrap fix + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Self-Review (completed by plan author) + +**Spec coverage:** +- `minimumWidth` (readonly, contentInset-aware) → Task 2. +- Label two-line wrap fix (surgical seed in `layoutSubviews`) → Task 1. +- Comprehensive tests (init/defaults, text/appearance, sizing, glitch regression, behavior) → Tasks 1–4. +- Relational assertions + KVC private access → helper in Task 1, used throughout. +- CHANGELOG update (acceptance criterion) → Task 5. +- Out-of-scope `setTextFont:` relayout quirk → correctly omitted. + +**Placeholder scan:** No TBD/TODO/"add error handling" — every code step contains complete code; the spec's `` stand-in is resolved to `1.0e6`. + +**Type consistency:** `referenceLabelForButton:` defined once (Task 1) and reused (Tasks 2, plus inline single-line reference). `minimumWidth` declared in Task 2 Step 3 and used in Task 4's `testSizeToFitResizesButtonToMinimumWidth`. KVC keys (`titleLabel`, `containerView`, `backgroundView`) match the ivars in `TORoundedButton.m`. Test target/class path `TORoundedButtonExampleTests/TORoundedButtonExampleTests` consistent across all commands. From 795c4d1b7101f17d0616bfe9c2e1232a5341739a Mon Sep 17 00:00:00 2001 From: Tim Oliver Date: Wed, 27 May 2026 12:41:02 +0900 Subject: [PATCH 03/18] Fix title label wrapping to two lines on small frames --- TORoundedButton/TORoundedButton.m | 6 +++ .../TORoundedButtonExampleTests.m | 37 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/TORoundedButton/TORoundedButton.m b/TORoundedButton/TORoundedButton.m index 31fd842..19a577e 100644 --- a/TORoundedButton/TORoundedButton.m +++ b/TORoundedButton/TORoundedButton.m @@ -292,6 +292,12 @@ - (void)layoutSubviews { // Lay out the title label if (!_titleLabel) { return; } + // Seed the label with the available content width first; otherwise a stale, + // too-narrow frame makes sizeToFit wrap the text onto multiple lines even + // when there is plenty of horizontal room. + CGRect labelFrame = _titleLabel.frame; + labelFrame.size = contentBounds.size; + _titleLabel.frame = labelFrame; [_titleLabel sizeToFit]; _titleLabel.center = (CGPoint){ .x = CGRectGetMidX(_contentView.bounds), diff --git a/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m b/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m index 57cf6a4..72ec73d 100644 --- a/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m +++ b/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m @@ -49,4 +49,41 @@ - (void)testButtonInteraction [self waitForExpectations:@[expectation] timeout:0.5f]; } +#pragma mark - Helpers + +/// A standalone label configured like the button's private title label, +/// for measuring expected sizes without coupling tests to exact pixel values. +- (UILabel *)referenceLabelForButton:(TORoundedButton *)button { + UILabel *titleLabel = [button valueForKey:@"titleLabel"]; + UILabel *reference = [[UILabel alloc] init]; + reference.font = titleLabel.font; + reference.text = titleLabel.text; + return reference; +} + +#pragma mark - Layout Regression + +- (void)testTitleLabelDoesNotWrapWhenButtonIsWideEnough { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Reasonably Long Title"]; + button.frame = CGRectMake(0, 0, 400, 52); // easily wide enough for one line + + UILabel *titleLabel = [button valueForKey:@"titleLabel"]; + + // Natural single-line height (a fresh label defaults to numberOfLines == 1). + UILabel *reference = [self referenceLabelForButton:button]; + [reference sizeToFit]; + CGFloat singleLineHeight = reference.frame.size.height; + + // Force the buggy precondition: a too-narrow label frame before layout. + CGRect narrow = titleLabel.frame; + narrow.size.width = 10.0; // narrower than the longest word + titleLabel.frame = narrow; + + [button setNeedsLayout]; + [button layoutIfNeeded]; + + XCTAssertEqualWithAccuracy(titleLabel.frame.size.height, singleLineHeight, 2.0, + @"Title label wrapped to multiple lines despite adequate button width"); +} + @end From ca3399da4511375163091c339daf5a73d459a960 Mon Sep 17 00:00:00 2001 From: Tim Oliver Date: Wed, 27 May 2026 12:44:05 +0900 Subject: [PATCH 04/18] Clarify test helper contract and tolerance comment --- TORoundedButtonExampleTests/TORoundedButtonExampleTests.m | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m b/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m index 72ec73d..db12957 100644 --- a/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m +++ b/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m @@ -51,8 +51,9 @@ - (void)testButtonInteraction #pragma mark - Helpers -/// A standalone label configured like the button's private title label, -/// for measuring expected sizes without coupling tests to exact pixel values. +/// A standalone label matching the button's private title label font and text, for +/// measuring its natural *single-line* size (this label keeps the default +/// numberOfLines == 1) without coupling tests to exact pixel values. - (UILabel *)referenceLabelForButton:(TORoundedButton *)button { UILabel *titleLabel = [button valueForKey:@"titleLabel"]; UILabel *reference = [[UILabel alloc] init]; @@ -82,6 +83,7 @@ - (void)testTitleLabelDoesNotWrapWhenButtonIsWideEnough { [button setNeedsLayout]; [button layoutIfNeeded]; + // ±2pt slack absorbs CGRectIntegral rounding and font-metric variation. XCTAssertEqualWithAccuracy(titleLabel.frame.size.height, singleLineHeight, 2.0, @"Title label wrapped to multiple lines despite adequate button width"); } From bbe4286216628c92c7ec5a15c0f9e035df181f76 Mon Sep 17 00:00:00 2001 From: Tim Oliver Date: Wed, 27 May 2026 12:45:34 +0900 Subject: [PATCH 05/18] Re-add padding-aware minimumWidth property --- TORoundedButton/TORoundedButton.h | 5 ++++ TORoundedButton/TORoundedButton.m | 7 ++++++ .../TORoundedButtonExampleTests.m | 25 +++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/TORoundedButton/TORoundedButton.h b/TORoundedButton/TORoundedButton.h index 90e75bf..f75d9cc 100644 --- a/TORoundedButton/TORoundedButton.h +++ b/TORoundedButton/TORoundedButton.h @@ -134,6 +134,11 @@ IB_DESIGNABLE @interface TORoundedButton : UIControl /// A callback handler triggered each time the button is tapped. @property (nonatomic, copy) void (^tappedHandler)(void); +/// The smallest width this button can be while still fitting its content on a single +/// line, including the horizontal `contentInset` padding. Useful for external layout +/// systems (such as alert controllers) sizing themselves around the button. +@property (nonatomic, readonly) CGFloat minimumWidth; + /// Create a new instance of a button that can be further configured with either text or custom subviews. /// The size will be 288 points wide, and 50 tall by default. - (instancetype)init; diff --git a/TORoundedButton/TORoundedButton.m b/TORoundedButton/TORoundedButton.m index 19a577e..9b101f4 100644 --- a/TORoundedButton/TORoundedButton.m +++ b/TORoundedButton/TORoundedButton.m @@ -336,6 +336,13 @@ - (CGSize)sizeThatFits:(CGSize)size { return newSize; } +- (CGFloat)minimumWidth { + // Measure at a large but finite size so the content lays out on a single line + // without tripping Core Text overflow handling (which a literal CGFLOAT_MAX can, + // once sizeThatFits: subtracts the horizontal padding from it). + return [self sizeThatFits:(CGSize){1.0e6, 1.0e6}].width; +} + #pragma mark - Interaction - - (void)_didTouchDownInside { diff --git a/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m b/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m index db12957..aedafc8 100644 --- a/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m +++ b/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m @@ -49,6 +49,31 @@ - (void)testButtonInteraction [self waitForExpectations:@[expectation] timeout:0.5f]; } +#pragma mark - Sizing + +- (void)testMinimumWidthIncludesContentInset { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Hello"]; + UILabel *reference = [self referenceLabelForButton:button]; + CGFloat textWidth = [reference sizeThatFits:CGSizeMake(1.0e6, 1.0e6)].width; + CGFloat expected = textWidth + button.contentInset.left + button.contentInset.right; + XCTAssertEqualWithAccuracy(button.minimumWidth, expected, 1.0); +} + +- (void)testMinimumWidthGrowsWithLongerText { + TORoundedButton *shortButton = [[TORoundedButton alloc] initWithText:@"Hi"]; + TORoundedButton *longButton = [[TORoundedButton alloc] initWithText:@"A much longer button title"]; + XCTAssertGreaterThan(longButton.minimumWidth, shortButton.minimumWidth); +} + +- (void)testMinimumWidthGrowsWithContentInset { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Hello"]; + CGFloat before = button.minimumWidth; + button.contentInset = UIEdgeInsetsMake(15, 40, 15, 40); + CGFloat after = button.minimumWidth; + // Horizontal inset went from 15+15 (30) to 40+40 (80): a 50pt increase. + XCTAssertEqualWithAccuracy(after - before, 50.0, 1.0); +} + #pragma mark - Helpers /// A standalone label matching the button's private title label font and text, for From c4f7666979780eabb4c86d706f107361fec59e95 Mon Sep 17 00:00:00 2001 From: Tim Oliver Date: Wed, 27 May 2026 12:48:51 +0900 Subject: [PATCH 06/18] Make inset-delta test comment reference-proof --- TORoundedButtonExampleTests/TORoundedButtonExampleTests.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m b/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m index aedafc8..adedc8f 100644 --- a/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m +++ b/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m @@ -70,7 +70,7 @@ - (void)testMinimumWidthGrowsWithContentInset { CGFloat before = button.minimumWidth; button.contentInset = UIEdgeInsetsMake(15, 40, 15, 40); CGFloat after = button.minimumWidth; - // Horizontal inset went from 15+15 (30) to 40+40 (80): a 50pt increase. + // Left+right inset delta drives the change: (40-15) + (40-15) = 50pt. XCTAssertEqualWithAccuracy(after - before, 50.0, 1.0); } From 7e82d1e2c6da570744d32c25326e5797e5b7a847 Mon Sep 17 00:00:00 2001 From: Tim Oliver Date: Wed, 27 May 2026 12:49:52 +0900 Subject: [PATCH 07/18] Add init, default, and appearance tests --- .../TORoundedButtonExampleTests.m | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m b/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m index adedc8f..97e3a0a 100644 --- a/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m +++ b/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m @@ -113,4 +113,63 @@ - (void)testTitleLabelDoesNotWrapWhenButtonIsWideEnough { @"Title label wrapped to multiple lines despite adequate button width"); } +#pragma mark - Init & Defaults + +- (void)testInitUsesDefaultFrameSize { + TORoundedButton *button = [[TORoundedButton alloc] init]; + XCTAssertEqualWithAccuracy(button.bounds.size.width, 288.0, 0.001); + XCTAssertEqualWithAccuracy(button.bounds.size.height, 52.0, 0.001); +} + +- (void)testInitWithTextSetsText { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Confirm"]; + XCTAssertEqualObjects(button.text, @"Confirm"); +} + +- (void)testInitWithContentViewSetsContentView { + UIView *custom = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 100, 40)]; + TORoundedButton *button = [[TORoundedButton alloc] initWithContentView:custom]; + XCTAssertEqualObjects(button.contentView, custom); +} + +#pragma mark - Text & Appearance + +- (void)testTextRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] init]; + button.text = @"Hello"; + XCTAssertEqualObjects(button.text, @"Hello"); +} + +- (void)testAttributedTextRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] init]; + NSAttributedString *attributed = [[NSAttributedString alloc] initWithString:@"Styled"]; + button.attributedText = attributed; + XCTAssertEqualObjects(button.attributedText.string, @"Styled"); +} + +- (void)testTextColorRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + button.textColor = [UIColor redColor]; + XCTAssertEqualObjects(button.textColor, [UIColor redColor]); +} + +- (void)testTextFontRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + UIFont *font = [UIFont systemFontOfSize:22.0]; + button.textFont = font; + XCTAssertEqualObjects(button.textFont, font); +} + +- (void)testTextPointSizeSetsFontSize { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + button.textPointSize = 28.0; + XCTAssertEqualWithAccuracy(button.textFont.pointSize, 28.0, 0.001); +} + +- (void)testContentInsetRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + button.contentInset = UIEdgeInsetsMake(10, 20, 10, 20); + XCTAssertTrue(UIEdgeInsetsEqualToEdgeInsets(button.contentInset, UIEdgeInsetsMake(10, 20, 10, 20))); +} + @end From f3fdcac149aec4762b6258a4f5fd75d1a961f1a7 Mon Sep 17 00:00:00 2001 From: Tim Oliver Date: Wed, 27 May 2026 12:53:57 +0900 Subject: [PATCH 08/18] Strengthen attributedText test to verify attribute preservation --- .../TORoundedButtonExampleTests.m | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m b/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m index 97e3a0a..60a8f55 100644 --- a/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m +++ b/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m @@ -142,14 +142,24 @@ - (void)testTextRoundTrip { - (void)testAttributedTextRoundTrip { TORoundedButton *button = [[TORoundedButton alloc] init]; - NSAttributedString *attributed = [[NSAttributedString alloc] initWithString:@"Styled"]; + NSAttributedString *attributed = [[NSAttributedString alloc] initWithString:@"Styled" + attributes:@{ NSForegroundColorAttributeName : [UIColor blueColor] }]; button.attributedText = attributed; + + // Both the text and the attributes we set should survive the round-trip. We check a + // specific attribute we set rather than full-object equality, since UILabel augments + // the string with its own default attributes (so == against the original can fail). XCTAssertEqualObjects(button.attributedText.string, @"Styled"); + UIColor *foreground = [button.attributedText attribute:NSForegroundColorAttributeName + atIndex:0 + effectiveRange:NULL]; + XCTAssertEqualObjects(foreground, [UIColor blueColor]); } - (void)testTextColorRoundTrip { TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; button.textColor = [UIColor redColor]; + // The getter returns the stored UIColor unchanged, so -isEqual: is the right check. XCTAssertEqualObjects(button.textColor, [UIColor redColor]); } From f12fc5362df810b3fa398a9de4a196bc96e99647 Mon Sep 17 00:00:00 2001 From: Tim Oliver Date: Wed, 27 May 2026 12:55:20 +0900 Subject: [PATCH 09/18] Add interaction, state, and background-style tests --- .../TORoundedButtonExampleTests.m | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m b/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m index 60a8f55..96670e6 100644 --- a/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m +++ b/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m @@ -9,6 +9,18 @@ #import #import "TORoundedButton.h" +@interface TORoundedButtonTestDelegate : NSObject +@property (nonatomic, assign) NSInteger tapCount; +@property (nonatomic, weak) TORoundedButton *lastButton; +@end + +@implementation TORoundedButtonTestDelegate +- (void)roundedButtonDidTap:(TORoundedButton *)button { + self.tapCount += 1; + self.lastButton = button; +} +@end + @interface TORoundedButtonExampleTests : XCTestCase @end @@ -182,4 +194,63 @@ - (void)testContentInsetRoundTrip { XCTAssertTrue(UIEdgeInsetsEqualToEdgeInsets(button.contentInset, UIEdgeInsetsMake(10, 20, 10, 20))); } +#pragma mark - Behavior + +- (void)testTappedHandlerFiresOnTouchUpInside { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Tap"]; + __block NSInteger count = 0; + button.tappedHandler = ^{ count += 1; }; + [button sendActionsForControlEvents:UIControlEventTouchUpInside]; + XCTAssertEqual(count, 1); +} + +- (void)testDelegateReceivesTap { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Tap"]; + TORoundedButtonTestDelegate *delegate = [TORoundedButtonTestDelegate new]; + button.delegate = delegate; + [button sendActionsForControlEvents:UIControlEventTouchUpInside]; + XCTAssertEqual(delegate.tapCount, 1); + XCTAssertEqualObjects(delegate.lastButton, button); +} + +- (void)testDisabledReducesContainerAlpha { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + UIView *container = [button valueForKey:@"containerView"]; + XCTAssertEqualWithAccuracy(container.alpha, 1.0, 0.001); + button.enabled = NO; + XCTAssertEqualWithAccuracy(container.alpha, 0.4, 0.001); + button.enabled = YES; + XCTAssertEqualWithAccuracy(container.alpha, 1.0, 0.001); +} + +- (void)testOverrideContentViewHidesAndRestoresContentView { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + UIView *override = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 50, 50)]; + + button.overrideContentView = override; + XCTAssertEqualObjects(button.overrideContentView, override); + XCTAssertTrue(button.contentView.hidden); + XCTAssertEqualObjects(override.superview, [button valueForKey:@"containerView"]); + + button.overrideContentView = nil; + XCTAssertNil(button.overrideContentView); + XCTAssertFalse(button.contentView.hidden); +} + +- (void)testBackgroundStyleBlurUsesVisualEffectView { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + button.backgroundStyle = TORoundedButtonBackgroundStyleBlur; + XCTAssertEqual(button.backgroundStyle, TORoundedButtonBackgroundStyleBlur); + UIView *backgroundView = [button valueForKey:@"backgroundView"]; + XCTAssertTrue([backgroundView isKindOfClass:[UIVisualEffectView class]]); +} + +- (void)testSizeToFitResizesButtonToMinimumWidth { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + button.frame = CGRectMake(0, 0, 1000, 200); + [button sizeToFit]; + XCTAssertEqualWithAccuracy(button.bounds.size.width, button.minimumWidth, 1.0); + XCTAssertGreaterThan(button.bounds.size.height, 0.0); +} + @end From ee4eba617f6b5828c002206aab9ca65b4de8cf0c Mon Sep 17 00:00:00 2001 From: Tim Oliver Date: Wed, 27 May 2026 12:59:01 +0900 Subject: [PATCH 10/18] De-circularize sizeToFit test; clarify behavior test constants --- .../TORoundedButtonExampleTests.m | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m b/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m index 96670e6..a963d63 100644 --- a/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m +++ b/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m @@ -218,6 +218,7 @@ - (void)testDisabledReducesContainerAlpha { UIView *container = [button valueForKey:@"containerView"]; XCTAssertEqualWithAccuracy(container.alpha, 1.0, 0.001); button.enabled = NO; + // setEnabled: hard-codes the disabled alpha to 0.4; this locks that value. XCTAssertEqualWithAccuracy(container.alpha, 0.4, 0.001); button.enabled = YES; XCTAssertEqualWithAccuracy(container.alpha, 1.0, 0.001); @@ -239,18 +240,31 @@ - (void)testOverrideContentViewHidesAndRestoresContentView { - (void)testBackgroundStyleBlurUsesVisualEffectView { TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + // Changing backgroundStyle rebuilds the background view synchronously, so this + // holds even without a superview (where the view would otherwise be created lazily). button.backgroundStyle = TORoundedButtonBackgroundStyleBlur; XCTAssertEqual(button.backgroundStyle, TORoundedButtonBackgroundStyleBlur); UIView *backgroundView = [button valueForKey:@"backgroundView"]; XCTAssertTrue([backgroundView isKindOfClass:[UIVisualEffectView class]]); } -- (void)testSizeToFitResizesButtonToMinimumWidth { +- (void)testSizeToFitResizesButtonToFitContent { TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; button.frame = CGRectMake(0, 0, 1000, 200); [button sizeToFit]; - XCTAssertEqualWithAccuracy(button.bounds.size.width, button.minimumWidth, 1.0); - XCTAssertGreaterThan(button.bounds.size.height, 0.0); + + // sizeToFit should shrink the over-sized button down to its content: the label's + // single-line width plus the horizontal contentInset. The expected width is anchored + // to an independent reference measurement (not the button's own minimumWidth, which + // would make this circular since both route through sizeThatFits:). + UILabel *reference = [self referenceLabelForButton:button]; + CGFloat expectedWidth = [reference sizeThatFits:CGSizeMake(1.0e6, 1.0e6)].width + + button.contentInset.left + button.contentInset.right; + XCTAssertEqualWithAccuracy(button.bounds.size.width, expectedWidth, 1.0); + + // And it genuinely shrank from the over-sized starting frame in both dimensions. + XCTAssertLessThan(button.bounds.size.width, 1000.0); + XCTAssertLessThan(button.bounds.size.height, 200.0); } @end From 893964eecacbd0b6741d603743ab1fc406c3ffa8 Mon Sep 17 00:00:00 2001 From: Tim Oliver Date: Wed, 27 May 2026 12:59:45 +0900 Subject: [PATCH 11/18] Update CHANGELOG for minimumWidth and label-wrap fix --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9856c8..0534538 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ x.y.z Release Notes (yyyy-MM-dd) ============================================================= +### Added + +* Re-added the `minimumWidth` property, now including the horizontal `contentInset` padding, to help external layout systems size the button around its content. + +### Fixed + +* An issue where the title label could wrap onto two lines after `sizeToFit` was called against an already-narrow frame. + 2.0.0 Release Notes (2026-01-22) ============================================================= From f23e5e9b45d72234197608635046b1856abb74f3 Mon Sep 17 00:00:00 2001 From: Tim Oliver Date: Wed, 27 May 2026 13:18:57 +0900 Subject: [PATCH 12/18] Add round-trip tests for all public accessor properties --- .../TORoundedButtonExampleTests.m | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m b/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m index a963d63..2601d01 100644 --- a/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m +++ b/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m @@ -267,4 +267,124 @@ - (void)testSizeToFitResizesButtonToFitContent { XCTAssertLessThan(button.bounds.size.height, 200.0); } +#pragma mark - Accessors + +- (void)testContentViewRoundTripAndNilResets { + TORoundedButton *button = [[TORoundedButton alloc] init]; + UIView *custom = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 80, 30)]; + button.contentView = custom; + XCTAssertEqualObjects(button.contentView, custom); + + // contentView is null_resettable: setting nil restores a fresh, non-nil view. + button.contentView = nil; + XCTAssertNotNil(button.contentView); + XCTAssertNotEqualObjects(button.contentView, custom); +} + +- (void)testTintColorRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + button.tintColor = [UIColor purpleColor]; + XCTAssertEqualObjects(button.tintColor, [UIColor purpleColor]); +} + +- (void)testTappedTintColorRoundTripResetsBrightnessOffset { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + button.tappedTintColor = [UIColor greenColor]; + XCTAssertEqualObjects(button.tappedTintColor, [UIColor greenColor]); + // Setting an explicit tapped tint disables the brightness-derived color. + XCTAssertEqualWithAccuracy(button.tappedTintColorBrightnessOffset, 0.0, 0.001); +} + +- (void)testTappedTintColorBrightnessOffsetRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + button.tappedTintColorBrightnessOffset = 0.5; + XCTAssertEqualWithAccuracy(button.tappedTintColorBrightnessOffset, 0.5, 0.001); +} + +- (void)testTappedTextAlphaRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + button.tappedTextAlpha = 0.5; + XCTAssertEqualWithAccuracy(button.tappedTextAlpha, 0.5, 0.001); +} + +- (void)testTappedButtonScaleRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + button.tappedButtonScale = 0.8; + XCTAssertEqualWithAccuracy(button.tappedButtonScale, 0.8, 0.001); +} + +- (void)testTapAnimationDurationsRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + button.tapDownAnimationDuration = 0.2; + button.tapUpAnimationDuration = 0.6; + XCTAssertEqualWithAccuracy(button.tapDownAnimationDuration, 0.2, 0.001); + XCTAssertEqualWithAccuracy(button.tapUpAnimationDuration, 0.6, 0.001); +} + +- (void)testCornerRadiusRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + button.cornerRadius = 20.0; + XCTAssertEqualWithAccuracy(button.cornerRadius, 20.0, 0.001); +#ifdef __IPHONE_26_0 + if (@available(iOS 26.0, *)) { + // On iOS 26, setting cornerRadius also rebuilds the corner configuration. + XCTAssertNotNil(button.cornerConfiguration); + } +#endif +} + +- (void)testImpactStyleRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + button.impactStyle = TORoundedButtonImpactStyleHeavy; + XCTAssertEqual(button.impactStyle, TORoundedButtonImpactStyleHeavy); + button.impactStyle = TORoundedButtonImpactStyleNone; + XCTAssertEqual(button.impactStyle, TORoundedButtonImpactStyleNone); +} + +- (void)testBlurStyleRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + // Switch to the blur background so the style is actually applied to the effect view. + button.backgroundStyle = TORoundedButtonBackgroundStyleBlur; + button.blurStyle = UIBlurEffectStyleLight; + XCTAssertEqual(button.blurStyle, UIBlurEffectStyleLight); +} + +- (void)testDelegateRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + TORoundedButtonTestDelegate *delegate = [TORoundedButtonTestDelegate new]; + button.delegate = delegate; + XCTAssertEqualObjects(button.delegate, delegate); +} + +- (void)testTappedHandlerRoundTrip { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + XCTAssertNil(button.tappedHandler); + button.tappedHandler = ^{}; + XCTAssertNotNil(button.tappedHandler); +} + +#ifdef __IPHONE_26_0 +- (void)testCornerConfigurationRoundTrip { + if (@available(iOS 26.0, *)) { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + UICornerConfiguration *config = + [UICornerConfiguration configurationWithUniformRadius:[UICornerRadius fixedRadius:8.0]]; + button.cornerConfiguration = config; + XCTAssertEqualObjects(button.cornerConfiguration, config); + } +} + +- (void)testGlassStyleRoundTrip { + if (@available(iOS 26.0, *)) { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; + // Toggle away from and back to glass so the glass effect view exists and the + // style is actually applied (the default is already glass on iOS 26). + button.backgroundStyle = TORoundedButtonBackgroundStyleSolid; + button.backgroundStyle = TORoundedButtonBackgroundStyleGlass; + button.glassStyle = UIGlassEffectStyleClear; + XCTAssertEqual(button.glassStyle, UIGlassEffectStyleClear); + } +} +#endif + @end From 415cbe2b3f74a432ee39d6de6b8cdb05aa675adc Mon Sep 17 00:00:00 2001 From: Tim Oliver Date: Wed, 27 May 2026 13:26:12 +0900 Subject: [PATCH 13/18] Clarify equality semantics in accessor tests --- TORoundedButtonExampleTests/TORoundedButtonExampleTests.m | 2 ++ 1 file changed, 2 insertions(+) diff --git a/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m b/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m index 2601d01..eeae460 100644 --- a/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m +++ b/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m @@ -284,6 +284,7 @@ - (void)testContentViewRoundTripAndNilResets { - (void)testTintColorRoundTrip { TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; button.tintColor = [UIColor purpleColor]; + // The getter returns the explicitly-set tint color unchanged. XCTAssertEqualObjects(button.tintColor, [UIColor purpleColor]); } @@ -370,6 +371,7 @@ - (void)testCornerConfigurationRoundTrip { UICornerConfiguration *config = [UICornerConfiguration configurationWithUniformRadius:[UICornerRadius fixedRadius:8.0]]; button.cornerConfiguration = config; + // Pointer equality: the setter stores the config directly without copying. XCTAssertEqualObjects(button.cornerConfiguration, config); } } From ab11cdef76407182dbf117f3088f9067fdc8b036 Mon Sep 17 00:00:00 2001 From: Tim Oliver Date: Wed, 27 May 2026 13:29:28 +0900 Subject: [PATCH 14/18] Enabled code coverage on the test suite --- .../xcshareddata/xcschemes/TORoundedButtonExample.xcscheme | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/TORoundedButtonExample.xcodeproj/xcshareddata/xcschemes/TORoundedButtonExample.xcscheme b/TORoundedButtonExample.xcodeproj/xcshareddata/xcschemes/TORoundedButtonExample.xcscheme index 435fa67..83521bb 100644 --- a/TORoundedButtonExample.xcodeproj/xcshareddata/xcschemes/TORoundedButtonExample.xcscheme +++ b/TORoundedButtonExample.xcodeproj/xcshareddata/xcschemes/TORoundedButtonExample.xcscheme @@ -26,7 +26,8 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES" + codeCoverageEnabled = "YES"> Date: Wed, 27 May 2026 13:31:29 +0900 Subject: [PATCH 15/18] Removed completed implementation docs --- .../plans/2026-05-27-rounded-button-sizing.md | 540 ------------------ ...2026-05-27-rounded-button-sizing-design.md | 128 ----- 2 files changed, 668 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-27-rounded-button-sizing.md delete mode 100644 docs/superpowers/specs/2026-05-27-rounded-button-sizing-design.md diff --git a/docs/superpowers/plans/2026-05-27-rounded-button-sizing.md b/docs/superpowers/plans/2026-05-27-rounded-button-sizing.md deleted file mode 100644 index c05130d..0000000 --- a/docs/superpowers/plans/2026-05-27-rounded-button-sizing.md +++ /dev/null @@ -1,540 +0,0 @@ -# TORoundedButton Sizing & Test Coverage Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Re-add a padding-aware `minimumWidth` property, fix the title-label two-line wrap glitch, and add comprehensive unit tests. - -**Architecture:** `minimumWidth` is a readonly property that delegates to the existing `sizeThatFits:` path at an unconstrained width, so it returns single-line content width + horizontal `contentInset`. The wrap glitch is fixed by seeding the title label's frame with the available content width before `sizeToFit` in `layoutSubviews`, so it wraps only when genuinely too narrow. Tests are added to the existing XCTest file and use relational assertions against a freshly-measured reference label. - -**Tech Stack:** Objective-C, UIKit, XCTest, `xcodebuild`. - ---- - -## Orientation (read once before starting) - -- **One real source file.** `spm/TORoundedButton.m` and `spm/include/TORoundedButton.h` are symlinks to `TORoundedButton/TORoundedButton.m` and `TORoundedButton/TORoundedButton.h`. Edit only the `TORoundedButton/` copies. -- **Tests** live in `TORoundedButtonExampleTests/TORoundedButtonExampleTests.m`, which is already a member of the `TORoundedButtonTests` scheme/target. Adding methods to this file needs **no** `.pbxproj` changes. -- **Private members** (`_titleLabel`, `_containerView`, `_backgroundView`) are reached from tests via KVC, e.g. `[button valueForKey:@"titleLabel"]` — KVC resolves key `titleLabel` to the `_titleLabel` ivar. -- **Run a single test:** - ```bash - xcodebuild test \ - -project TORoundedButtonExample.xcodeproj \ - -scheme TORoundedButtonTests \ - -destination 'platform=iOS Simulator,name=iPhone 16e,OS=26.2' \ - -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/ - ``` - Output ends in `** TEST SUCCEEDED **` or `** TEST FAILED **`. -- **Run the whole suite (CI command):** - ```bash - xcodebuild -project TORoundedButtonExample.xcodeproj -scheme TORoundedButtonTests \ - -destination 'platform=iOS Simulator,name=iPhone 16e,OS=26.2' clean test - ``` -- If the `iPhone 16e` / `OS=26.2` simulator is not installed locally, run `xcrun simctl list devices available` and substitute an installed device/OS in the `-destination`. -- **Observation, not in scope:** the header comment for `init`/`initWithText:` says "50 tall" but the code uses `52`. Tests below assert the real value (`52`). Leave the comment alone. - ---- - -## File Structure - -- **Modify** `TORoundedButton/TORoundedButton.h` — declare the `minimumWidth` property. -- **Modify** `TORoundedButton/TORoundedButton.m` — implement `minimumWidth`; seed the label frame in `layoutSubviews`. -- **Modify** `TORoundedButtonExampleTests/TORoundedButtonExampleTests.m` — add a test delegate class, a reference-label helper, and all new tests. -- **Modify** `CHANGELOG.md` — record the re-added property and the fix. - ---- - -## Task 1: Fix the title-label two-line wrap glitch (TDD) - -**Files:** -- Test: `TORoundedButtonExampleTests/TORoundedButtonExampleTests.m` -- Modify: `TORoundedButton/TORoundedButton.m` (`layoutSubviews`, currently lines ~293-300) - -- [ ] **Step 1: Add the reference-label helper and the failing regression test** - -Add this helper and test inside `@implementation TORoundedButtonExampleTests` (e.g. just below the existing `testButtonInteraction`): - -```objc -#pragma mark - Helpers - -/// A standalone label configured like the button's private title label, -/// for measuring expected sizes without coupling tests to exact pixel values. -- (UILabel *)referenceLabelForButton:(TORoundedButton *)button { - UILabel *titleLabel = [button valueForKey:@"titleLabel"]; - UILabel *reference = [[UILabel alloc] init]; - reference.font = titleLabel.font; - reference.text = titleLabel.text; - return reference; -} - -#pragma mark - Layout Regression - -- (void)testTitleLabelDoesNotWrapWhenButtonIsWideEnough { - TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Reasonably Long Title"]; - button.frame = CGRectMake(0, 0, 400, 52); // easily wide enough for one line - - UILabel *titleLabel = [button valueForKey:@"titleLabel"]; - - // Natural single-line height (a fresh label defaults to numberOfLines == 1). - UILabel *reference = [self referenceLabelForButton:button]; - [reference sizeToFit]; - CGFloat singleLineHeight = reference.frame.size.height; - - // Force the buggy precondition: a too-narrow label frame before layout. - CGRect narrow = titleLabel.frame; - narrow.size.width = 10.0; // narrower than the longest word - titleLabel.frame = narrow; - - [button setNeedsLayout]; - [button layoutIfNeeded]; - - XCTAssertEqualWithAccuracy(titleLabel.frame.size.height, singleLineHeight, 2.0, - @"Title label wrapped to multiple lines despite adequate button width"); -} -``` - -- [ ] **Step 2: Run the test and verify it FAILS** - -Run: -```bash -xcodebuild test -project TORoundedButtonExample.xcodeproj -scheme TORoundedButtonTests \ - -destination 'platform=iOS Simulator,name=iPhone 16e,OS=26.2' \ - -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testTitleLabelDoesNotWrapWhenButtonIsWideEnough -``` -Expected: `** TEST FAILED **` — the label's height is ~2 lines because `layoutSubviews` calls `sizeToFit` against the width-10 frame. - -- [ ] **Step 3: Apply the fix in `layoutSubviews`** - -In `TORoundedButton/TORoundedButton.m`, replace: - -```objc - // Lay out the title label - if (!_titleLabel) { return; } - [_titleLabel sizeToFit]; -``` - -with: - -```objc - // Lay out the title label - if (!_titleLabel) { return; } - // Seed the label with the available content width first; otherwise a stale, - // too-narrow frame makes sizeToFit wrap the text onto multiple lines even - // when there is plenty of horizontal room. - CGRect labelFrame = _titleLabel.frame; - labelFrame.size = contentBounds.size; - _titleLabel.frame = labelFrame; - [_titleLabel sizeToFit]; -``` - -- [ ] **Step 4: Run the test and verify it PASSES** - -Run the same command as Step 2. -Expected: `** TEST SUCCEEDED **`. - -- [ ] **Step 5: Commit** - -```bash -git add TORoundedButton/TORoundedButton.m TORoundedButtonExampleTests/TORoundedButtonExampleTests.m -git commit -m "Fix title label wrapping to two lines on small frames - -Co-Authored-By: Claude Opus 4.7 " -``` - ---- - -## Task 2: Re-add the `minimumWidth` property (TDD) - -**Files:** -- Test: `TORoundedButtonExampleTests/TORoundedButtonExampleTests.m` -- Modify: `TORoundedButton/TORoundedButton.h` (after the `tappedHandler` property, ~line 135) -- Modify: `TORoundedButton/TORoundedButton.m` (after `sizeThatFits:`, ~line 331) - -- [ ] **Step 1: Write the failing tests** - -Add to `TORoundedButtonExampleTests.m` under a new pragma mark: - -```objc -#pragma mark - Sizing - -- (void)testMinimumWidthIncludesContentInset { - TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Hello"]; - UILabel *reference = [self referenceLabelForButton:button]; - CGFloat textWidth = [reference sizeThatFits:CGSizeMake(1.0e6, 1.0e6)].width; - CGFloat expected = textWidth + button.contentInset.left + button.contentInset.right; - XCTAssertEqualWithAccuracy(button.minimumWidth, expected, 1.0); -} - -- (void)testMinimumWidthGrowsWithLongerText { - TORoundedButton *shortButton = [[TORoundedButton alloc] initWithText:@"Hi"]; - TORoundedButton *longButton = [[TORoundedButton alloc] initWithText:@"A much longer button title"]; - XCTAssertGreaterThan(longButton.minimumWidth, shortButton.minimumWidth); -} - -- (void)testMinimumWidthGrowsWithContentInset { - TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Hello"]; - CGFloat before = button.minimumWidth; - button.contentInset = UIEdgeInsetsMake(15, 40, 15, 40); - CGFloat after = button.minimumWidth; - // Horizontal inset went from 15+15 (30) to 40+40 (80): a 50pt increase. - XCTAssertEqualWithAccuracy(after - before, 50.0, 1.0); -} -``` - -- [ ] **Step 2: Run the tests and verify they FAIL (compile error)** - -Run: -```bash -xcodebuild test -project TORoundedButtonExample.xcodeproj -scheme TORoundedButtonTests \ - -destination 'platform=iOS Simulator,name=iPhone 16e,OS=26.2' \ - -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testMinimumWidthIncludesContentInset -``` -Expected: build failure — `property 'minimumWidth' not found on object of type 'TORoundedButton *'`. - -- [ ] **Step 3: Declare the property in the header** - -In `TORoundedButton/TORoundedButton.h`, replace: - -```objc -/// A callback handler triggered each time the button is tapped. -@property (nonatomic, copy) void (^tappedHandler)(void); -``` - -with: - -```objc -/// A callback handler triggered each time the button is tapped. -@property (nonatomic, copy) void (^tappedHandler)(void); - -/// The smallest width this button can be while still fitting its content on a single -/// line, including the horizontal `contentInset` padding. Useful for external layout -/// systems (such as alert controllers) sizing themselves around the button. -@property (nonatomic, readonly) CGFloat minimumWidth; -``` - -- [ ] **Step 4: Implement the property** - -In `TORoundedButton/TORoundedButton.m`, find the end of `sizeThatFits:`: - -```objc - newSize.width += horizontalPadding; - newSize.height += verticalPadding; - return newSize; -} -``` - -and insert immediately after it: - -```objc - -- (CGFloat)minimumWidth { - // Measure at a large but finite size so the content lays out on a single line - // without tripping Core Text overflow handling (which a literal CGFLOAT_MAX can, - // once sizeThatFits: subtracts the horizontal padding from it). - return [self sizeThatFits:(CGSize){1.0e6, 1.0e6}].width; -} -``` - -- [ ] **Step 5: Run the tests and verify they PASS** - -Run: -```bash -xcodebuild test -project TORoundedButtonExample.xcodeproj -scheme TORoundedButtonTests \ - -destination 'platform=iOS Simulator,name=iPhone 16e,OS=26.2' \ - -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testMinimumWidthIncludesContentInset \ - -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testMinimumWidthGrowsWithLongerText \ - -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testMinimumWidthGrowsWithContentInset -``` -Expected: `** TEST SUCCEEDED **`. - -- [ ] **Step 6: Commit** - -```bash -git add TORoundedButton/TORoundedButton.h TORoundedButton/TORoundedButton.m TORoundedButtonExampleTests/TORoundedButtonExampleTests.m -git commit -m "Re-add padding-aware minimumWidth property - -Co-Authored-By: Claude Opus 4.7 " -``` - ---- - -## Task 3: Characterization tests — init, defaults, text & appearance - -These document existing behavior, so they should pass against current code on first run. If any fails, stop and investigate — it's a real bug. - -**Files:** -- Test: `TORoundedButtonExampleTests/TORoundedButtonExampleTests.m` - -- [ ] **Step 1: Add the tests** - -Add under a new pragma mark: - -```objc -#pragma mark - Init & Defaults - -- (void)testInitUsesDefaultFrameSize { - TORoundedButton *button = [[TORoundedButton alloc] init]; - XCTAssertEqualWithAccuracy(button.bounds.size.width, 288.0, 0.001); - XCTAssertEqualWithAccuracy(button.bounds.size.height, 52.0, 0.001); -} - -- (void)testInitWithTextSetsText { - TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Confirm"]; - XCTAssertEqualObjects(button.text, @"Confirm"); -} - -- (void)testInitWithContentViewSetsContentView { - UIView *custom = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 100, 40)]; - TORoundedButton *button = [[TORoundedButton alloc] initWithContentView:custom]; - XCTAssertEqualObjects(button.contentView, custom); -} - -#pragma mark - Text & Appearance - -- (void)testTextRoundTrip { - TORoundedButton *button = [[TORoundedButton alloc] init]; - button.text = @"Hello"; - XCTAssertEqualObjects(button.text, @"Hello"); -} - -- (void)testAttributedTextRoundTrip { - TORoundedButton *button = [[TORoundedButton alloc] init]; - NSAttributedString *attributed = [[NSAttributedString alloc] initWithString:@"Styled"]; - button.attributedText = attributed; - XCTAssertEqualObjects(button.attributedText.string, @"Styled"); -} - -- (void)testTextColorRoundTrip { - TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; - button.textColor = [UIColor redColor]; - XCTAssertEqualObjects(button.textColor, [UIColor redColor]); -} - -- (void)testTextFontRoundTrip { - TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; - UIFont *font = [UIFont systemFontOfSize:22.0]; - button.textFont = font; - XCTAssertEqualObjects(button.textFont, font); -} - -- (void)testTextPointSizeSetsFontSize { - TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; - button.textPointSize = 28.0; - XCTAssertEqualWithAccuracy(button.textFont.pointSize, 28.0, 0.001); -} - -- (void)testContentInsetRoundTrip { - TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; - button.contentInset = UIEdgeInsetsMake(10, 20, 10, 20); - XCTAssertTrue(UIEdgeInsetsEqualToEdgeInsets(button.contentInset, UIEdgeInsetsMake(10, 20, 10, 20))); -} -``` - -- [ ] **Step 2: Run the new tests and verify they PASS** - -Run: -```bash -xcodebuild test -project TORoundedButtonExample.xcodeproj -scheme TORoundedButtonTests \ - -destination 'platform=iOS Simulator,name=iPhone 16e,OS=26.2' \ - -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testInitUsesDefaultFrameSize \ - -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testInitWithTextSetsText \ - -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testInitWithContentViewSetsContentView \ - -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testTextRoundTrip \ - -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testAttributedTextRoundTrip \ - -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testTextColorRoundTrip \ - -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testTextFontRoundTrip \ - -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testTextPointSizeSetsFontSize \ - -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testContentInsetRoundTrip -``` -Expected: `** TEST SUCCEEDED **`. - -- [ ] **Step 3: Commit** - -```bash -git add TORoundedButtonExampleTests/TORoundedButtonExampleTests.m -git commit -m "Add init, default, and appearance tests - -Co-Authored-By: Claude Opus 4.7 " -``` - ---- - -## Task 4: Characterization tests — interaction, state & background style - -**Files:** -- Test: `TORoundedButtonExampleTests/TORoundedButtonExampleTests.m` - -- [ ] **Step 1: Add a test delegate class** - -At the top of the file, after the `#import` lines and before `@interface TORoundedButtonExampleTests`: - -```objc -@interface TORoundedButtonTestDelegate : NSObject -@property (nonatomic, assign) NSInteger tapCount; -@property (nonatomic, weak) TORoundedButton *lastButton; -@end - -@implementation TORoundedButtonTestDelegate -- (void)roundedButtonDidTap:(TORoundedButton *)button { - self.tapCount += 1; - self.lastButton = button; -} -@end -``` - -- [ ] **Step 2: Add the behavior tests** - -Add under a new pragma mark in `@implementation TORoundedButtonExampleTests`: - -```objc -#pragma mark - Behavior - -- (void)testTappedHandlerFiresOnTouchUpInside { - TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Tap"]; - __block NSInteger count = 0; - button.tappedHandler = ^{ count += 1; }; - [button sendActionsForControlEvents:UIControlEventTouchUpInside]; - XCTAssertEqual(count, 1); -} - -- (void)testDelegateReceivesTap { - TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Tap"]; - TORoundedButtonTestDelegate *delegate = [TORoundedButtonTestDelegate new]; - button.delegate = delegate; - [button sendActionsForControlEvents:UIControlEventTouchUpInside]; - XCTAssertEqual(delegate.tapCount, 1); - XCTAssertEqualObjects(delegate.lastButton, button); -} - -- (void)testDisabledReducesContainerAlpha { - TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; - UIView *container = [button valueForKey:@"containerView"]; - XCTAssertEqualWithAccuracy(container.alpha, 1.0, 0.001); - button.enabled = NO; - XCTAssertEqualWithAccuracy(container.alpha, 0.4, 0.001); - button.enabled = YES; - XCTAssertEqualWithAccuracy(container.alpha, 1.0, 0.001); -} - -- (void)testOverrideContentViewHidesAndRestoresContentView { - TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; - UIView *override = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 50, 50)]; - - button.overrideContentView = override; - XCTAssertEqualObjects(button.overrideContentView, override); - XCTAssertTrue(button.contentView.hidden); - XCTAssertEqualObjects(override.superview, [button valueForKey:@"containerView"]); - - button.overrideContentView = nil; - XCTAssertNil(button.overrideContentView); - XCTAssertFalse(button.contentView.hidden); -} - -- (void)testBackgroundStyleBlurUsesVisualEffectView { - TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; - button.backgroundStyle = TORoundedButtonBackgroundStyleBlur; - XCTAssertEqual(button.backgroundStyle, TORoundedButtonBackgroundStyleBlur); - UIView *backgroundView = [button valueForKey:@"backgroundView"]; - XCTAssertTrue([backgroundView isKindOfClass:[UIVisualEffectView class]]); -} - -- (void)testSizeToFitResizesButtonToMinimumWidth { - TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Test"]; - button.frame = CGRectMake(0, 0, 1000, 200); - [button sizeToFit]; - XCTAssertEqualWithAccuracy(button.bounds.size.width, button.minimumWidth, 1.0); - XCTAssertGreaterThan(button.bounds.size.height, 0.0); -} -``` - -- [ ] **Step 3: Run the new tests and verify they PASS** - -Run: -```bash -xcodebuild test -project TORoundedButtonExample.xcodeproj -scheme TORoundedButtonTests \ - -destination 'platform=iOS Simulator,name=iPhone 16e,OS=26.2' \ - -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testTappedHandlerFiresOnTouchUpInside \ - -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testDelegateReceivesTap \ - -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testDisabledReducesContainerAlpha \ - -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testOverrideContentViewHidesAndRestoresContentView \ - -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testBackgroundStyleBlurUsesVisualEffectView \ - -only-testing:TORoundedButtonExampleTests/TORoundedButtonExampleTests/testSizeToFitResizesButtonToMinimumWidth -``` -Expected: `** TEST SUCCEEDED **`. (If `testBackgroundStyleBlurUsesVisualEffectView` fails, inspect `_makeBackgroundViewWithStyle:` — the assertion documents the real return type; adjust the assertion to match observed behavior rather than forcing it.) - -- [ ] **Step 4: Commit** - -```bash -git add TORoundedButtonExampleTests/TORoundedButtonExampleTests.m -git commit -m "Add interaction, state, and background-style tests - -Co-Authored-By: Claude Opus 4.7 " -``` - ---- - -## Task 5: Update CHANGELOG and verify the full suite - -**Files:** -- Modify: `CHANGELOG.md` - -- [ ] **Step 1: Add CHANGELOG entries** - -In `CHANGELOG.md`, replace: - -``` -x.y.z Release Notes (yyyy-MM-dd) -============================================================= - -2.0.0 Release Notes (2026-01-22) -``` - -with: - -``` -x.y.z Release Notes (yyyy-MM-dd) -============================================================= - -### Added - -* Re-added the `minimumWidth` property, now including the horizontal `contentInset` padding, to help external layout systems size the button around its content. - -### Fixed - -* An issue where the title label could wrap onto two lines after `sizeToFit` was called against an already-narrow frame. - -2.0.0 Release Notes (2026-01-22) -``` - -- [ ] **Step 2: Run the full suite and verify it PASSES** - -Run: -```bash -xcodebuild -project TORoundedButtonExample.xcodeproj -scheme TORoundedButtonTests \ - -destination 'platform=iOS Simulator,name=iPhone 16e,OS=26.2' clean test -``` -Expected: `** TEST SUCCEEDED **` with all tests (the original 2 plus the new ones) passing. - -- [ ] **Step 3: Commit** - -```bash -git add CHANGELOG.md -git commit -m "Update CHANGELOG for minimumWidth and label-wrap fix - -Co-Authored-By: Claude Opus 4.7 " -``` - ---- - -## Self-Review (completed by plan author) - -**Spec coverage:** -- `minimumWidth` (readonly, contentInset-aware) → Task 2. -- Label two-line wrap fix (surgical seed in `layoutSubviews`) → Task 1. -- Comprehensive tests (init/defaults, text/appearance, sizing, glitch regression, behavior) → Tasks 1–4. -- Relational assertions + KVC private access → helper in Task 1, used throughout. -- CHANGELOG update (acceptance criterion) → Task 5. -- Out-of-scope `setTextFont:` relayout quirk → correctly omitted. - -**Placeholder scan:** No TBD/TODO/"add error handling" — every code step contains complete code; the spec's `` stand-in is resolved to `1.0e6`. - -**Type consistency:** `referenceLabelForButton:` defined once (Task 1) and reused (Tasks 2, plus inline single-line reference). `minimumWidth` declared in Task 2 Step 3 and used in Task 4's `testSizeToFitResizesButtonToMinimumWidth`. KVC keys (`titleLabel`, `containerView`, `backgroundView`) match the ivars in `TORoundedButton.m`. Test target/class path `TORoundedButtonExampleTests/TORoundedButtonExampleTests` consistent across all commands. diff --git a/docs/superpowers/specs/2026-05-27-rounded-button-sizing-design.md b/docs/superpowers/specs/2026-05-27-rounded-button-sizing-design.md deleted file mode 100644 index 9d96023..0000000 --- a/docs/superpowers/specs/2026-05-27-rounded-button-sizing-design.md +++ /dev/null @@ -1,128 +0,0 @@ -# TORoundedButton — `minimumWidth`, label-wrap fix, and test coverage - -**Date:** 2026-05-27 -**Status:** Approved -**Branch:** `alert-fixes` - -## Context - -`TORoundedButton` is consumed by a separate alert library (`UIAlertViewController`), -which surfaced two regressions plus a desire for far stronger test coverage. - -Note: `spm/TORoundedButton.m` and `spm/include/TORoundedButton.h` are **symlinks** to -`TORoundedButton/TORoundedButton.{m,h}`. There is one real source file per pair — edits -to the canonical files cover the SPM package automatically. - -Tests run via `xcodebuild test` on the `TORoundedButtonTests` scheme against an iOS 26.2 -simulator (iPhone 16e). `TORoundedButtonExampleTests/TORoundedButtonExampleTests.m` is -already a member of that target. - -## Goals - -1. Re-introduce a `minimumWidth` property that reports the smallest width the button can - be while fitting its content — now padding-aware. -2. Fix a visual glitch where the title label wraps to two lines even when there is enough - horizontal room. -3. Add comprehensive unit-test coverage. - -## Non-goals - -- No new public sizing API beyond `minimumWidth` (e.g. no `minimumHorizontalPadding`, - no corner-radius-aware padding). `YAGNI` — `contentInset` is the agreed padding source. -- `setTextFont:` not always calling `setNeedsLayout` is a known separate latent issue and - is explicitly out of scope. - -## Design - -### 1. `minimumWidth` (readonly, padding-aware) - -Re-add to the public header: - -```objc -/// The smallest width this button can be while still fitting its content on a single -/// line, including the horizontal `contentInset` padding. -@property (nonatomic, readonly) CGFloat minimumWidth; -``` - -Implementation reuses the existing, tested `sizeThatFits:` path rather than a parallel -calculation: - -```objc -- (CGFloat)minimumWidth { - return [self sizeThatFits:CGSizeMake(, CGFLOAT_MAX)].width; -} -``` - -Behaviour: - -- Returns single-line natural content width **plus** `contentInset.left + contentInset.right`. -- Tracks `text`, `attributedText`, `textFont`, `textPointSize`, and `contentInset` - automatically. -- Works for custom `contentView`s via the same `sizeThatFits:` dispatch. - -Implementation detail: pass a **large finite** width (not literal `CGFLOAT_MAX`) into the -content/label measurement to avoid Core Text overflow edge cases when `sizeThatFits:` -subtracts the horizontal padding from the incoming width. - -### 2. Title-label two-line wrap fix - -Root cause: `layoutSubviews` calls `[_titleLabel sizeToFit]` against the label's *current* -frame. With `numberOfLines = 0`, a stale/too-narrow starting width makes -`sizeThatFits:` wrap the text to two lines even when the button is wide enough. - -Fix (surgical seed) — in `layoutSubviews`, before the existing `sizeToFit`: - -```objc -if (!_titleLabel) { return; } -CGRect labelFrame = _titleLabel.frame; -labelFrame.size = contentBounds.size; // seed with the available content room -_titleLabel.frame = labelFrame; -[_titleLabel sizeToFit]; // now wraps only when genuinely needed -``` - -`contentBounds` is the inset-adjusted rect already computed at the top of `layoutSubviews`. -Seeding with the real available width means the label wraps only when the text truly does -not fit, preserving intended multi-line behaviour for genuinely narrow buttons. - -Rejected alternative (B): rewrite `layoutSubviews` to drive the label frame from -`sizeThatFits:` like any other content. More principled but a larger change with higher -regression risk for a published library. - -### 3. Test coverage - -Expand `TORoundedButtonExampleTests/TORoundedButtonExampleTests.m` in place (already a -target member — no `.pbxproj` changes). Organise with `#pragma mark` groups: - -- **Init & defaults:** all four initializers (`init`, `initWithFrame:`, `initWithText:`, - `initWithContentView:`), default property values, iOS 26 `cornerConfiguration` vs - `cornerRadius`. -- **Text & appearance:** `text` / `attributedText` round-trips, `textColor`, `textFont`, - `textPointSize`, `contentInset`. -- **Sizing:** `minimumWidth` relationships and `sizeThatFits:` / `sizeToFit`. -- **Glitch regression:** put the title label in a too-narrow state, force layout at an - adequate width, assert the label stays a single line. Must fail before the fix and pass - after. -- **Behavior:** `tappedHandler` + delegate fire on tap, `enabled` alpha, - `overrideContentView` swap/restore, `backgroundStyle` (solid / blur / glass). - -Testing conventions: - -- **Relational assertions, not hard-coded pixels.** Compare against a freshly measured - reference `UILabel` (same font/text) so tests survive Dynamic Type and font-metric - differences across simulators. E.g. `minimumWidth ≈ referenceLabelWidth + - contentInset.left + contentInset.right`; `minimumWidth` grows with longer text; grows - with larger `contentInset`. -- **Private title label access via KVC:** `UILabel *label = [button valueForKey:@"titleLabel"];` - (KVC resolves to the `_titleLabel` ivar). Pragmatic for same-project XCTest; used only - where introspection is required (the glitch regression test). -- Drive layout in tests with `setNeedsLayout` + `layoutIfNeeded`. - -## Acceptance criteria - -- `minimumWidth` returns content single-line width + horizontal `contentInset`, and the - relational sizing tests pass. -- The glitch regression test fails on the pre-fix code and passes on the fixed code. -- All new tests pass via the CI command: - `xcodebuild -project TORoundedButtonExample.xcodeproj -scheme TORoundedButtonTests - -destination 'platform=iOS Simulator,name=iPhone 16e,OS=26.2' clean test`. -- CHANGELOG updated (re-added `minimumWidth`, label-wrap fix). From 5985bd4bac96e758c3923d20acea221d9fabdf23 Mon Sep 17 00:00:00 2001 From: Tim Oliver Date: Wed, 27 May 2026 13:39:44 +0900 Subject: [PATCH 16/18] Add tests covering the full touch-gesture and tap-animation paths --- .../TORoundedButtonExampleTests.m | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m b/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m index eeae460..fde4eda 100644 --- a/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m +++ b/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m @@ -99,6 +99,11 @@ - (UILabel *)referenceLabelForButton:(TORoundedButton *)button { return reference; } +/// Reads the button's private `_isTapped` flag via KVC. +- (BOOL)isTappedForButton:(TORoundedButton *)button { + return [[button valueForKey:@"isTapped"] boolValue]; +} + #pragma mark - Layout Regression - (void)testTitleLabelDoesNotWrapWhenButtonIsWideEnough { @@ -389,4 +394,68 @@ - (void)testGlassStyleRoundTrip { } #endif +#pragma mark - Touch & Animation + +- (void)testTouchSequenceTogglesTappedState { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Tap"]; + XCTAssertFalse([self isTappedForButton:button]); + + [button sendActionsForControlEvents:UIControlEventTouchDown]; // _didTouchDownInside + XCTAssertTrue([self isTappedForButton:button]); + + [button sendActionsForControlEvents:UIControlEventTouchDragExit]; // _didDragOutside + XCTAssertFalse([self isTappedForButton:button]); + + [button sendActionsForControlEvents:UIControlEventTouchDragEnter]; // _didDragInside + XCTAssertTrue([self isTappedForButton:button]); + + [button sendActionsForControlEvents:UIControlEventTouchUpInside]; // _didTouchUpInside:event: + XCTAssertFalse([self isTappedForButton:button]); +} + +- (void)testTouchCancelResetsTappedState { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Tap"]; + [button sendActionsForControlEvents:UIControlEventTouchDown]; + XCTAssertTrue([self isTappedForButton:button]); + [button sendActionsForControlEvents:UIControlEventTouchCancel]; // _didDragOutside + XCTAssertFalse([self isTappedForButton:button]); +} + +- (void)testTouchDownScalesContainerThenRestoresOnTouchUp { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Tap"]; + UIView *container = [button valueForKey:@"containerView"]; + + [button sendActionsForControlEvents:UIControlEventTouchDown]; + // Container scales down to tappedButtonScale (default 0.97); .a is the x-scale factor. + XCTAssertEqualWithAccuracy(container.transform.a, button.tappedButtonScale, 0.0001); + + [button sendActionsForControlEvents:UIControlEventTouchUpInside]; + XCTAssertEqualWithAccuracy(container.transform.a, 1.0, 0.0001); +} + +- (void)testTouchDimsLabelWhenTappedTextAlphaSet { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Tap"]; + button.tappedTextAlpha = 0.4; // a value < 1 enables the alpha-dim path (1.0 disables it) + UILabel *titleLabel = [button valueForKey:@"titleLabel"]; + + [button sendActionsForControlEvents:UIControlEventTouchDown]; + XCTAssertEqualWithAccuracy(titleLabel.alpha, 0.4, 0.0001); + + [button sendActionsForControlEvents:UIControlEventTouchUpInside]; + XCTAssertEqualWithAccuracy(titleLabel.alpha, 1.0, 0.0001); +} + +- (void)testTappedBackgroundColorPathForSolidStyle { + TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Tap"]; + button.backgroundStyle = TORoundedButtonBackgroundStyleSolid; + button.tappedTintColor = [UIColor redColor]; + + // With a tintable (solid) background and an explicit tapped tint, the tap sequence + // exercises the background-color animation path in both directions. + [button sendActionsForControlEvents:UIControlEventTouchDown]; + XCTAssertTrue([self isTappedForButton:button]); + [button sendActionsForControlEvents:UIControlEventTouchUpInside]; + XCTAssertFalse([self isTappedForButton:button]); +} + @end From 5b49243a514003cb404b7d69cbdb6c9d04df1a0d Mon Sep 17 00:00:00 2001 From: Tim Oliver Date: Wed, 27 May 2026 13:42:52 +0900 Subject: [PATCH 17/18] Assert background color change in tapped solid-style test --- TORoundedButtonExampleTests/TORoundedButtonExampleTests.m | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m b/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m index fde4eda..fe49f77 100644 --- a/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m +++ b/TORoundedButtonExampleTests/TORoundedButtonExampleTests.m @@ -449,13 +449,17 @@ - (void)testTappedBackgroundColorPathForSolidStyle { TORoundedButton *button = [[TORoundedButton alloc] initWithText:@"Tap"]; button.backgroundStyle = TORoundedButtonBackgroundStyleSolid; button.tappedTintColor = [UIColor redColor]; + UIView *backgroundView = [button valueForKey:@"backgroundView"]; - // With a tintable (solid) background and an explicit tapped tint, the tap sequence - // exercises the background-color animation path in both directions. + // For a solid background the tapped tint is applied directly as the background color. [button sendActionsForControlEvents:UIControlEventTouchDown]; XCTAssertTrue([self isTappedForButton:button]); + XCTAssertEqualObjects(backgroundView.backgroundColor, [UIColor redColor]); + + // Releasing reverts the background away from the tapped tint. [button sendActionsForControlEvents:UIControlEventTouchUpInside]; XCTAssertFalse([self isTappedForButton:button]); + XCTAssertNotEqualObjects(backgroundView.backgroundColor, [UIColor redColor]); } @end From b22204b976b2e66f031b435577fd3abf75cea0f9 Mon Sep 17 00:00:00 2001 From: Tim Oliver Date: Wed, 27 May 2026 13:50:41 +0900 Subject: [PATCH 18/18] Bumped version number --- CHANGELOG.md | 3 +++ TORoundedButton.podspec | 2 +- TORoundedButtonFramework/Info.plist | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0534538..78672a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ x.y.z Release Notes (yyyy-MM-dd) ============================================================= +2.1.0 Release Notes (2026-05-27) +============================================================= + ### Added * Re-added the `minimumWidth` property, now including the horizontal `contentInset` padding, to help external layout systems size the button around its content. diff --git a/TORoundedButton.podspec b/TORoundedButton.podspec index d7c5888..d39782d 100644 --- a/TORoundedButton.podspec +++ b/TORoundedButton.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TORoundedButton' - s.version = '2.0.0' + s.version = '2.1.0' s.license = { :type => 'MIT', :file => 'LICENSE' } s.summary = 'A high-performance button control with rounded corners for iOS.' s.homepage = 'https://github.com/TimOliver/TORoundedButton' diff --git a/TORoundedButtonFramework/Info.plist b/TORoundedButtonFramework/Info.plist index bfc2cc2..4cea8ff 100644 --- a/TORoundedButtonFramework/Info.plist +++ b/TORoundedButtonFramework/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 2.0.0 + 2.1.0 CFBundleVersion $(CURRENT_PROJECT_VERSION)