diff --git a/MacDown 3000.xcodeproj/project.pbxproj b/MacDown 3000.xcodeproj/project.pbxproj index 33194fe0..6b34e727 100644 --- a/MacDown 3000.xcodeproj/project.pbxproj +++ b/MacDown 3000.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 0319BD8F4A64D9E406448C13 /* MPSyntaxHighlightingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 9C64395EB126274BB2E01E01 /* MPSyntaxHighlightingTests.m */; }; 03224E1B02E36783D5307F4A /* MPDocumentIOTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 35FB5AC5D9A67BFB58A9430F /* MPDocumentIOTests.m */; }; 1457815B1A710E4C40F07FCA /* MPPaneToggleTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 5E44CF1BD72E49A99DA6A4C1 /* MPPaneToggleTests.m */; }; + A1B2C3D4E5F67890ABCDEF12 /* MPZoomTests.m in Sources */ = {isa = PBXBuildFile; fileRef = B2C3D4E5F67890ABCDEF1234 /* MPZoomTests.m */; }; 197TESTS0B00000000197RSTB /* MPRendererStateTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 197TESTS0F00000000197RSTF /* MPRendererStateTests.m */; }; 1F002A23195B3DAE008B8D93 /* MPRenderer.m in Sources */ = {isa = PBXBuildFile; fileRef = 1F002A22195B3DAE008B8D93 /* MPRenderer.m */; }; 1F0D9D65194AC7CF008E1856 /* NSString+Lookup.m in Sources */ = {isa = PBXBuildFile; fileRef = 1F0D9D5F194AC7CF008E1856 /* NSString+Lookup.m */; }; @@ -522,6 +523,7 @@ 4DBAA63927A60EB2150642B3 /* Pods-macdown-cmd.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-macdown-cmd.debug.xcconfig"; path = "Pods/Target Support Files/Pods-macdown-cmd/Pods-macdown-cmd.debug.xcconfig"; sourceTree = ""; }; 5162FB5F85C6B4B09403BE65 /* MPHTMLResourceURLs.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MPHTMLResourceURLs.h; sourceTree = ""; }; 5E44CF1BD72E49A99DA6A4C1 /* MPPaneToggleTests.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = MPPaneToggleTests.m; sourceTree = ""; }; + B2C3D4E5F67890ABCDEF1234 /* MPZoomTests.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = MPZoomTests.m; sourceTree = ""; }; 5F23020AD4494E700E72C264 /* MPRendererTestHelpers.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MPRendererTestHelpers.h; sourceTree = ""; }; 615588251EB35DEE6204B94E /* MPResourceWatcherSet.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MPResourceWatcherSet.h; sourceTree = ""; }; 6811F39B13C649FC86C4CE5B /* MPMathJaxRenderingTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MPMathJaxRenderingTests.m; sourceTree = ""; }; @@ -991,6 +993,7 @@ QLTST00002PREF00000M /* MPQuickLookPreferencesTests.m */, QLTST00003PVCT00000M /* MPPreviewViewControllerTests.m */, 5E44CF1BD72E49A99DA6A4C1 /* MPPaneToggleTests.m */, + B2C3D4E5F67890ABCDEF1234 /* MPZoomTests.m */, ); path = MacDownTests; sourceTree = ""; @@ -1468,6 +1471,7 @@ QLBLD00005PREFTESTSRC /* MPQuickLookPreferencesTests.m in Sources */, QLBLD00006PVCTESTSRC0 /* MPPreviewViewControllerTests.m in Sources */, 1457815B1A710E4C40F07FCA /* MPPaneToggleTests.m in Sources */, + A1B2C3D4E5F67890ABCDEF12 /* MPZoomTests.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/MacDown/Code/Document/MPDocument.h b/MacDown/Code/Document/MPDocument.h index 312f5c09..1f795108 100644 --- a/MacDown/Code/Document/MPDocument.h +++ b/MacDown/Code/Document/MPDocument.h @@ -27,4 +27,8 @@ */ + (NSString *)toggleCheckboxAtIndex:(NSUInteger)index inMarkdown:(NSString *)markdown; +- (IBAction)zoomIn:(id)sender; +- (IBAction)zoomOut:(id)sender; +- (IBAction)resetZoom:(id)sender; + @end diff --git a/MacDown/Code/Document/MPDocument.m b/MacDown/Code/Document/MPDocument.m index 6b355b5d..0f325664 100644 --- a/MacDown/Code/Document/MPDocument.m +++ b/MacDown/Code/Document/MPDocument.m @@ -38,6 +38,9 @@ static NSString * const kMPDefaultAutosaveName = @"Untitled"; +static const CGFloat kMPMinZoom = 0.5; +static const CGFloat kMPMaxZoom = 3.0; + NS_INLINE NSString *MPEditorPreferenceKeyWithValueKey(NSString *key) { @@ -254,6 +257,8 @@ typedef NS_ENUM(NSUInteger, MPScrollOwner) { // Store file content in initializer until nib is loaded. @property (copy) NSString *loadedString; +@property CGFloat zoomMultiplier; + - (void)scaleWebview; - (void)syncScrollers; - (void)syncScrollersReverse; @@ -440,6 +445,7 @@ - (instancetype)init _scrollOwner = MPScrollOwnerNeither; self.previousSplitRatio = -1.0; self.lastNonCollapsedRatio = -1.0; + self.zoomMultiplier = 1.0; return self; } @@ -853,7 +859,21 @@ - (BOOL)validateUserInterfaceItem:(id)item { BOOL result = [super validateUserInterfaceItem:item]; SEL action = item.action; - if (action == @selector(toggleToolbar:)) + + // Zoom menu validation + if (action == @selector(zoomIn:)) + { + return self.zoomMultiplier < kMPMaxZoom; + } + else if (action == @selector(zoomOut:)) + { + return self.zoomMultiplier > kMPMinZoom; + } + else if (action == @selector(resetZoom:)) + { + return fabs(self.zoomMultiplier - 1.0) > 0.001; + } + else if (action == @selector(toggleToolbar:)) { NSMenuItem *it = ((NSMenuItem *)item); it.title = self.toolbarVisible ? @@ -2152,32 +2172,7 @@ - (void)setupEditor:(NSString *)changedKey || [changedKey isEqualToString:@"editorStyleName"] || [changedKey isEqualToString:@"editorLineSpacing"]) { - NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init]; - style.lineSpacing = self.preferences.editorLineSpacing; - - // Configure tab stops to match 4-space tab width (fixes #195) - NSFont *font = [self.preferences.editorBaseFont copy]; - if (font) - { - NSDictionary *attrs = @{NSFontAttributeName: font}; - CGFloat spaceWidth = [@" " sizeWithAttributes:attrs].width; - CGFloat tabInterval = spaceWidth * 4; - - NSMutableArray *tabStops = [NSMutableArray array]; - for (NSInteger i = 1; i <= 100; i++) - { - NSTextTab *tab = [[NSTextTab alloc] - initWithTextAlignment:NSTextAlignmentLeft - location:tabInterval * i - options:@{}]; - [tabStops addObject:tab]; - } - style.tabStops = tabStops; - } - - self.editor.defaultParagraphStyle = [style copy]; - if (font) - self.editor.font = font; + [self applyEditorFontAndParagraphStyle]; self.editor.textColor = nil; self.editor.backgroundColor = [NSColor clearColor]; self.highlighter.styles = nil; @@ -2315,18 +2310,24 @@ - (void)redrawDivider } } -- (void)scaleWebview +- (CGFloat)previewScale { - if (!self.preferences.previewZoomRelativeToBaseFontSize) - return; + if (self.preferences.previewZoomRelativeToBaseFontSize) + { + CGFloat fontSize = self.preferences.editorBaseFontSize; + if (fontSize > 0.0) + { + static const CGFloat defaultSize = 14.0; + return (fontSize / defaultSize) * self.zoomMultiplier; + } + } + return self.zoomMultiplier; +} - CGFloat fontSize = self.preferences.editorBaseFontSize; - if (fontSize <= 0.0) - return; +- (void)scaleWebview +{ + CGFloat scale = [self previewScale]; - static const CGFloat defaultSize = 14.0; - CGFloat scale = fontSize / defaultSize; - #if 0 // Sadly, this doesn’t work correctly. // It looks fine, but selections are offset relative to the mouse cursor. @@ -2341,6 +2342,76 @@ - (void)scaleWebview #endif } +- (NSFont *)zoomedEditorFont +{ + NSFont *baseFont = self.preferences.editorBaseFont; + if (!baseFont) + return nil; + CGFloat zoomedSize = baseFont.pointSize * self.zoomMultiplier; + return [NSFont fontWithName:baseFont.fontName size:zoomedSize]; +} + +- (void)applyEditorFontAndParagraphStyle +{ + NSFont *font = [[self zoomedEditorFont] copy]; + + NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init]; + style.lineSpacing = self.preferences.editorLineSpacing; + + // Configure tab stops to match 4-space tab width (fixes #195) + if (font) + { + NSDictionary *attrs = @{NSFontAttributeName: font}; + CGFloat spaceWidth = [@" " sizeWithAttributes:attrs].width; + CGFloat tabInterval = spaceWidth * 4; + + NSMutableArray *tabStops = [NSMutableArray array]; + for (NSInteger i = 1; i <= 100; i++) + { + NSTextTab *tab = [[NSTextTab alloc] + initWithTextAlignment:NSTextAlignmentLeft + location:tabInterval * i + options:@{}]; + [tabStops addObject:tab]; + } + style.tabStops = tabStops; + } + + self.editor.defaultParagraphStyle = [style copy]; + if (font) + self.editor.font = font; +} + +- (IBAction)zoomIn:(id)sender +{ + if (self.zoomMultiplier >= kMPMaxZoom) + return; + + self.zoomMultiplier = MIN(self.zoomMultiplier + 0.1, kMPMaxZoom); + [self applyCurrentZoom]; +} + +- (IBAction)zoomOut:(id)sender +{ + if (self.zoomMultiplier <= kMPMinZoom) + return; + + self.zoomMultiplier = MAX(self.zoomMultiplier - 0.1, kMPMinZoom); + [self applyCurrentZoom]; +} + +- (IBAction)resetZoom:(id)sender +{ + self.zoomMultiplier = 1.0; + [self applyCurrentZoom]; +} + +- (void)applyCurrentZoom +{ + [self applyEditorFontAndParagraphStyle]; + [self scaleWebview]; +} + /** * Updates cached positions of reference points (headers, standalone images) in both * editor and preview for scroll synchronization. diff --git a/MacDown/Localization/Base.lproj/MainMenu.xib b/MacDown/Localization/Base.lproj/MainMenu.xib index 57a5b077..e23d0d52 100644 --- a/MacDown/Localization/Base.lproj/MainMenu.xib +++ b/MacDown/Localization/Base.lproj/MainMenu.xib @@ -405,6 +405,23 @@ + + + + + + + + + + + + + + + + + diff --git a/MacDownTests/MPZoomTests.m b/MacDownTests/MPZoomTests.m new file mode 100644 index 00000000..d8b592dc --- /dev/null +++ b/MacDownTests/MPZoomTests.m @@ -0,0 +1,519 @@ +// +// MPZoomTests.m +// MacDownTests +// +// Tests for the per-document zoom feature in MPDocument +// (zoomIn:/zoomOut:/resetZoom: actions, zoomMultiplier property, +// and the zoom-aware font and tab-stop code paths). +// + +#import +#import "MPDocument.h" +#import "MPEditorView.h" +#import "MPPreferences.h" + +#pragma mark - Testing Category + +@interface MPDocument (ZoomTesting) +@property CGFloat zoomMultiplier; +@property (unsafe_unretained) IBOutlet MPEditorView *editor; +- (IBAction)zoomIn:(id)sender; +- (IBAction)zoomOut:(id)sender; +- (IBAction)resetZoom:(id)sender; +- (void)applyCurrentZoom; +- (void)setupEditor:(NSString *)changedKey; +- (CGFloat)previewScale; +@end + +#pragma mark - Mock Menu Item + +// Separate class from MPPaneToggleTests.m's MockMenuItem to avoid duplicate symbol. +@interface MockZoomMenuItem : NSMenuItem +@end + +@implementation MockZoomMenuItem +@end + +#pragma mark - Test Case + +@interface MPZoomTests : XCTestCase +@property (strong) MPDocument *document; +@end + +@implementation MPZoomTests + +- (void)setUp +{ + [super setUp]; + self.document = [[MPDocument alloc] init]; +} + +- (void)tearDown +{ + self.document = nil; + [super tearDown]; +} + + +#pragma mark - Zoom Multiplier Basics + +/** + * A new document should start with zoomMultiplier of 1.0. + */ +- (void)testZoomMultiplierDefaultsToOne +{ + XCTAssertEqualWithAccuracy(self.document.zoomMultiplier, 1.0, 0.001, + @"New document should default to zoom multiplier of 1.0"); +} + +/** + * After calling zoomIn:nil the multiplier should increase by 0.1 (to 1.1). + */ +- (void)testZoomInIncrementsMultiplier +{ + [self.document zoomIn:nil]; + XCTAssertEqualWithAccuracy(self.document.zoomMultiplier, 1.1, 0.001, + @"zoomIn: should increment multiplier by 0.1"); +} + +/** + * After calling zoomOut:nil the multiplier should decrease by 0.1 (to 0.9). + */ +- (void)testZoomOutDecrementsMultiplier +{ + [self.document zoomOut:nil]; + XCTAssertEqualWithAccuracy(self.document.zoomMultiplier, 0.9, 0.001, + @"zoomOut: should decrement multiplier by 0.1"); +} + +/** + * Setting multiplier to 2.0 then calling resetZoom:nil should restore 1.0. + */ +- (void)testResetZoomSetsMultiplierToOne +{ + self.document.zoomMultiplier = 2.0; + [self.document resetZoom:nil]; + XCTAssertEqualWithAccuracy(self.document.zoomMultiplier, 1.0, 0.001, + @"resetZoom: should restore multiplier to 1.0"); +} + +/** + * When already at kMPMaxZoom (3.0), zoomIn: should be a no-op. + */ +- (void)testZoomInAtMaxZoomIsNoOp +{ + self.document.zoomMultiplier = 3.0; + [self.document zoomIn:nil]; + XCTAssertEqualWithAccuracy(self.document.zoomMultiplier, 3.0, 0.001, + @"zoomIn: at maximum zoom should not increase multiplier"); +} + +/** + * When already at kMPMinZoom (0.5), zoomOut: should be a no-op. + */ +- (void)testZoomOutAtMinZoomIsNoOp +{ + self.document.zoomMultiplier = 0.5; + [self.document zoomOut:nil]; + XCTAssertEqualWithAccuracy(self.document.zoomMultiplier, 0.5, 0.001, + @"zoomOut: at minimum zoom should not decrease multiplier"); +} + +/** + * 25 zoomIn, then 50 zoomOut, then 25 zoomIn must keep multiplier within [0.5, 3.0]. + */ +- (void)testRapidZoomInOut +{ + for (int i = 0; i < 25; i++) + [self.document zoomIn:nil]; + for (int i = 0; i < 50; i++) + [self.document zoomOut:nil]; + for (int i = 0; i < 25; i++) + [self.document zoomIn:nil]; + + CGFloat m = self.document.zoomMultiplier; + XCTAssertGreaterThanOrEqual(m, 0.5 - 0.001, + @"Rapid zoom sequence must not go below minimum (0.5)"); + XCTAssertLessThanOrEqual(m, 3.0 + 0.001, + @"Rapid zoom sequence must not exceed maximum (3.0)"); +} + + +#pragma mark - Menu Validation + +/** + * validateUserInterfaceItem: should return YES for zoomIn: at default zoom (1.0). + */ +- (void)testZoomInMenuValidationEnabledAtDefault +{ + MockZoomMenuItem *item = [[MockZoomMenuItem alloc] initWithTitle:@"Zoom In" + action:@selector(zoomIn:) + keyEquivalent:@""]; + BOOL result = [self.document validateUserInterfaceItem:item]; + XCTAssertTrue(result, @"Zoom In should be enabled at default zoom (1.0)"); +} + +/** + * validateUserInterfaceItem: should return YES for zoomOut: at default zoom (1.0). + */ +- (void)testZoomOutMenuValidationEnabledAtDefault +{ + MockZoomMenuItem *item = [[MockZoomMenuItem alloc] initWithTitle:@"Zoom Out" + action:@selector(zoomOut:) + keyEquivalent:@""]; + BOOL result = [self.document validateUserInterfaceItem:item]; + XCTAssertTrue(result, @"Zoom Out should be enabled at default zoom (1.0)"); +} + +/** + * validateUserInterfaceItem: should return NO for resetZoom: at default zoom (1.0), + * because there is nothing to reset. + */ +- (void)testResetZoomMenuValidationDisabledAtDefault +{ + MockZoomMenuItem *item = [[MockZoomMenuItem alloc] initWithTitle:@"Reset Zoom" + action:@selector(resetZoom:) + keyEquivalent:@""]; + BOOL result = [self.document validateUserInterfaceItem:item]; + XCTAssertFalse(result, @"Reset Zoom should be disabled when multiplier is already 1.0"); +} + +/** + * validateUserInterfaceItem: should return YES for resetZoom: when multiplier != 1.0. + */ +- (void)testResetZoomMenuValidationEnabledWhenZoomed +{ + self.document.zoomMultiplier = 1.5; + MockZoomMenuItem *item = [[MockZoomMenuItem alloc] initWithTitle:@"Reset Zoom" + action:@selector(resetZoom:) + keyEquivalent:@""]; + BOOL result = [self.document validateUserInterfaceItem:item]; + XCTAssertTrue(result, @"Reset Zoom should be enabled when multiplier is not 1.0"); +} + +/** + * validateUserInterfaceItem: should return NO for zoomIn: when at maximum zoom (3.0). + */ +- (void)testZoomInMenuValidationDisabledAtMaxZoom +{ + self.document.zoomMultiplier = 3.0; + MockZoomMenuItem *item = [[MockZoomMenuItem alloc] initWithTitle:@"Zoom In" + action:@selector(zoomIn:) + keyEquivalent:@""]; + BOOL result = [self.document validateUserInterfaceItem:item]; + XCTAssertFalse(result, @"Zoom In should be disabled at maximum zoom (3.0)"); +} + +/** + * validateUserInterfaceItem: should return NO for zoomOut: when at minimum zoom (0.5). + */ +- (void)testZoomOutMenuValidationDisabledAtMinZoom +{ + self.document.zoomMultiplier = 0.5; + MockZoomMenuItem *item = [[MockZoomMenuItem alloc] initWithTitle:@"Zoom Out" + action:@selector(zoomOut:) + keyEquivalent:@""]; + BOOL result = [self.document validateUserInterfaceItem:item]; + XCTAssertFalse(result, @"Zoom Out should be disabled at minimum zoom (0.5)"); +} + + +#pragma mark - Preference Observer Tests + +/** + * Calling setupEditor: while zoomed should not crash. + * nil changedKey exercises the full setup path. + */ +- (void)testSetupEditorDoesNotCrashWhileZoomed +{ + self.document.zoomMultiplier = 2.0; + XCTAssertNoThrow([self.document setupEditor:nil], + @"setupEditor:nil should not crash when zoomed to 2.0"); +} + +/** + * Calling setupEditor: for a line-spacing change while zoomed should not crash. + */ +- (void)testSetupEditorDoesNotCrashForLineSpacingChangeWhileZoomed +{ + self.document.zoomMultiplier = 1.3; + XCTAssertNoThrow([self.document setupEditor:@"editorLineSpacing"], + @"setupEditor:editorLineSpacing should not crash when zoomed"); +} + +/** + * Calling setupEditor: for a style change while zoomed should not crash. + */ +- (void)testSetupEditorDoesNotCrashForStyleChangeWhileZoomed +{ + self.document.zoomMultiplier = 1.3; + XCTAssertNoThrow([self.document setupEditor:@"editorStyleName"], + @"setupEditor:editorStyleName should not crash when zoomed"); +} + +/** + * After zooming to 1.5 and calling applyCurrentZoom, a subsequent + * setupEditor:editorBaseFontInfo must not revert the editor font to the + * unzoomed base size. + * + * Skips in headless environments where the editor outlet is nil. + */ +- (void)testSetupEditorPreservesZoomedFontSize +{ + [self.document makeWindowControllers]; + + if (!self.document.editor) { + NSLog(@"Skipping testSetupEditorPreservesZoomedFontSize - editor outlet is nil (headless)"); + return; + } + + // Zoom to 1.5x and apply. + self.document.zoomMultiplier = 1.5; + [self.document applyCurrentZoom]; + + CGFloat zoomedPointSize = self.document.editor.font.pointSize; + + // Simulate a preference change that triggers font re-application. + [self.document setupEditor:@"editorBaseFontInfo"]; + + CGFloat afterSetupPointSize = self.document.editor.font.pointSize; + + XCTAssertEqualWithAccuracy(afterSetupPointSize, zoomedPointSize, 0.1, + @"setupEditor: must not revert the editor font to " + @"the unzoomed base size after applyCurrentZoom has run"); +} + +/** + * After zooming, a preference change (setupEditor:), and another zoomIn:, + * the multiplier should reflect both the original zoom and the new step. + * This exercises the multiplier state across a full zoom -> preference -> zoom cycle. + */ +- (void)testZoomThenPreferenceChangeThenZoomAgain +{ + self.document.zoomMultiplier = 1.5; + XCTAssertNoThrow([self.document setupEditor:@"editorBaseFontInfo"], + @"setupEditor: should not throw while zoomed"); + + [self.document zoomIn:nil]; + + XCTAssertEqualWithAccuracy(self.document.zoomMultiplier, 1.6, 0.001, + @"After zoom(1.5) -> setupEditor -> zoomIn, " + @"multiplier should be 1.6"); +} + + +#pragma mark - Tab Stop Calculation Tests + +/** + * Pure math test: a font at 2x size must produce a wider space character and + * therefore a wider tab interval than the same font at base size. + * This test does not require a window controller and always runs. + */ +- (void)testZoomedFontProducesDifferentTabWidth +{ + NSFont *baseFont = [MPPreferences sharedInstance].editorBaseFont; + XCTAssertNotNil(baseFont, @"editorBaseFont must not be nil"); + + CGFloat baseSize = baseFont.pointSize; + CGFloat zoomedSize = baseSize * 2.0; + + NSFont *zoomedFont = [NSFont fontWithName:baseFont.fontName size:zoomedSize]; + XCTAssertNotNil(zoomedFont, @"Could not create zoomed font"); + + NSDictionary *baseAttrs = @{NSFontAttributeName: baseFont}; + NSDictionary *zoomedAttrs = @{NSFontAttributeName: zoomedFont}; + + CGFloat baseSpaceWidth = [@" " sizeWithAttributes:baseAttrs].width; + CGFloat zoomedSpaceWidth = [@" " sizeWithAttributes:zoomedAttrs].width; + + XCTAssertGreaterThan(zoomedSpaceWidth, baseSpaceWidth, + @"Space character must be wider in a larger font"); + + CGFloat baseTabInterval = baseSpaceWidth * 4.0; + CGFloat zoomedTabInterval = zoomedSpaceWidth * 4.0; + + XCTAssertGreaterThan(zoomedTabInterval, baseTabInterval, + @"Tab interval (4 spaces) must be wider at 2x zoom than at base size"); +} + +/** + * After zooming to 2.0 and applying, the first tab stop should match the + * tab interval computed from the zoomed font, not the base font. + * + * Skips in headless environments where the editor outlet is nil. + */ +- (void)testTabStopsReflectZoomedFontSize +{ + [self.document makeWindowControllers]; + + if (!self.document.editor) { + NSLog(@"Skipping testTabStopsReflectZoomedFontSize - editor outlet is nil (headless)"); + return; + } + + if (self.document.editor.defaultParagraphStyle.tabStops.count == 0) { + NSLog(@"Skipping testTabStopsReflectZoomedFontSize - no tab stops (headless)"); + return; + } + + // Zoom to 2.0 and apply. + self.document.zoomMultiplier = 2.0; + [self.document applyCurrentZoom]; + + // Trigger the code path that recomputes tab stops. + [self.document setupEditor:@"editorBaseFontInfo"]; + + // Compute the expected tab interval from the zoomed font. + NSFont *baseFont = [MPPreferences sharedInstance].editorBaseFont; + CGFloat zoomedSize = baseFont.pointSize * 2.0; + NSFont *zoomedFont = [NSFont fontWithName:baseFont.fontName size:zoomedSize]; + NSDictionary *attrs = @{NSFontAttributeName: zoomedFont}; + CGFloat spaceWidth = [@" " sizeWithAttributes:attrs].width; + CGFloat expectedTabInterval = spaceWidth * 4.0; + + NSArray *tabStops = self.document.editor.defaultParagraphStyle.tabStops; + XCTAssertGreaterThan(tabStops.count, 0U, @"There should be at least one tab stop"); + + NSTextTab *firstTab = tabStops[0]; + + XCTAssertEqualWithAccuracy(firstTab.location, expectedTabInterval, 0.5, + @"First tab stop must reflect zoomed font size, " + @"not the unzoomed base font size"); +} + +/** + * CONDITIONAL test: at default zoom (1.0), tab stops should match the interval + * computed from the base font. This is a green test that verifies the baseline + * behaviour is correct before any zoom is applied. + * + * Skips in headless environments where the editor outlet is nil. + */ +- (void)testTabStopsAtDefaultZoomMatchBaseFont +{ + [self.document makeWindowControllers]; + + if (!self.document.editor) { + NSLog(@"Skipping testTabStopsAtDefaultZoomMatchBaseFont - editor outlet is nil (headless)"); + return; + } + + if (self.document.editor.defaultParagraphStyle.tabStops.count == 0) { + NSLog(@"Skipping testTabStopsAtDefaultZoomMatchBaseFont - no tab stops (headless)"); + return; + } + + // Ensure default zoom. + self.document.zoomMultiplier = 1.0; + [self.document setupEditor:@"editorBaseFontInfo"]; + + NSFont *baseFont = [MPPreferences sharedInstance].editorBaseFont; + NSDictionary *attrs = @{NSFontAttributeName: baseFont}; + CGFloat spaceWidth = [@" " sizeWithAttributes:attrs].width; + CGFloat expectedTabInterval = spaceWidth * 4.0; + + NSArray *tabStops = self.document.editor.defaultParagraphStyle.tabStops; + XCTAssertGreaterThan(tabStops.count, 0U, @"There should be at least one tab stop"); + + NSTextTab *firstTab = tabStops[0]; + XCTAssertEqualWithAccuracy(firstTab.location, expectedTabInterval, 0.5, + @"At default zoom, first tab stop should match " + @"the base-font tab interval"); +} + + +#pragma mark - Preview Scale Calculation Tests + +/** + * scaleWebview routes through previewScale. These tests pin the scale + * computation across both branches of the previewZoomRelativeToBaseFontSize + * preference, including the regression risk introduced by removing the + * old early-return when the preference is OFF. + * + * Each test saves and restores the preference values it touches so the + * shared MPPreferences instance is not mutated across tests. + */ + +- (void)testPreviewScaleAtDefaultZoomWhenPreferenceOffIsOne +{ + MPPreferences *prefs = [MPPreferences sharedInstance]; + BOOL savedPref = prefs.previewZoomRelativeToBaseFontSize; + @try { + prefs.previewZoomRelativeToBaseFontSize = NO; + self.document.zoomMultiplier = 1.0; + + XCTAssertEqualWithAccuracy([self.document previewScale], 1.0, 0.001, + @"At default zoom with preference OFF, " + @"previewScale must be 1.0 (no-op)."); + } @finally { + prefs.previewZoomRelativeToBaseFontSize = savedPref; + } +} + +- (void)testPreviewScaleTracksZoomMultiplierWhenPreferenceOff +{ + MPPreferences *prefs = [MPPreferences sharedInstance]; + BOOL savedPref = prefs.previewZoomRelativeToBaseFontSize; + @try { + prefs.previewZoomRelativeToBaseFontSize = NO; + self.document.zoomMultiplier = 1.5; + + XCTAssertEqualWithAccuracy([self.document previewScale], 1.5, 0.001, + @"With preference OFF, previewScale should " + @"equal zoomMultiplier (1.5)."); + + self.document.zoomMultiplier = 0.5; + XCTAssertEqualWithAccuracy([self.document previewScale], 0.5, 0.001, + @"With preference OFF, previewScale should " + @"track zoomMultiplier across changes."); + } @finally { + prefs.previewZoomRelativeToBaseFontSize = savedPref; + } +} + +- (void)testPreviewScaleCombinesFontRatioAndZoomWhenPreferenceOn +{ + MPPreferences *prefs = [MPPreferences sharedInstance]; + BOOL savedPref = prefs.previewZoomRelativeToBaseFontSize; + NSFont *savedFont = prefs.editorBaseFont; + @try { + prefs.previewZoomRelativeToBaseFontSize = YES; + + NSFont *font21 = [NSFont fontWithName:savedFont.fontName size:21.0]; + if (!font21) { + NSLog(@"Skipping testPreviewScaleCombinesFontRatioAndZoomWhenPreferenceOn" + @" - cannot construct 21pt variant of base font."); + return; + } + prefs.editorBaseFont = font21; + + self.document.zoomMultiplier = 2.0; + + // 21pt / 14pt default = 1.5; combined with 2.0 zoom = 3.0. + XCTAssertEqualWithAccuracy([self.document previewScale], 3.0, 0.01, + @"With preference ON, previewScale should " + @"be (fontSize/14) * zoomMultiplier."); + } @finally { + prefs.editorBaseFont = savedFont; + prefs.previewZoomRelativeToBaseFontSize = savedPref; + } +} + +- (void)testPreviewScaleAtDefaultZoomWhenPreferenceOnMatchesFontRatio +{ + MPPreferences *prefs = [MPPreferences sharedInstance]; + BOOL savedPref = prefs.previewZoomRelativeToBaseFontSize; + @try { + prefs.previewZoomRelativeToBaseFontSize = YES; + self.document.zoomMultiplier = 1.0; + + // At zoom 1.0, scale should match the legacy fontSize/14 behaviour. + CGFloat expected = prefs.editorBaseFontSize / 14.0; + XCTAssertEqualWithAccuracy([self.document previewScale], expected, 0.001, + @"At zoom 1.0 with preference ON, previewScale" + @" must equal the pre-PR fontSize/14 ratio."); + } @finally { + prefs.previewZoomRelativeToBaseFontSize = savedPref; + } +} + +@end