/*
* SPDX-License-Identifier: GPL-3.0-only
* MuseScore-CLA-applies
*
* MuseScore Studio
* Music Composition & Notation
*
* Copyright (C) 2026 MuseScore Limited and others
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 3 as
* published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
#include
#import
#include "interactive/internal/platform/macos/macosinteractivehelper.h"
@interface MockMenuTarget : NSObject
- (void)triggerAction:(id)sender;
- (void)fileSaveAction:(id)sender;
@end
@implementation MockMenuTarget
- (void)triggerAction:(id)sender {}
- (void)fileSaveAction:(id)sender {}
@end
class MacOSInteractiveHelperTest : public ::testing::Test
{
protected:
void SetUp() override
{
m_pool = [[NSAutoreleasePool alloc] init];
NSApplication* app = [NSApplication sharedApplication];
if (!app) {
GTEST_SKIP() << "No macOS GUI / NSApplication session available";
}
m_mockTarget = [[MockMenuTarget alloc] init];
m_mainMenu = [[NSMenu alloc] initWithTitle:@"MainMenu"];
[NSApp setMainMenu:m_mainMenu];
// File Menu (non-edit menu)
NSMenuItem* fileTop = [[NSMenuItem alloc] initWithTitle:@"File" action:nil keyEquivalent:@""];
NSMenu* fileSub = [[NSMenu alloc] initWithTitle:@"File"];
m_saveItem = [[NSMenuItem alloc] initWithTitle:@"Save" action:@selector(fileSaveAction:) keyEquivalent:@"s"];
[m_saveItem setKeyEquivalentModifierMask:NSEventModifierFlagCommand];
[m_saveItem setTarget:m_mockTarget];
[fileSub addItem:m_saveItem];
[fileTop setSubmenu:fileSub];
[m_mainMenu addItem:fileTop];
// Edit Menu
NSMenuItem* editTop = [[NSMenuItem alloc] initWithTitle:@"Edit" action:nil keyEquivalent:@""];
NSMenu* editSub = [[NSMenu alloc] initWithTitle:@"Edit"];
m_undoItem = [[NSMenuItem alloc] initWithTitle:@"Undo" action:@selector(triggerAction:) keyEquivalent:@"z"];
[m_undoItem setKeyEquivalentModifierMask:NSEventModifierFlagCommand];
[m_undoItem setTarget:m_mockTarget];
[editSub addItem:m_undoItem];
m_redoItem = [[NSMenuItem alloc] initWithTitle:@"Redo" action:@selector(triggerAction:) keyEquivalent:@"Z"];
[m_redoItem setKeyEquivalentModifierMask:(NSEventModifierFlagCommand | NSEventModifierFlagShift)];
[m_redoItem setTarget:m_mockTarget];
[editSub addItem:m_redoItem];
m_cutItem = [[NSMenuItem alloc] initWithTitle:@"Cut" action:@selector(triggerAction:) keyEquivalent:@"x"];
[m_cutItem setKeyEquivalentModifierMask:NSEventModifierFlagCommand];
[m_cutItem setTarget:m_mockTarget];
[editSub addItem:m_cutItem];
m_copyItem = [[NSMenuItem alloc] initWithTitle:@"Copy" action:@selector(triggerAction:) keyEquivalent:@"c"];
[m_copyItem setKeyEquivalentModifierMask:NSEventModifierFlagCommand];
[m_copyItem setTarget:m_mockTarget];
[editSub addItem:m_copyItem];
m_pasteItem = [[NSMenuItem alloc] initWithTitle:@"Paste" action:@selector(triggerAction:) keyEquivalent:@"v"];
[m_pasteItem setKeyEquivalentModifierMask:NSEventModifierFlagCommand];
[m_pasteItem setTarget:m_mockTarget];
[editSub addItem:m_pasteItem];
m_selectAllItem = [[NSMenuItem alloc] initWithTitle:@"Select All" action:@selector(triggerAction:) keyEquivalent:@"a"];
[m_selectAllItem setKeyEquivalentModifierMask:NSEventModifierFlagCommand];
[m_selectAllItem setTarget:m_mockTarget];
[editSub addItem:m_selectAllItem];
[editTop setSubmenu:editSub];
[m_mainMenu addItem:editTop];
std::map defaultStructure = {
{ muse::MacOSInteractiveHelper::EditAction::Undo, 0 },
{ muse::MacOSInteractiveHelper::EditAction::Redo, 1 },
{ muse::MacOSInteractiveHelper::EditAction::Cut, 2 },
{ muse::MacOSInteractiveHelper::EditAction::Copy, 3 },
{ muse::MacOSInteractiveHelper::EditAction::Paste, 4 },
{ muse::MacOSInteractiveHelper::EditAction::SelectAll, 5 },
};
muse::MacOSInteractiveHelper::setEditMenuIndex(1);
muse::MacOSInteractiveHelper::setEditMenuStructure(defaultStructure);
}
void TearDown() override
{
muse::MacOSInteractiveHelper::setEditMenuIndex(-1);
muse::MacOSInteractiveHelper::setEditMenuStructure({});
[NSApp setMainMenu:nil];
[m_pool drain];
}
NSAutoreleasePool* m_pool = nil;
MockMenuTarget* m_mockTarget = nil;
NSMenu* m_mainMenu = nil;
NSMenuItem* m_saveItem = nil;
NSMenuItem* m_undoItem = nil;
NSMenuItem* m_redoItem = nil;
NSMenuItem* m_cutItem = nil;
NSMenuItem* m_copyItem = nil;
NSMenuItem* m_pasteItem = nil;
NSMenuItem* m_selectAllItem = nil;
};
TEST_F(MacOSInteractiveHelperTest, NativeDialogScope_SetsNilTargetAndStandardActions)
{
// Before scope: All edit items target the mock object with triggerAction:
EXPECT_EQ([m_undoItem target], m_mockTarget);
EXPECT_EQ([m_undoItem action], @selector(triggerAction:));
{
muse::MacOSInteractiveHelper::NativeDialogScope scope;
// Inside scope: Edit items must target First Responder (nil) with standard selectors and key equivalents
EXPECT_EQ([m_undoItem target], nil);
EXPECT_EQ([m_undoItem action], @selector(undo:));
EXPECT_STREQ([[m_undoItem keyEquivalent] UTF8String], "z");
EXPECT_TRUE([m_undoItem isEnabled]);
EXPECT_EQ([m_redoItem target], nil);
EXPECT_EQ([m_redoItem action], @selector(redo:));
EXPECT_STREQ([[m_redoItem keyEquivalent] UTF8String], "Z");
EXPECT_TRUE([m_redoItem isEnabled]);
EXPECT_EQ([m_cutItem target], nil);
EXPECT_EQ([m_cutItem action], @selector(cut:));
EXPECT_STREQ([[m_cutItem keyEquivalent] UTF8String], "x");
EXPECT_TRUE([m_cutItem isEnabled]);
EXPECT_EQ([m_copyItem target], nil);
EXPECT_EQ([m_copyItem action], @selector(copy:));
EXPECT_STREQ([[m_copyItem keyEquivalent] UTF8String], "c");
EXPECT_TRUE([m_copyItem isEnabled]);
EXPECT_EQ([m_pasteItem target], nil);
EXPECT_EQ([m_pasteItem action], @selector(paste:));
EXPECT_STREQ([[m_pasteItem keyEquivalent] UTF8String], "v");
EXPECT_TRUE([m_pasteItem isEnabled]);
EXPECT_EQ([m_selectAllItem target], nil);
EXPECT_EQ([m_selectAllItem action], @selector(selectAll:));
EXPECT_STREQ([[m_selectAllItem keyEquivalent] UTF8String], "a");
EXPECT_TRUE([m_selectAllItem isEnabled]);
}
}
TEST_F(MacOSInteractiveHelperTest, NativeDialogScope_RestoresOriginalTargetsAndActions)
{
{
muse::MacOSInteractiveHelper::NativeDialogScope scope;
EXPECT_EQ([m_undoItem target], nil);
}
// After scope exits: All items must be restored to original target, action, and keyEquivalent
EXPECT_EQ([m_undoItem target], m_mockTarget);
EXPECT_EQ([m_undoItem action], @selector(triggerAction:));
EXPECT_STREQ([[m_undoItem keyEquivalent] UTF8String], "z");
EXPECT_EQ([m_redoItem target], m_mockTarget);
EXPECT_EQ([m_redoItem action], @selector(triggerAction:));
EXPECT_STREQ([[m_redoItem keyEquivalent] UTF8String], "Z");
EXPECT_EQ([m_cutItem target], m_mockTarget);
EXPECT_EQ([m_cutItem action], @selector(triggerAction:));
EXPECT_STREQ([[m_cutItem keyEquivalent] UTF8String], "x");
EXPECT_EQ([m_copyItem target], m_mockTarget);
EXPECT_EQ([m_copyItem action], @selector(triggerAction:));
EXPECT_STREQ([[m_copyItem keyEquivalent] UTF8String], "c");
EXPECT_EQ([m_pasteItem target], m_mockTarget);
EXPECT_EQ([m_pasteItem action], @selector(triggerAction:));
EXPECT_STREQ([[m_pasteItem keyEquivalent] UTF8String], "v");
EXPECT_EQ([m_selectAllItem target], m_mockTarget);
EXPECT_EQ([m_selectAllItem action], @selector(triggerAction:));
EXPECT_STREQ([[m_selectAllItem keyEquivalent] UTF8String], "a");
}
TEST_F(MacOSInteractiveHelperTest, NativeDialogScope_HandlesNestedScopes)
{
{
muse::MacOSInteractiveHelper::NativeDialogScope outerScope;
EXPECT_EQ([m_pasteItem target], nil);
{
muse::MacOSInteractiveHelper::NativeDialogScope innerScope;
EXPECT_EQ([m_pasteItem target], nil);
}
// Still in outer scope
EXPECT_EQ([m_pasteItem target], nil);
EXPECT_EQ([m_pasteItem action], @selector(paste:));
}
// Outermost scope exited -> restored
EXPECT_EQ([m_pasteItem target], m_mockTarget);
EXPECT_EQ([m_pasteItem action], @selector(triggerAction:));
}
TEST_F(MacOSInteractiveHelperTest, NativeDialogScope_PreservesNonEditItems)
{
{
muse::MacOSInteractiveHelper::NativeDialogScope scope;
// Non-edit items (such as Save) must remain completely untouched
EXPECT_EQ([m_saveItem target], m_mockTarget);
EXPECT_EQ([m_saveItem action], @selector(fileSaveAction:));
}
EXPECT_EQ([m_saveItem target], m_mockTarget);
EXPECT_EQ([m_saveItem action], @selector(fileSaveAction:));
}
TEST_F(MacOSInteractiveHelperTest, NativeDialogScope_HandlesEmptyKeyEquivalentMenuItems)
{
// Simulate main branch where Qt constructs menu items without shortcuts (keyEquivalent is empty)
NSMenuItem* editTop = [m_mainMenu itemArray][1];
NSMenu* editSub = [editTop submenu];
[editSub removeAllItems];
std::map structure = {
{ muse::MacOSInteractiveHelper::EditAction::Undo, 0 },
{ muse::MacOSInteractiveHelper::EditAction::Redo, 1 },
{ muse::MacOSInteractiveHelper::EditAction::Paste, 2 },
};
muse::MacOSInteractiveHelper::setEditMenuStructure(structure);
NSMenuItem* undo = [[NSMenuItem alloc] initWithTitle:@"Undo\t" action:@selector(triggerAction:) keyEquivalent:@""];
[undo setTarget:m_mockTarget];
[editSub addItem:undo];
NSMenuItem* redo = [[NSMenuItem alloc] initWithTitle:@"Redo\t" action:@selector(triggerAction:) keyEquivalent:@""];
[redo setTarget:m_mockTarget];
[editSub addItem:redo];
NSMenuItem* paste = [[NSMenuItem alloc] initWithTitle:@"&Paste\t" action:@selector(triggerAction:) keyEquivalent:@""];
[paste setTarget:m_mockTarget];
[editSub addItem:paste];
{
muse::MacOSInteractiveHelper::NativeDialogScope scope;
EXPECT_EQ([undo target], nil);
EXPECT_EQ([undo action], @selector(undo:));
EXPECT_STREQ([[undo keyEquivalent] UTF8String], "z");
EXPECT_TRUE([undo isEnabled]);
EXPECT_EQ([redo target], nil);
EXPECT_EQ([redo action], @selector(redo:));
EXPECT_STREQ([[redo keyEquivalent] UTF8String], "Z");
EXPECT_TRUE([redo isEnabled]);
EXPECT_EQ([paste target], nil);
EXPECT_EQ([paste action], @selector(paste:));
EXPECT_STREQ([[paste keyEquivalent] UTF8String], "v");
EXPECT_TRUE([paste isEnabled]);
}
// After scope: original empty keyEquivalent and target restored
EXPECT_EQ([undo target], m_mockTarget);
EXPECT_STREQ([[undo keyEquivalent] UTF8String], "");
EXPECT_EQ([redo target], m_mockTarget);
EXPECT_STREQ([[redo keyEquivalent] UTF8String], "");
EXPECT_EQ([paste target], m_mockTarget);
EXPECT_STREQ([[paste keyEquivalent] UTF8String], "");
}
TEST_F(MacOSInteractiveHelperTest, NativeDialogScope_HandlesUppercaseZRedoWithoutShift)
{
// AppKit uppercase "Z" implies Shift even if modifierMask is only Command
NSMenuItem* editTop = [m_mainMenu itemArray][1];
NSMenu* editSub = [editTop submenu];
[editSub removeAllItems];
std::map structure = {
{ muse::MacOSInteractiveHelper::EditAction::Redo, 0 },
};
muse::MacOSInteractiveHelper::setEditMenuStructure(structure);
NSMenuItem* redoItem = [[NSMenuItem alloc] initWithTitle:@"Redo" action:@selector(triggerAction:) keyEquivalent:@"Z"];
[redoItem setKeyEquivalentModifierMask:NSEventModifierFlagCommand]; // no explicit Shift flag
[redoItem setTarget:m_mockTarget];
[editSub addItem:redoItem];
{
muse::MacOSInteractiveHelper::NativeDialogScope scope;
EXPECT_EQ([redoItem target], nil);
EXPECT_EQ([redoItem action], @selector(redo:));
EXPECT_STREQ([[redoItem keyEquivalent] UTF8String], "Z");
EXPECT_TRUE([redoItem isEnabled]);
}
EXPECT_EQ([redoItem target], m_mockTarget);
EXPECT_EQ([redoItem action], @selector(triggerAction:));
EXPECT_STREQ([[redoItem keyEquivalent] UTF8String], "Z");
}
TEST_F(MacOSInteractiveHelperTest, NativeDialogScope_PreservesPasteSpecialWithDifferentModifier)
{
NSMenuItem* editTop = [m_mainMenu itemArray][1];
NSMenu* editSub = [editTop submenu];
[editSub removeAllItems];
std::map structure = {
{ muse::MacOSInteractiveHelper::EditAction::Paste, 0 },
};
muse::MacOSInteractiveHelper::setEditMenuStructure(structure);
NSMenuItem* normalPaste = [[NSMenuItem alloc] initWithTitle:@"&Paste\t" action:@selector(triggerAction:) keyEquivalent:@""];
[normalPaste setTarget:m_mockTarget];
[editSub addItem:normalPaste];
NSMenuItem* specialPaste = [[NSMenuItem alloc] initWithTitle:@"Paste Special\t" action:@selector(triggerAction:) keyEquivalent:@"v"];
[specialPaste setKeyEquivalentModifierMask:(NSEventModifierFlagCommand | NSEventModifierFlagOption)];
[specialPaste setTarget:m_mockTarget];
[editSub addItem:specialPaste];
{
muse::MacOSInteractiveHelper::NativeDialogScope scope;
EXPECT_EQ([normalPaste target], nil);
EXPECT_EQ([normalPaste action], @selector(paste:));
EXPECT_STREQ([[normalPaste keyEquivalent] UTF8String], "v");
EXPECT_EQ([normalPaste keyEquivalentModifierMask], NSEventModifierFlagCommand);
EXPECT_TRUE([normalPaste isEnabled]);
// Paste Special must remain completely untouched
EXPECT_EQ([specialPaste target], m_mockTarget);
EXPECT_EQ([specialPaste action], @selector(triggerAction:));
EXPECT_STREQ([[specialPaste keyEquivalent] UTF8String], "v");
EXPECT_EQ([specialPaste keyEquivalentModifierMask], (NSEventModifierFlagCommand | NSEventModifierFlagOption));
}
EXPECT_EQ([normalPaste target], m_mockTarget);
EXPECT_STREQ([[normalPaste keyEquivalent] UTF8String], "");
EXPECT_EQ([specialPaste target], m_mockTarget);
EXPECT_EQ([specialPaste keyEquivalentModifierMask], (NSEventModifierFlagCommand | NSEventModifierFlagOption));
}
TEST_F(MacOSInteractiveHelperTest, NativeDialogScope_CreatesAndRemovesFallbackItemsWhenActionsMissing)
{
// Configure menu with only Undo (index 0) provided. Redo, Cut, Copy, Paste, Select All are missing.
std::map structure = {
{ muse::MacOSInteractiveHelper::EditAction::Undo, 0 },
};
muse::MacOSInteractiveHelper::setEditMenuStructure(structure);
muse::MacOSInteractiveHelper::setEditMenuIndex(1);
NSMenuItem* editTop = [m_mainMenu itemArray][1];
NSMenu* editSub = [editTop submenu];
[editSub removeAllItems];
NSMenuItem* undo = [[NSMenuItem alloc] initWithTitle:@"Undo" action:@selector(triggerAction:) keyEquivalent:@"z"];
[undo setTarget:m_mockTarget];
[editSub addItem:undo];
NSUInteger initialCount = [[editSub itemArray] count]; // 1 item (Undo)
{
muse::MacOSInteractiveHelper::NativeDialogScope scope;
// 5 fallback items should be created for missing actions (Redo, Cut, Copy, Paste, Select All)
EXPECT_EQ([[editSub itemArray] count], initialCount + 5);
// Verify fallback items target First Responder, are enabled, and hidden from view
for (NSUInteger i = initialCount; i < [[editSub itemArray] count]; ++i) {
NSMenuItem* fallback = [editSub itemArray][i];
EXPECT_EQ([fallback target], nil);
EXPECT_TRUE([fallback isEnabled]);
EXPECT_TRUE([fallback isHidden]);
}
}
// On scope exit, all 5 fallback items must be removed cleanly
EXPECT_EQ([[editSub itemArray] count], initialCount);
EXPECT_EQ([undo target], m_mockTarget);
}
TEST_F(MacOSInteractiveHelperTest, NativeDialogScope_MatchesCustomStructureAndCreatesZeroTemporaryItems)
{
std::map structure = {
{ muse::MacOSInteractiveHelper::EditAction::Undo, 0 },
{ muse::MacOSInteractiveHelper::EditAction::Redo, 1 },
{ muse::MacOSInteractiveHelper::EditAction::Cut, 4 },
{ muse::MacOSInteractiveHelper::EditAction::Copy, 5 },
{ muse::MacOSInteractiveHelper::EditAction::Paste, 6 },
{ muse::MacOSInteractiveHelper::EditAction::SelectAll, 8 },
};
muse::MacOSInteractiveHelper::setEditMenuStructure(structure);
muse::MacOSInteractiveHelper::setEditMenuIndex(2);
[m_mainMenu removeAllItems];
NSMenuItem* appItem = [[NSMenuItem alloc] initWithTitle:@"MuseScore Studio" action:nil keyEquivalent:@""];
[appItem setSubmenu:[[NSMenu alloc] initWithTitle:@"MuseScore Studio"]];
[m_mainMenu addItem:appItem];
NSMenuItem* fileItem = [[NSMenuItem alloc] initWithTitle:@"文件 (F)" action:nil keyEquivalent:@""];
[fileItem setSubmenu:[[NSMenu alloc] initWithTitle:@"文件 (F)"]];
[m_mainMenu addItem:fileItem];
NSMenuItem* editItem = [[NSMenuItem alloc] initWithTitle:@"编辑 (E)" action:nil keyEquivalent:@""];
NSMenu* editSub = [[NSMenu alloc] initWithTitle:@"编辑 (E)"];
NSMenuItem* zhUndo = [[NSMenuItem alloc] initWithTitle:@"撤消“粘贴”" action:@selector(triggerAction:) keyEquivalent:@"z"];
[zhUndo setTarget:m_mockTarget];
[editSub addItem:zhUndo];
NSMenuItem* zhRedo = [[NSMenuItem alloc] initWithTitle:@"恢复" action:@selector(triggerAction:) keyEquivalent:@"Z"];
[zhRedo setTarget:m_mockTarget];
[editSub addItem:zhRedo];
NSMenuItem* zhHistory = [[NSMenuItem alloc] initWithTitle:@"历史 (H)" action:@selector(triggerAction:) keyEquivalent:@""];
[zhHistory setTarget:m_mockTarget];
[editSub addItem:zhHistory];
[editSub addItem:[NSMenuItem separatorItem]];
NSMenuItem* zhCut = [[NSMenuItem alloc] initWithTitle:@"剪切 (T)" action:@selector(triggerAction:) keyEquivalent:@"x"];
[zhCut setTarget:m_mockTarget];
[editSub addItem:zhCut];
NSMenuItem* zhCopy = [[NSMenuItem alloc] initWithTitle:@"拷贝 (C)" action:@selector(triggerAction:) keyEquivalent:@"c"];
[zhCopy setTarget:m_mockTarget];
[editSub addItem:zhCopy];
NSMenuItem* zhPaste = [[NSMenuItem alloc] initWithTitle:@"粘贴 (E)" action:@selector(triggerAction:) keyEquivalent:@"v"];
[zhPaste setTarget:m_mockTarget];
[editSub addItem:zhPaste];
[editSub addItem:[NSMenuItem separatorItem]];
NSMenuItem* zhSelectAll = [[NSMenuItem alloc] initWithTitle:@"全选 (A)" action:@selector(triggerAction:) keyEquivalent:@"a"];
[zhSelectAll setTarget:m_mockTarget];
[editSub addItem:zhSelectAll];
[editItem setSubmenu:editSub];
[m_mainMenu addItem:editItem];
NSUInteger initialCount = [[editSub itemArray] count];
{
muse::MacOSInteractiveHelper::NativeDialogScope scope;
// Verify zero temporary items were created in editSub
EXPECT_EQ([[editSub itemArray] count], initialCount);
// Verify existing Chinese items were modified in-place
EXPECT_EQ([zhUndo target], nil);
EXPECT_EQ([zhUndo action], @selector(undo:));
EXPECT_STREQ([[zhUndo keyEquivalent] UTF8String], "z");
EXPECT_TRUE([zhUndo isEnabled]);
EXPECT_EQ([zhRedo target], nil);
EXPECT_EQ([zhRedo action], @selector(redo:));
EXPECT_STREQ([[zhRedo keyEquivalent] UTF8String], "Z");
EXPECT_TRUE([zhRedo isEnabled]);
EXPECT_EQ([zhCut target], nil);
EXPECT_EQ([zhCut action], @selector(cut:));
EXPECT_STREQ([[zhCut keyEquivalent] UTF8String], "x");
EXPECT_TRUE([zhCut isEnabled]);
EXPECT_EQ([zhCopy target], nil);
EXPECT_EQ([zhCopy action], @selector(copy:));
EXPECT_STREQ([[zhCopy keyEquivalent] UTF8String], "c");
EXPECT_TRUE([zhCopy isEnabled]);
EXPECT_EQ([zhPaste target], nil);
EXPECT_EQ([zhPaste action], @selector(paste:));
EXPECT_STREQ([[zhPaste keyEquivalent] UTF8String], "v");
EXPECT_TRUE([zhPaste isEnabled]);
EXPECT_EQ([zhSelectAll target], nil);
EXPECT_EQ([zhSelectAll action], @selector(selectAll:));
EXPECT_STREQ([[zhSelectAll keyEquivalent] UTF8String], "a");
EXPECT_TRUE([zhSelectAll isEnabled]);
// Unmodified items like 历史 (H) remain untouched
EXPECT_EQ([zhHistory target], m_mockTarget);
}
// Post-scope: original state restored, item count identical
EXPECT_EQ([[editSub itemArray] count], initialCount);
EXPECT_EQ([zhUndo target], m_mockTarget);
EXPECT_STREQ([[zhUndo keyEquivalent] UTF8String], "z");
EXPECT_EQ([zhPaste target], m_mockTarget);
EXPECT_STREQ([[zhPaste keyEquivalent] UTF8String], "v");
}
TEST_F(MacOSInteractiveHelperTest, NativeDialogScope_MaintainsTargetWhenEditMenuIsClickedAndTracked)
{
[m_mainMenu removeAllItems];
NSMenuItem* appItem = [[NSMenuItem alloc] initWithTitle:@"MuseScore Studio" action:nil keyEquivalent:@""];
[appItem setSubmenu:[[NSMenu alloc] initWithTitle:@"MuseScore Studio"]];
[m_mainMenu addItem:appItem];
NSMenuItem* editItem = [[NSMenuItem alloc] initWithTitle:@"Edit" action:nil keyEquivalent:@""];
NSMenu* editSub = [[NSMenu alloc] initWithTitle:@"Edit"];
muse::MacOSInteractiveHelper::setEditMenuIndex(1);
NSMenuItem* undo = [[NSMenuItem alloc] initWithTitle:@"Undo" action:@selector(triggerAction:) keyEquivalent:@""];
[undo setTarget:m_mockTarget];
[editSub addItem:undo];
NSMenuItem* redo = [[NSMenuItem alloc] initWithTitle:@"Redo" action:@selector(triggerAction:) keyEquivalent:@""];
[redo setTarget:m_mockTarget];
[editSub addItem:redo];
[editItem setSubmenu:editSub];
[m_mainMenu addItem:editItem];
{
muse::MacOSInteractiveHelper::NativeDialogScope scope;
EXPECT_EQ([undo target], nil);
EXPECT_EQ([redo target], nil);
// Simulate QML/Qt resetting the target when onAboutToShow executes
[undo setTarget:m_mockTarget];
[redo setTarget:m_mockTarget];
[editSub setAutoenablesItems:YES];
EXPECT_EQ([undo target], m_mockTarget);
// Simulate user clicking the Edit menu -> AppKit posts NSMenuDidEndTrackingNotification when menu interaction completes
[[NSNotificationCenter defaultCenter] postNotificationName:NSMenuDidEndTrackingNotification object:editSub];
[[NSOperationQueue mainQueue] waitUntilAllOperationsAreFinished];
// Verify observer automatically restored target = nil and autoenablesItems = NO
EXPECT_EQ([undo target], nil);
EXPECT_EQ([redo target], nil);
EXPECT_FALSE([editSub autoenablesItems]);
}
EXPECT_EQ([undo target], m_mockTarget);
EXPECT_EQ([redo target], m_mockTarget);
}
TEST_F(MacOSInteractiveHelperTest, NativeDialogScope_HandlesMenuRecreationDuringTracking)
{
[m_mainMenu removeAllItems];
NSMenuItem* appItem = [[NSMenuItem alloc] initWithTitle:@"MuseScore Studio" action:nil keyEquivalent:@""];
[appItem setSubmenu:[[NSMenu alloc] initWithTitle:@"MuseScore Studio"]];
[m_mainMenu addItem:appItem];
NSMenuItem* editItem = [[NSMenuItem alloc] initWithTitle:@"Edit" action:nil keyEquivalent:@""];
NSMenu* editSub = [[NSMenu alloc] initWithTitle:@"Edit"];
muse::MacOSInteractiveHelper::setEditMenuIndex(1);
NSMenuItem* undo1 = [[NSMenuItem alloc] initWithTitle:@"Undo" action:@selector(triggerAction:) keyEquivalent:@"z"];
[undo1 setTarget:m_mockTarget];
[editSub addItem:undo1];
[editItem setSubmenu:editSub];
[m_mainMenu addItem:editItem];
NSMenuItem* undo2 = nil;
{
muse::MacOSInteractiveHelper::NativeDialogScope scope;
EXPECT_EQ([undo1 target], nil);
// Simulate Qt completely clearing the menu and adding brand new NSMenuItem objects during menu tracking
[editSub removeAllItems];
undo2 = [[NSMenuItem alloc] initWithTitle:@"Undo" action:@selector(triggerAction:) keyEquivalent:@"z"];
[undo2 setTarget:m_mockTarget];
[editSub addItem:undo2];
[editSub setAutoenablesItems:YES];
EXPECT_EQ([undo2 target], m_mockTarget);
// End tracking notification fires when user closes menu
[[NSNotificationCenter defaultCenter] postNotificationName:NSMenuDidEndTrackingNotification object:editSub];
[[NSOperationQueue mainQueue] waitUntilAllOperationsAreFinished];
// Observer must dynamically match undo2 and recreate missing fallbacks (Redo, Cut, Copy, Paste, Select All)
EXPECT_EQ([undo2 target], nil);
EXPECT_FALSE([editSub autoenablesItems]);
EXPECT_EQ([[editSub itemArray] count], 6);
for (NSUInteger i = 1; i < [[editSub itemArray] count]; ++i) {
NSMenuItem* fallback = [editSub itemArray][i];
EXPECT_EQ([fallback target], nil);
EXPECT_TRUE([fallback isEnabled]);
EXPECT_TRUE([fallback isHidden]);
}
}
// On scope exit, fallbacks must be removed cleanly and undo2 restored to original target
EXPECT_EQ([[editSub itemArray] count], 1);
EXPECT_EQ([undo2 target], m_mockTarget);
}
TEST_F(MacOSInteractiveHelperTest, NativeDialogScope_HandlesSubmenuReplacementDuringTracking)
{
[m_mainMenu removeAllItems];
NSMenuItem* appItem = [[NSMenuItem alloc] initWithTitle:@"MuseScore Studio" action:nil keyEquivalent:@""];
[appItem setSubmenu:[[NSMenu alloc] initWithTitle:@"MuseScore Studio"]];
[m_mainMenu addItem:appItem];
NSMenuItem* editItem = [[NSMenuItem alloc] initWithTitle:@"Edit" action:nil keyEquivalent:@""];
NSMenu* oldEditSub = [[NSMenu alloc] initWithTitle:@"Edit"];
muse::MacOSInteractiveHelper::setEditMenuIndex(1);
NSMenuItem* undo1 = [[NSMenuItem alloc] initWithTitle:@"Undo" action:@selector(triggerAction:) keyEquivalent:@"z"];
[undo1 setTarget:m_mockTarget];
[oldEditSub addItem:undo1];
[editItem setSubmenu:oldEditSub];
[m_mainMenu addItem:editItem];
NSMenuItem* undo2 = nil;
NSMenu* newEditSub = nil;
{
muse::MacOSInteractiveHelper::NativeDialogScope scope;
EXPECT_EQ([undo1 target], nil);
// Simulate replacing the entire NSMenu instance of the Edit submenu
newEditSub = [[NSMenu alloc] initWithTitle:@"Edit"];
undo2 = [[NSMenuItem alloc] initWithTitle:@"Undo" action:@selector(triggerAction:) keyEquivalent:@"z"];
[undo2 setTarget:m_mockTarget];
[newEditSub addItem:undo2];
[editItem setSubmenu:newEditSub];
EXPECT_EQ([undo2 target], m_mockTarget);
// Tracking notification fires -> applyScopeTransformations re-resolves the live submenu
[[NSNotificationCenter defaultCenter] postNotificationName:NSMenuDidEndTrackingNotification object:newEditSub];
[[NSOperationQueue mainQueue] waitUntilAllOperationsAreFinished];
EXPECT_EQ([undo2 target], nil);
EXPECT_FALSE([newEditSub autoenablesItems]);
EXPECT_EQ([[newEditSub itemArray] count], 6);
}
// On scope exit, fallbacks removed and target restored
EXPECT_EQ([[newEditSub itemArray] count], 1);
EXPECT_EQ([undo2 target], m_mockTarget);
}