Search code examples
c#winformsautomationmicrosoft-ui-automationwhite-framework

Windows UI Automation: Get selected object from C# ListBox control


A little background: I'm currently writing a sample project using Winforms/C# that emulates Conway's Game of Life. Part of this sample involves UI Automation using the White Automation Framework. The relevant layout of the form includes a custom grid control for setting up the world and a list box control that displays/stores past generations of the world.

I have a World object that stores a list of Cell objects and calculates the next generation of a World from its current state:

public class World
{
   public IReadOnlyCollection<Cell> Cells { get; private set; }

   public World(IList<Cell> seed)
   {
      Cells = new ReadOnlyCollection<Cell>(seed);
   }

   public World GetNextGeneration()
   {
      /* ... */
   }
}

In my UI, when I calculate the next world generation, the past generations list is updated. The past generation list stores World objects as its items, and I have subscribed to the Format event of the list box to format the item display. _worldProvider.PreviousGenerations is a collection of World objects.

private void UpdatePastGenerationsList()
{
   GenerationList.SuspendLayout();
   GenerationList.Items.Add(_worldProvider.PreviousGenerations.Last());
   GenerationList.SelectedItem = _worldProvider.PreviousGenerations.Last();
   GenerationList.ResumeLayout();
}

From this snippet you can see that the items of the ListBox are World objects. What I want to do in my test code is get the actual World object (or some representation of it) from the selected ListBox item, and then compare it to the grid's representation of the world. The grid has a full automation implementation so I can easily get a representation of the grid using existing automation calls in White.

The only idea I had was to make a derived ListBox control that sends an ItemStatus property changed automation event when the selected index changes from an automation click event, and then listening for that ItemStatus event in the test code. The World is first converted to a string (WorldSerialize.SerializeWorldToString) where each live cell is converted to formatted coordinates {x},{y};.

public class PastGenerationListBox : ListBox
{
   public const string ITEMSTATUS_SELECTEDITEMCHANGED = "SelectedItemChanged";

   protected override void OnSelectedIndexChanged(EventArgs e)
   {
      FireSelectedItemChanged(SelectedItem as World);
      base.OnSelectedIndexChanged(e);
   }

   private void FireSelectedItemChanged(World world)
   {
      if (!AutomationInteropProvider.ClientsAreListening)
         return;

      var provider = AutomationInteropProvider.HostProviderFromHandle(Handle);
      var args = new AutomationPropertyChangedEventArgs(
                      AutomationElementIdentifiers.ItemStatusProperty,
                      ITEMSTATUS_SELECTEDITEMCHANGED,
                      WorldSerialize.SerializeWorldToString(world));
      AutomationInteropProvider.RaiseAutomationPropertyChangedEvent(provider, args);
   }
}

The problem I have with this is that the event handler code in the test class is never being called. I think the problem is with the AutomationInteropProvider.HostProviderFromHandle call returning a different provider object from the one in the test code, but I am not sure.

My questions are:

  1. Is there a better approach I can take, such as something provided by the MS Automation API?
  2. If not - is there a way I can get the default C# IRawElementProviderSimple implementation for the ListBox control (to raise the Property Changed event)? I would rather not re-implement it just for this little bit of functionality.

Here is the code from the test side, which adds the listener for ItemStatusProperty change event. I am using SpecFlow for BDD which defines ScenarioContext.Current as a dictionary. WorldGridSteps.Window is a TestStack.White.Window object.

  private static void HookListItemStatusEvent()
  {
     var list = WorldGridSteps.Window.Get<ListBox>(GENERATION_LIST_NAME);
     Automation.AddAutomationPropertyChangedEventHandler(list.AutomationElement,
                                                         TreeScope.Element,
                                                         OnGenerationSelected,
                                                         AutomationElementIdentifiers.ItemStatusProperty);
  }

  private static void UnhookListItemStatusEvent()
  {
     var list = WorldGridSteps.Window.Get<ListBox>(GENERATION_LIST_NAME);
     Automation.RemoveAutomationPropertyChangedEventHandler(list.AutomationElement, OnGenerationSelected);
  }

  private static void OnGenerationSelected(object sender, AutomationPropertyChangedEventArgs e)
  {
     if (e.EventId.Id != AutomationElementIdentifiers.ItemStatusProperty.Id)
        return;

     World world = null;
     switch (e.OldValue as string)
     {
        case PastGenerationListBox.ITEMSTATUS_SELECTEDITEMCHANGED:
           world = WorldSerialize.DeserializeWorldFromString(e.NewValue as string);
           break;
     }

     if (world != null)
     {
        if (ScenarioContext.Current.ContainsKey(SELECTED_WORLD_KEY))
           ScenarioContext.Current[SELECTED_WORLD_KEY] = world;
        else
           ScenarioContext.Current.Add(SELECTED_WORLD_KEY, world);
     }
  }

Solution

  • I was able to work around this problem by using non-persisted memory mapped files to allow additional communication between the window GUI and the test process.

    This ended up being much easier than trying to completely re-write IRawElementProviderSimple implementations for both my "custom" ListBox and the items contained within.

    My custom ListBox ended up looking like this:

    public class PastGenerationListBox : ListBox
    {
       public const string SELECTEDWORLD_MEMORY_NAME = "SelectedWorld";
       public const string SELECTEDWORLD_MUTEX_NAME = "SelectedWorldMutex";
    
       private const int SHARED_MEMORY_CAPACITY = 8192;
       private MemoryMappedFile _sharedMemory;
       private Mutex _sharedMemoryMutex;
    
       public new World SelectedItem
       {
          get { return base.SelectedItem as World; }
          set { base.SelectedItem = value; }
       }
    
       public PastGenerationListBox()
       {
          _sharedMemory = MemoryMappedFile.CreateNew(SELECTEDWORLD_MEMORY_NAME, SHARED_MEMORY_CAPACITY);
          _sharedMemoryMutex = new Mutex(false, SELECTEDWORLD_MUTEX_NAME);
       }
    
       protected override void OnSelectedIndexChanged(EventArgs e)
       {
          WriteSharedMemory(SelectedItem);
          base.OnSelectedIndexChanged(e);
       }
    
       protected override void Dispose(bool disposing)
       {
          if (disposing)
          {
             _sharedMemoryMutex.WaitOne();
    
             if (_sharedMemory != null)
                _sharedMemory.Dispose();
             _sharedMemory = null;
    
             _sharedMemoryMutex.ReleaseMutex();
    
             if (_sharedMemoryMutex != null)
                _sharedMemoryMutex.Dispose();
             _sharedMemoryMutex = null;
          }
          base.Dispose(disposing);
       }
    
       private void WriteSharedMemory(World world)
       {
          if (!AutomationInteropProvider.ClientsAreListening) return;
    
          var data = WorldSerialize.SerializeWorldToString(world);
          var bytes = Encoding.ASCII.GetBytes(data);
          if (bytes.Length > 8188)
             throw new Exception("Error: the world is too big for the past generation list!");
    
          _sharedMemoryMutex.WaitOne();
          using (var str = _sharedMemory.CreateViewStream(0, SHARED_MEMORY_CAPACITY))
          {
             str.Write(BitConverter.GetBytes(bytes.Length), 0, 4);
             str.Write(bytes, 0, bytes.Length);
          }
          _sharedMemoryMutex.ReleaseMutex();
       }
    }
    

    My test code looks like this:

    private static World GetWorldFromMappedMemory()
    {
       string str;
    
       using (var mut = Mutex.OpenExisting(PastGenerationListBox.SELECTEDWORLD_MUTEX_NAME))
       {
          mut.WaitOne();
    
          using (var sharedMem = MemoryMappedFile.OpenExisting(PastGenerationListBox.SELECTEDWORLD_MEMORY_NAME))
          {
             using (var stream = sharedMem.CreateViewStream())
             {
                byte[] rawLen = new byte[4];
                stream.Read(rawLen, 0, 4);
                var len = BitConverter.ToInt32(rawLen, 0);
    
                byte[] rawData = new byte[len];
                stream.Read(rawData, 0, rawData.Length);
                str = Encoding.ASCII.GetString(rawData);
             }
          }
    
          mut.ReleaseMutex();
       }
    
       return WorldSerialize.DeserializeWorldFromString(str);
    }