Files
GacUI/.github/KnowledgeBase/KB_GacUI_RemoteProtocolUnitTestFramework.md
2026-03-08 03:04:45 -07:00

21 KiB

Remote Protocol Unit Test Framework

The remote protocol unit test framework allows testing GacUI applications without real OS windows or rendering. It reuses the same remote protocol architecture as production remote deployment, replacing the renderer side with a mock implementation (UnitTestRemoteProtocol) that captures rendering results as snapshots and feeds simulated user input through the protocol pipeline. The GacUI core side runs identically to production.

Implementation lives in Source/UnitTestUtilities/.

Comprehensive Example

Below is a complete example demonstrating the key concepts of the framework: test executable setup, GacUI XML resource definition, frame-based interaction with named snapshots, control lookup, and mouse input simulation.

Test Executable Entry Point

The test executable's main function initializes the framework once, runs all test cases, and finalizes:

int UnitTestMain(int argc, T* argv[])
{
    UnitTestFrameworkConfig config;
    config.snapshotFolder = GetTestSnapshotPath();
    config.resourceFolder = GetTestDataPath();

    GacUIUnitTest_Initialize(&config);
    int result = UnitTest::RunAndDisposeTests(argc, argv);
    GacUIUnitTest_Finalize();
    return result;
}

A Test Case: Clicking a Button

This example defines a GacUI XML resource with a window containing an OK button, then tests hovering, pressing, and releasing the button in separate frames. Each frame callback describes the action it performs; the frame name is assigned to the rendering snapshot that results from the previous frame's action.

const auto resource = LR"GacUISrc(
<Resource>
  <Instance name="MainWindowResource">
    <Instance ref.Class="gacuisrc_unittest::MainWindow">
      <Window ref.Name="self" Text="Hello, world!" ClientSize="x:320 y:240">
        <Button ref.Name="buttonOK" Text="OK">
          <att.BoundsComposition-set AlignmentToParent="left:5 top:5 right:-1 bottom:-1"/>
          <ev.Clicked-eval><![CDATA {
            Application::GetApplication().InvokeInMainThread(self, func():void{
              // changing UI structures that affects the sender of an event
              // is supposed to be put in InvokeInMainThread
              // Otherwise followed up events on the same control may gone
              self.Close();
            });
          }]]></ev.Clicked-eval>
        </Button>
      </Window>
    </Instance>
  </Instance>
</Resource>
)GacUISrc";

TEST_CASE(L"Click Button")
{
    GacUIUnitTest_SetGuiMainProxy([](UnitTestRemoteProtocol* protocol, IUnitTestContext*)
    {
        // Frame 0: snapshot named "Ready" — shows the initial rendering after the window opens.
        // Callback action: move the mouse on the OK button.
        protocol->OnNextIdleFrame(L"Ready", [=]()
        {
            auto window = GetApplication()->GetMainWindow();
            auto buttonOK = TryFindObjectByName<GuiButton>(window, L"buttonOK");
            // MouseMove(location) + LClick() could be merged into one operation
            // LClick(location) will perform both
            // but when the side effect of MouseMove is important
            // you are given choices
            protocol->MouseMove(protocol->LocationOf(buttonOK));
        });
        // Frame 1: snapshot named "Hover" — shows the button in hover state (result of MouseMove above).
        // Callback action: click the OK button, triggering Clicked → window closes.
        protocol->OnNextIdleFrame(L"Hover", [=]()
        {
            protocol->LClick();
        });
        // If the user interaction above does not cause the application to exit,
        // an additional frame is needed to close the window explicitly:
        //
        // protocol->OnNextIdleFrame(L"Frame name describing what the previous frame does", [=]()
        // {
        //     auto window = GetApplication()->GetMainWindow();
        //     window->Hide();
        // });
    });
    GacUIUnitTest_StartFast_WithResourceAsText<darkskin::Theme>(
        WString::Unmanaged(L"UnitTestFramework/WindowWithOKButton"),
        WString::Unmanaged(L"gacuisrc_unittest::MainWindow"),
        resource
        );
});

What This Example Demonstrates

  • GacUIUnitTest_SetGuiMainProxy: Registers the test's frame callback function. It receives UnitTestRemoteProtocol* (for input simulation and snapshot access) and IUnitTestContext*.
  • OnNextIdleFrame(name, callback): Registers a callback to run after the next rendering frame settles. The name is attached to the snapshot that was just captured (the rendering result), not to what the callback will do.
  • TryFindObjectByName<T>(window, name): Looks up a named control from the GacUI XML resource by its ref.Name.
  • protocol->LocationOf(control): Computes the absolute screen coordinate of the center of a control, for use with mouse input methods.
  • LClick(location): Simulates a full left mouse click (mouse move + button down + button up) at the given location. Use this for simple clicks instead of separate _LDown/_LUp calls.
  • GacUIUnitTest_StartFast_WithResourceAsText<Theme>: Compiles the XML resource, registers the theme, creates the window, runs the application, and captures snapshots — all in one call.
  • Frame name semantics: "Ready" names the initial rendering. Each snapshot file (frame_0.json) records the full rendering DOM at that point.
  • Closing the window: The button's click handler uses InvokeInMainThread to defer self.Close(). This is necessary because Close() would otherwise be called synchronously inside the IO event dispatch, which could block the frame callback (see the blocking function caveat below). If user interaction does not cause the application to exit, an extra frame must be added at the end to call window->Hide() explicitly.

Test Executable Initialization and Finalization

Global Setup (GacUIUnitTest_Initialize)

Each test executable's main calls GacUIUnitTest_Initialize(&config) with a UnitTestFrameworkConfig containing snapshotFolder and resourceFolder. This function:

  • Sets GACUI_UNITTEST_ONLY_SKIP_THREAD_LOCAL_STORAGE_DISPOSE_STORAGES and GACUI_UNITTEST_ONLY_SKIP_TYPE_AND_PLUGIN_LOAD_UNLOAD to true, allowing the type manager and plugin system to be loaded once and reused across all test cases.
  • Calls GetGlobalTypeManager()->Load() and GetPluginManager()->Load(true, false) once.

Global Teardown (GacUIUnitTest_Finalize)

Called after all tests complete. Tears down the type manager and plugin manager.

Per-Test-Case Setup and the Proxy Chaining Pattern

Registering Frame Callbacks

Each test case follows a standard pattern:

  1. Call GacUIUnitTest_SetGuiMainProxy(callback) to register a UnitTestMainFunc (signature: void(UnitTestRemoteProtocol*, IUnitTestContext*)). This is the test's frame-based action callback that registers idle frame handlers via OnNextIdleFrame.
  2. Optionally call GacUIUnitTest_LinkGuiMainProxy(linkCallback) to wrap additional setup around the test proxy.
  3. Call one of the start functions to launch the test.

Proxy Chaining (GacUIUnitTest_LinkGuiMainProxy)

GacUIUnitTest_LinkGuiMainProxy captures the current proxy as previousMainProxy and installs a new proxy that calls the link function with the previous proxy as a parameter. This enables decorator-style composition — each link layer can perform setup before delegating to the inner proxy and cleanup after.

GacUIUnitTest_StartFast_WithResourceAsText

This template function is the most commonly used entry point. It internally calls GacUIUnitTest_LinkGuiMainProxy to inject:

  1. Sending OnControllerConnect with ControllerGlobalConfig to the protocol.
  2. Registering a theme (e.g., DarkSkin).
  3. Compiling the GacUI XML resource via GacUIUnitTest_CompileAndLoad.
  4. Creating the main window from the resource via Value::Create(windowTypeFullName).
  5. Calling previousMainProxy(protocol, context) to let the test register its frame callbacks.
  6. Running the application via GetApplication()->Run(window).
  7. Unregistering the theme on cleanup.

It also saves the compiled Workflow script text as a snapshot file ([x64].txt or [x86].txt) for Workflow generation stability verification.

Start Functions

  • GacUIUnitTest_Start(appName, config) — synchronous in-process test with optional serialization channel.
  • GacUIUnitTest_StartAsync(appName, config) — async test with core and renderer on separate threads.
  • GacUIUnitTest_Start_WithResourceAsText(appName, config, resourceText) — wraps resource compilation via GacUIUnitTest_LinkGuiMainProxy, then delegates to GacUIUnitTest_Start or GacUIUnitTest_StartAsync depending on config.useChannel.

Protocol Stack Construction

Synchronous Mode (GacUIUnitTest_Start)

GacUIUnitTest_Start constructs the full protocol stack in-process:

Renderer side (deserialization direction):

  • UnitTestRemoteProtocol — mock IGuiRemoteProtocol implementation.
  • GuiRemoteJsonChannelFromProtocol — converts protocol calls to JSON.
  • GuiRemoteJsonChannelStringDeserializer — JSON to String.
  • GuiRemoteUtfStringChannelDeserializer<wchar_t, char8_t> — String to UTF-8.

Core side (serialization direction, mirrors back):

  • GuiRemoteUtfStringChannelSerializer<wchar_t, char8_t> — UTF-8 to String.
  • GuiRemoteJsonChannelStringSerializer — String to JSON.
  • GuiRemoteProtocolFromJsonChannel — JSON to typed protocol calls.

Protocol filter layers on core side:

  • GuiRemoteProtocolFilterVerifier — validates repeat-filtering invariants.
  • GuiRemoteProtocolFilter — filters redundant messages.
  • GuiRemoteProtocolDomDiffConverter — (optional, when useDomDiff is true) converts full DOM to DOM diffs.

When useChannel == UnitTestRemoteChannel::None, the verifier directly wraps UnitTestRemoteProtocol's IGuiRemoteProtocol, bypassing the serialization layers for speed. Otherwise, the full serialization channel is used for testing round-trip fidelity.

Finally, SetupRemoteNativeController(protocol) creates the runtime stack: GuiRemoteControllerGuiHostedController → resource managers, then calls GuiApplicationMain()GuiMain() → the registered test proxy.

Async Mode (GacUIUnitTest_StartAsync)

GacUIUnitTest_StartAsync inserts GuiRemoteProtocolAsyncJsonChannelSerializer into the channel, placing the core and renderer on separate threads. Two threads are spawned via RunInNewThread: a channel thread for serialization I/O and a UI thread for the GacUI application. The call waits for asyncChannelSender.WaitForStopped() before writing snapshots.

UnitTestRemoteProtocol Class Hierarchy

UnitTestRemoteProtocol inherits from UnitTestRemoteProtocolFeatures and IGuiRemoteEventProcessor. Its base classes are:

  • UnitTestRemoteProtocolBase — holds IGuiRemoteProtocolEvents*, UnitTestScreenConfig, default Impl_* stubs, Initialize, GetExecutablePath.
  • UnitTestRemoteProtocol_MainWindow — simulates window management (bounds, sizing config, styles).
  • UnitTestRemoteProtocol_IO — simulates controller connection and IO event forwarding.
  • UnitTestRemoteProtocol_Rendering — simulates element creation/destruction, rendering begin/end, DOM capture.
  • UnitTestRemoteProtocol_IOCommands — provides high-level input simulation methods.
  • UnitTestRemoteProtocolFeatures — combines all feature bases and implements LogRenderingResult(), the core frame capture mechanism.

UnitTestRemoteProtocol adds:

  • processRemoteEvents — a list of (Nullable<WString>, Func<void()>) pairs representing named frame callbacks.
  • OnNextIdleFrame(callback) and OnNextIdleFrame(name, callback) — register callbacks to execute after specific rendering frames settle.
  • ProcessRemoteEvents() — the main event processing method called each cycle.

The Main Event Loop

GuiRemoteController::RunOneCycle() drives the core side per cycle:

  1. remoteProtocol->GetRemoteEventProcessor()->ProcessRemoteEvents() — this invokes UnitTestRemoteProtocol::ProcessRemoteEvents().
  2. remoteMessages.Submit(disconnected) — flushes pending protocol messages.
  3. callbackService.InvokeGlobalTimer() — triggers GuiHostedController::GlobalTimer(), which performs layout and rendering, generating new DOM/element data.
  4. asyncService.ExecuteAsyncTasks().

The cycle repeats until connectionStopped is true (application closes).

Frame Capture and Snapshot Mechanism

Passive Rendering Capture (UnitTestRemoteProtocol_Rendering)

Each time the GacUI core calls RequestRendererBeginRendering, UnitTestRemoteProtocol_Rendering::Impl_RendererBeginRendering creates a new UnitTestLoggedFrame and records the frameId from the core's monotonic counter. When Impl_RendererEndRendering is called, the frame's renderingDom is captured from the rendering commands.

Active Frame Logging (LogRenderingResult)

LogRenderingResult() is called at the start of each ProcessRemoteEvents() cycle in UnitTestRemoteProtocolFeatures:

  1. TryGetLastRenderingFrameAndReset() checks if a rendering frame completed since the last check. If yes, it becomes the candidateFrame.
  2. On the next cycle where no new rendering occurred (the UI has settled), the candidateFrame is committed to loggedTrace.frames with a copy of lastElementDescs and sizingConfig. This ensures the snapshot captures the final stable rendering state after all layout passes.
  3. LogRenderingResult() returns true, signaling that a frame is ready.
  4. In ProcessRemoteEvents(), the frameName is set on the just-committed frame (from the callback's registered name), and then the callback function executes.
  5. If 100 consecutive cycles pass without any rendering change after previously rendering, the test fails with an error.

Frame Name Semantics

The frame name is assigned to the already-committed snapshot (the rendering result), then the callback executes. This means:

  • The frameName describes what led to the current visual state, not what the callback will do next.
  • The first frame is conventionally named "Ready", representing the initial rendering state after the window opens.
  • Subsequent frame names describe the action taken in the previous callback (e.g., "Hover" means the previous callback moved the mouse, and this snapshot shows the hover state).

The sequence is:

  1. Application renders → DOM/elements captured as candidateFrame.
  2. UI settles (no more rendering) → candidateFrame committed to loggedTrace.frames.
  3. frameName is set on the committed frame from the callback registration.
  4. Frame callback executes (may trigger events → triggering more rendering).
  5. Return to step 1.

Snapshot File Structure

For a test named Controls/Basic/GuiButton/ClickOnMouseUp:

  • Main trace file: {snapshotFolder}/Controls/Basic/GuiButton/ClickOnMouseUp.jsonUnitTest_RenderingTrace with createdElements, imageCreations, imageMetadatas, and frames (each frame only has frameId and frameName, detail stripped).
  • Per-frame files: {snapshotFolder}/Controls/Basic/GuiButton/ClickOnMouseUp/frame_0.json, frame_1.json, etc. — full UnitTest_RenderingFrame with frameId, frameName, windowSize, elements (all rendered element descriptions keyed by ID), and root (the rendering DOM tree).
  • Workflow snapshot: ClickOnMouseUp[x64].txt or [x86].txt — compiled Workflow script text for generation stability verification.
  • Commands log: ClickOnMouseUp[commands].txt — rendering command logs per frame (when not using DOM diff).
  • Diffs log: ClickOnMouseUp[diffs].txt — DOM diff logs per frame.

Write Strategy (GacUIUnitTest_LogUI)

GacUIUnitTest_LogUI performs the following:

  1. Serializes the full loggedTrace to JSON and verifies round-trip fidelity (serialize → deserialize → re-serialize must match).
  2. Writes each frame as an individual frame_N.json file via GacUIUnitTest_WriteSnapshotFileIfChanged, which only writes if content differs from the existing file (enabling clean git diff).
  3. Strips frame detail from the in-memory trace (keeping only frameId and frameName) and writes the main trace file.
  4. Deletes any leftover frame_*.json files that no longer correspond to actual frames.

User Interaction Simulation

UnitTestRemoteProtocol_IOCommands

UnitTestRemoteProtocol_IOCommands provides methods to simulate all user input. It maintains virtual state:

  • mousePosition — current mouse position (Nullable<NativePoint>, initially unset; first MouseMove triggers OnIOMouseEntered).
  • pressingKeysSortedList<VKEY> of currently pressed keys.
  • leftPressing, middlePressing, rightPressing — mouse button states.
  • capslockToggled — capslock toggle state.

Each method constructs the appropriate NativeWindowMouseInfo, NativeWindowKeyInfo, or NativeWindowCharInfo struct (via MakeMouseInfo(), MakeKeyInfo(), MakeCharInfo()) and calls the corresponding event on IGuiRemoteProtocolEvents (obtained via UseEvents()).

Location Calculation (LocationOf)

LocationOf(controlOrComposition, ratioX, ratioY, offsetX, offsetY) computes the target point in absolute screen coordinates:

  1. Gets the composition's GetGlobalBounds() (logical coordinates).
  2. Converts to native coordinates using nativeWindow->Convert().
  3. Applies the ratio (default 0.5 = center) and offset.
  4. Adds the window's screen position nativeWindow->GetBounds().LeftTop().

Overloads accept either GuiGraphicsComposition* or GuiControl*.

Mouse Input Methods

  • MouseMove(location) — sends OnIOMouseMoving. If mouse was previously unset, first sends OnIOMouseEntered.
  • _LDown(location) / _LUp(location) — low-level left button down/up. _LDown calls MouseMove if position changed, then UseEvents().OnIOButtonDown({Left, ...}).
  • LClick(location)_LDown followed by _LUp.
  • LDBClick(location) — two LClick calls (the framework detects double click from timing).
  • Analogous RClick, MClick, RDBClick, MDBClick for right and middle buttons.
  • WheelUp(jumps) / WheelDown(jumps)OnIOVWheel with delta scaled by 120 per jump.
  • HWheelLeft(jumps) / HWheelRight(jumps)OnIOHWheel similarly.

Key and Character Input Methods

  • KeyDown(key) / KeyUp(key) — sends OnIOKeyDown / OnIOKeyUp. Tracks pressingKeys state. Special handling for VKEY::KEY_CAPITAL toggles capslockToggled.
  • KeyPress(key)KeyDown followed by KeyUp.
  • KeyPress(key, ctrl, shift, alt) — wraps the key press with modifier key down/up events.
  • TypeString(text) — sends a sequence of OnIOChar events for each character via MakeCharInfo, without synthesizing key down/up events.

Event Flow Through the Pipeline

When a test calls protocol->LClick(location):

  1. _LDown(location)MouseMove(location) if position changed → UseEvents().OnIOButtonDown({Left, MakeMouseInfo()}).
  2. _LUp(location)UseEvents().OnIOButtonUp({Left, MakeMouseInfo()}).
  3. These events reach GuiRemoteEvents (the IGuiRemoteProtocolEvents implementation in GuiRemoteController).
  4. GuiRemoteEvents::OnIOButtonDown() dispatches to listener->LeftButtonDown(info) on remoteWindow.
  5. GuiGraphicsHost receives the call as INativeWindowListener.
  6. GuiGraphicsHost::LeftButtonDown() calls OnMouseInput(), which converts native coordinates to logical coordinates, performs hit-testing via FindVisibleComposition(), creates GuiMouseEventArgs, and dispatches via RaiseMouseEvent().
  7. The event bubbles through the composition tree, triggering control-level handlers.

All of this happens synchronously within a single ProcessRemoteEvents() call, so by the time the frame callback returns, all side effects of the interaction have been processed.

Blocking Function Caveat

If an IO action triggers a blocking function (like ShowDialog), the frame callback would never return. The unit test framework detects this via the frameExecuting flag and fails. The solution is to wrap the IO action in GetApplication()->InvokeInMainThread(window, [=]() { protocol->LClick(location); }), which defers the action to the next event loop iteration.

Running Multiple Test Applications

Each call to GacUIUnitTest_Start runs one complete application lifecycle (from GuiMain() to window close). Typically each TEST_CASE calls the full GacUIUnitTest_SetGuiMainProxyGacUIUnitTest_Start sequence independently.

Within a single application instance, multiple windows can be created inside frame callbacks using Value::Create(L"namespace::ClassName") and shown with Show(), ShowModal(), or ShowDialog().

GacUIUnitTest_LinkGuiMainProxy can be called multiple times before GacUIUnitTest_Start, chaining multiple setup layers (e.g., one for resources, one for theme, one for additional initialization).