University of Wales Trinity Saint David
ACCA6021 - Graphics and Games Programming

Lab 15 - Test Harness and C++ Architecture

Overview

In the Renderer and Refactoring lab (Lab 14) you assembled six abstraction classes into a clean main.cpp. As the number of rendering experiments grows, editing that single file to switch between them becomes unmanageable. This lab introduces a lightweight test harness - a plugin architecture where each experiment lives in its own class and registers itself with one line. You will build Tests.h and Tests.cpp, wire the dispatch loop into main.cpp, and end with a running application whose ImGui panel is ready to host future test scenes. Concrete tests start in the Test Template lab (Lab 16).

Learning Objectives

By the end of this lab you will be able to:

Concept

As a graphics project grows, placing every rendering experiment in main.cpp directly forces you to comment and uncomment large code sections each time you switch focus. The file accumulates unrelated state, and touching one experiment risks breaking another. What you need is a way to add new scenes without modifying existing code.

The solution combines a polymorphic interface with a factory pattern. The Tests class declares three virtual methods - Update, Render, and RenderGUI - each with an empty default body so a derived scene overrides only what it uses. TestMenu holds a std::vector of name-and-factory pairs. Each factory is a std::function<Tests*()> lambda that constructs and returns the matching scene object when called. When the user clicks a button in the ImGui panel, TestMenu::RenderGUI invokes the factory and stores the result in a Tests* pointer that lives in main.cpp. The main loop then calls Update, Render, and RenderGUI on whatever that pointer holds, with no knowledge of the concrete type. Adding a new scene requires only a new class and one RegisterTest call - no existing code changes.

Two C++ features make this clean. The TestMenu constructor takes a reference-to-pointer (Tests*&), giving the menu direct write access to the pointer in main.cpp when a button is clicked - no global required. Registration uses a function template: RegisterTest<T>(name) captures the type T in a lambda at compile time so the factory can call new T() at runtime. The variadic overload RegisterTest<T, Args...> handles tests whose constructors need arguments: it packs them into a std::tuple with std::make_tuple and unpacks them later with std::apply.

Tasks

Task 1 - Read the Tests base class

This task is read-only. The complete header is provided below as a reference. Work through it section by section and consider the question in each step. You do not need to write any code yet.

  1. Locate the three virtual methods on Tests: Update, Render, and RenderGUI. Each has an empty default body {}. Explain why a default empty body is more useful here than making the methods pure virtual (declared with = 0).
  2. Find the virtual ~Tests() destructor. Imagine a Tests* pointer holds a derived scene object and you call delete on it. What resource leak occurs if ~Tests() is not virtual?
  3. Find RegisterTest<T> (the no-argument overload). The lambda []() -> Tests* { return new T(); } is stored in m_Tests now but called later. In TestMenu::RenderGUI (which you implement in Task 2), identify the exact expression that invokes it.
  4. Read the variadic RegisterTest<T, Args...> overload. Why must constructor arguments be packed into a std::tuple rather than captured directly in the lambda? What does std::apply do with the tuple when the factory runs?
  5. Examine the m_Tests member type: std::vector<std::pair<std::string, std::function<Tests*()>>>. Why is std::function<Tests*()> required here rather than a plain function pointer of type Tests*(*)() ?
src/tests/Tests.h
#pragma once
#include <functional>
#include <string>
#include <vector>
#include <memory>

namespace test
{
    // =========================================================================
    // BASE TEST CLASS
    // =========================================================================
    /**
     * Tests - Abstract base class for all test scenes.
     *
     * VIRTUAL DESTRUCTOR:
     * Without it, deleting a derived object through a Tests* calls only the
     * base destructor, leaking the derived class's resources.
     *
     * DEFAULT IMPLEMENTATIONS:
     * Empty {} bodies allow derived classes to override only the methods they use.
     * If these were pure virtual (= 0), every derived class would be forced to
     * implement all three even when some are not needed.
     */
    class Tests
    {
    public:
        Tests() {}
        virtual ~Tests() {}

        virtual void Update(float deltaTime) {}
        virtual void Render() {}
        virtual void RenderGUI() {}
    };


    // =========================================================================
    // TEST MENU (Factory Pattern)
    // =========================================================================
    /**
     * TestMenu - manages test registration and instantiation.
     *
     * FACTORY PATTERN:
     * Each test is registered with a factory lambda. Adding a new test requires
     * only a RegisterTest call - TestMenu itself never needs to change.
     *
     * TYPE ERASURE:
     * std::function<Tests*()> can hold any callable that returns a Tests*.
     * This allows heterogeneous factory lambdas to live in a single vector.
     *
     * REFERENCE-TO-POINTER (Tests*&):
     * The constructor takes Tests*& so the menu can update the caller's pointer
     * directly when a button is clicked - no global variable needed.
     */
    class TestMenu : public Tests
    {
    public:
        TestMenu(Tests*& currentTestPointer);

        void RenderGUI() override;

        // Register a test that needs no constructor arguments.
        // A lambda captures the type T and constructs it on demand.
        template<typename T>
        void RegisterTest(const std::string& name)
        {
            m_Tests.push_back(std::make_pair(name, []() -> Tests* { return new T(); }));
        }

        // Register a test whose constructor requires arguments.
        // Arguments are packed into a tuple now and unpacked with std::apply
        // when the factory is called later.
        template<typename T, typename... Args>
        void RegisterTest(const std::string& name, Args&&... args)
        {
            m_Tests.push_back(std::make_pair(name,
                [args = std::make_tuple(std::forward<Args>(args)...)]() -> Tests* {
                    return std::apply([](auto&&... unpackedArgs)
                        {
                            return new T(std::forward<decltype(unpackedArgs)>(unpackedArgs)...);
                        }, args);
                }));
        }

    private:
        Tests*& m_CurrentTest;
        std::vector<std::pair<std::string, std::function<Tests* ()>>> m_Tests;
    };
}

Task 2 - Implement TestMenu

Create src/tests/Tests.cpp in your project and fill in the one function marked with a // TODO comment.

  1. In TestMenu::RenderGUI, the for loop already iterates over m_Tests. Inside the loop, call ImGui::Button(test.first.c_str()). This renders a labelled button and returns true on the frame the user clicks it. When it returns true, call test.second() to invoke the factory and assign the returned pointer to m_CurrentTest.
  2. Build the project. There should be no errors at this point. The menu will show no buttons yet because no tests have been registered in main.cpp.
src/tests/Tests.cpp
#include "Tests.h"
#include "../vendor/imgui/imgui.h"

namespace test
{
    TestMenu::TestMenu(Tests*& currentTestPointer) : m_CurrentTest(currentTestPointer)
    {
    }

    void TestMenu::RenderGUI()
    {
        for (auto& test : m_Tests)
        {
            // TODO: Create an ImGui button labelled with test.first
            //       When clicked, call test.second() to invoke the factory
            //       and assign the returned pointer to m_CurrentTest
            //       Hint: ImGui::Button(test.first.c_str()) returns true on click
        }
    }
}

Task 3 - Wire the harness into main.cpp

Update src/Main.cpp to route every frame through the test harness. Follow the numbered TODO comments in the skeleton below in order.

  1. Declare test::Tests* currentTest = nullptr; just after ImGui_ImplOpenGL3_Init(). This pointer is what the main loop reads every frame to know which scene to dispatch to.
  2. Construct the menu: test::TestMenu* TestMenu = new test::TestMenu(currentTest);. Passing currentTest by reference gives the menu direct write access to the pointer in this scope so a button click can update it without a global variable.
  3. Set the initial active scene: currentTest = TestMenu;. Because TestMenu inherits Tests, the same dispatch block that runs other scenes will render the menu itself on the first frame with no special case.
  4. Inside the render loop, add the dispatch block from TODO 4. The guard if (currentTest != TestMenu) on the back button prevents the menu from deleting itself and leaving a dangling pointer.
  5. After the render loop, add cleanup using the two-step pattern from TODO 5: if (currentTest != TestMenu) delete currentTest; then delete TestMenu;. This avoids a double-free when the user closes the window while the menu is still the active scene.
  6. Build and run. The application should open a window with an ImGui Test control panel window containing no buttons yet. The harness is ready - tests will be added from Lab 16 onward.
src/Main.cpp
#include <GL/glew.h>
#include <GLFW/glfw3.h>
#include <iostream>

#include "Renderer.h"
#include "tests/Tests.h"

#include "vendor/imgui/imgui.h"
#include "vendor/imgui/imgui_impl_glfw.h"
#include "vendor/imgui/imgui_impl_opengl3.h"

#define IMGUI_IMPL_OPENGL_LOADER_GLEW

int main()
{
    if (!glfwInit()) return -1;

    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);

    GLFWwindow* window = glfwCreateWindow(1280, 720, "OpenGL", NULL, NULL);
    if (!window) { glfwTerminate(); return -1; }
    glfwMakeContextCurrent(window);

    glewExperimental = GL_TRUE;
    if (glewInit() != GLEW_OK) { glfwTerminate(); return -1; }

    glfwSwapInterval(1);

    {
        Renderer renderer;

        ImGui::CreateContext();
        ImGui_ImplGlfw_InitForOpenGL(window, true);
        ImGui::StyleColorsDark();
        ImGui_ImplOpenGL3_Init();

        // TODO 1: Declare the active-test pointer
        //   test::Tests* currentTest = nullptr;

        // TODO 2: Create the TestMenu, passing currentTest by reference
        //   test::TestMenu* TestMenu = new test::TestMenu(currentTest);

        // TODO 3: Set the menu as the initial active scene
        //   currentTest = TestMenu;

        // Tests will be registered in future labs. The syntax is:
        //   TestMenu->RegisterTest<test::SomeTest>("Label");
        //   TestMenu->RegisterTest<test::SomeTest>("Label", arg1, arg2);

        float lastFrameTime = 0.0f;

        while (!glfwWindowShouldClose(window))
        {
            float now = (float)glfwGetTime();
            float deltaTime = now - lastFrameTime;
            lastFrameTime = now;

            renderer.Clear();

            ImGui_ImplOpenGL3_NewFrame();
            ImGui_ImplGlfw_NewFrame();
            ImGui::NewFrame();

            // TODO 4: Add the dispatch block:
            //
            //   if (currentTest)
            //   {
            //       currentTest->Update(deltaTime);
            //       currentTest->Render();
            //       ImGui::Begin("Test control panel");
            //       if (currentTest != TestMenu && ImGui::Button("<-"))
            //       {
            //           delete currentTest;
            //           currentTest = TestMenu;
            //       }
            //       currentTest->RenderGUI();
            //       ImGui::End();
            //   }

            ImGui::Render();
            ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
            glfwSwapBuffers(window);
            glfwPollEvents();
        }

        // TODO 5: Clean up - avoid a double-free when the menu is still active
        //   if (currentTest != TestMenu)
        //       delete currentTest;
        //   delete TestMenu;
    }

    ImGui_ImplGlfw_Shutdown();
    glfwDestroyWindow(window);
    glfwTerminate();
    return 0;
}

Solution

Try the tasks above before revealing the solution.