From d03f9ce51a0c132dea7d42ca95a68722bfa21e55 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 28 Sep 2026 21:40:37 +0200 Subject: [PATCH] test(gl): cover the loader's extension scanner and the handle and vertex-array failure paths --- tests/test_gl_context.cpp | 529 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 529 insertions(+) diff --git a/tests/test_gl_context.cpp b/tests/test_gl_context.cpp index 517321a..02fceae 100644 --- a/tests/test_gl_context.cpp +++ b/tests/test_gl_context.cpp @@ -16,9 +16,16 @@ #include #include #include +#include #include #include #include +#include + +#include +#include +#include +#include namespace { @@ -48,6 +55,124 @@ out vec4 o_color; void main() { o_color = this is not glsl; } )"; +/// Valid GLSL on both sides but an interface that cannot link: the fragment +/// reads a `vec3` where the vertex writes a `vec2`, so the link has to fail and +/// carry the driver's log through `Status::message`. +constexpr const char* kMismatchedVertexSrc = R"(#version 330 core +layout(location = 0) in vec2 a_pos; +out vec2 v_value; +void main() { v_value = a_pos; gl_Position = vec4(a_pos, 0.0, 1.0); } +)"; + +constexpr const char* kMismatchedFragmentSrc = R"(#version 330 core +in vec3 v_value; +out vec4 o_color; +void main() { o_color = vec4(v_value, 1.0); } +)"; + +/// Holds one GL entry point at a stub for as long as it lives, the render-smoke +/// stubs' save/restore pattern made generic over the slot's function-pointer +/// type so this file needs one class rather than one per entry point. The +/// pointer is restored by the destructor, which also runs while a failed +/// `PLOT_CHECK` unwinds, so a stub can never leak into the next case. +template +class EntryGuard { + public: + EntryGuard(Fn& slot, Fn value) noexcept : slot_(slot), saved_(slot) { slot_ = value; } + ~EntryGuard() { slot_ = saved_; } + EntryGuard(const EntryGuard&) = delete; + EntryGuard& operator=(const EntryGuard&) = delete; + + private: + Fn& slot_; + Fn saved_; +}; + +// --- the caps probe, driven by fake strings ---------------------------------- + +/// Reported by the stubbed `glGetString`; each case sets them before probing. +const char* g_fake_version = "2.1"; +const char* g_fake_extensions = ""; +bool g_fake_version_missing = false; + +const plot::gl::GLubyte* fake_get_string(plot::gl::GLenum name) noexcept { + if (name == plot::gl::GL_VERSION) { + return g_fake_version_missing ? nullptr + : reinterpret_cast(g_fake_version); + } + if (name == plot::gl::GL_EXTENSIONS) { + return g_fake_extensions == nullptr + ? nullptr + : reinterpret_cast(g_fake_extensions); + } + if (name == plot::gl::GL_RENDERER) { + return reinterpret_cast("fake renderer"); + } + if (name == plot::gl::GL_VENDOR) { + return reinterpret_cast("fake vendor"); + } + return nullptr; +} + +/// The indexed list's length and the profile mask the fake `glGetIntegerv` +/// reports; only the 3.x indexed path reads them. +void fake_get_integer_v(plot::gl::GLenum name, plot::gl::GLint* out) noexcept { + *out = name == plot::gl::GL_NUM_EXTENSIONS ? 2 : 0; // 0 = no core-profile bit +} + +/// What the stubbed `glGetStringi` reports: 0 = a null pointer, 1 = the +/// extension the probe looks for, 2 = some other one. +int g_fake_stringi_mode = 0; + +const plot::gl::GLubyte* fake_get_string_i(plot::gl::GLenum, plot::gl::GLuint) noexcept { + if (g_fake_stringi_mode == 0) { + return nullptr; + } + return reinterpret_cast( + g_fake_stringi_mode == 1 ? "GL_ARB_buffer_storage" : "GL_ARB_something_else"); +} + +// --- entry points that hand out no name, for the constructors' failure paths -- + +void zero_gen_buffers(plot::gl::GLsizei, plot::gl::GLuint* out) noexcept { *out = 0; } +void zero_gen_vertex_arrays(plot::gl::GLsizei, plot::gl::GLuint* out) noexcept { *out = 0; } +void zero_gen_textures(plot::gl::GLsizei, plot::gl::GLuint* out) noexcept { *out = 0; } +void zero_gen_framebuffers(plot::gl::GLsizei, plot::gl::GLuint* out) noexcept { *out = 0; } +plot::gl::GLuint zero_create_shader(plot::gl::GLenum) noexcept { return 0; } +plot::gl::GLuint zero_create_program() noexcept { return 0; } + +/// Probes caps once with the fake string table installed and `GL_EXTENSIONS` +/// reporting `extensions` (null meaning the query returns no string). The +/// `load()` first clears the cached probe *and* restores the real entry point +/// -- the previous probe's guard has already run -- so each call reads the stub. +/// Returning a copy keeps the probe valid after the guard restores the table. +plot::gl::Caps probe_legacy_caps(const char* extensions) noexcept { + plot::gl::load(nullptr); + EntryGuard guard(plot::gl::glGetString, &fake_get_string); + g_fake_version = "2.1"; + g_fake_version_missing = false; + g_fake_extensions = extensions; + return plot::gl::caps(); +} + +/// Probes caps once on a 3.2+ context, where the indexed list supplies the +/// names: `GL_NUM_EXTENSIONS` and the profile mask come from the fake +/// `glGetIntegerv`, each name from the fake `glGetStringi` (`stringi_mode`: +/// 0 = null pointer, 1 = the extension sought, 2 = another). +plot::gl::Caps probe_indexed_caps(int stringi_mode) noexcept { + plot::gl::load(nullptr); + EntryGuard string_guard(plot::gl::glGetString, + &fake_get_string); + EntryGuard int_guard(plot::gl::glGetIntegerv, + &fake_get_integer_v); + EntryGuard stringi_guard(plot::gl::glGetStringi, + &fake_get_string_i); + g_fake_version = "3.3"; + g_fake_version_missing = false; + g_fake_stringi_mode = stringi_mode; + return plot::gl::caps(); +} + } // namespace // Must stay the first GL case in this translation unit: `caps()` caches after @@ -61,6 +186,125 @@ PLOT_TEST(caps_before_load_is_zeroed) { PLOT_CHECK(caps.vendor != nullptr && caps.vendor[0] == '\0'); } +// The `kNoLoader` family and the `load()` failure branch need `available()` to +// be false, and `load()` is one-way and process-wide: once this suite has +// resolved the table -- the very next case does -- a failure cannot be produced +// in-process. So the whole case runs in a forked child, where poisoning the +// table poisons only its own copy. On a host that ships libGL.so.1 the only way +// left to make a required entry point unresolvable is to block `dlopen` by +// exhausting the file descriptors, which is what the child does first. +// +// It must run before `headless_context_provides_gl33`: after that case the +// process has opened libGL.so.1, and the excluded descriptors would no longer +// stop `dlopen` from handing the loader the table it wants. +PLOT_TEST(a_gl_failure_without_a_loader_is_safe_in_a_forked_child) { + std::fflush(nullptr); // so the child's exit() cannot re-flush the parent's stdout + const pid_t child = ::fork(); + PLOT_CHECK(child >= 0); + if (child == 0) { + int rc = 0; + try { + struct rlimit original {}; + PLOT_CHECK(::getrlimit(RLIMIT_NOFILE, &original) == 0); + struct rlimit lowered = original; + lowered.rlim_cur = original.rlim_max < 64 ? original.rlim_max : 64; + PLOT_CHECK(::setrlimit(RLIMIT_NOFILE, &lowered) == 0); + std::vector held; + int fd = -1; + while ((fd = ::open("/dev/null", O_RDONLY)) >= 0) { + held.push_back(fd); + } + // Every descriptor is spent, so dlopen("libGL.so.1") fails and no required + // entry point can be resolved: load() reports the first one it could not. + PLOT_CHECK(!plot::gl::load(nullptr)); + PLOT_CHECK(!plot::gl::available()); + + plot::Status st; + plot::gl::Buffer buffer; + PLOT_CHECK(!buffer.create(&st)); + PLOT_CHECK(st.code == plot::ErrorCode::Unsupported); + PLOT_CHECK(st.message != nullptr); + PLOT_CHECK(buffer.id() == 0); + plot::gl::VertexArray vao; + PLOT_CHECK(!vao.create(&st)); + PLOT_CHECK(st.code == plot::ErrorCode::Unsupported); + PLOT_CHECK(vao.id() == 0); + plot::gl::Program program = plot::gl::Program::from_source(kVertexSrc, kFragmentSrc, &st); + PLOT_CHECK(!st); + PLOT_CHECK(st.code == plot::ErrorCode::Unsupported); + PLOT_CHECK(program.id() == 0); + plot::gl::Texture rgba; + PLOT_CHECK(!rgba.create_rgba(4, 4, &st)); + PLOT_CHECK(st.code == plot::ErrorCode::Unsupported); + PLOT_CHECK(rgba.id() == 0); + plot::gl::Texture r8; + PLOT_CHECK(!r8.create_r8(4, 4, &st)); + PLOT_CHECK(st.code == plot::ErrorCode::Unsupported); + PLOT_CHECK(r8.id() == 0); + plot::gl::Framebuffer fbo; + PLOT_CHECK(!fbo.create(&st)); + PLOT_CHECK(st.code == plot::ErrorCode::Unsupported); + PLOT_CHECK(fbo.id() == 0); + plot::gl::Fence fence; + PLOT_CHECK(!fence.create(&st)); + PLOT_CHECK(st.code == plot::ErrorCode::Unsupported); + PLOT_CHECK(fence.id() == nullptr); + + // An empty object's guarded operations must not reach the null table. + plot::gl::Buffer empty_buffer; + empty_buffer.storage(16, plot::gl::GL_MAP_WRITE_BIT); // entry point null: no-op + plot::gl::Program empty_program; + empty_program.set_uniform_int("u_value", 1); // id 0: no-op + PLOT_CHECK(fence.wait(0)); // nothing in flight + + // Hand the descriptors back so the coverage dump at exit can open its file. + for (const int held_fd : held) { + ::close(held_fd); + } + PLOT_CHECK(::setrlimit(RLIMIT_NOFILE, &original) == 0); + } catch (...) { + rc = 1; + } + std::exit(rc); + } + int status = 0; + PLOT_CHECK(::waitpid(child, &status, 0) == child); + PLOT_CHECK(WIFEXITED(status)); + PLOT_CHECK(WEXITSTATUS(status) == 0); +} + +// `resolve()`'s second source, `dlsym(RTLD_DEFAULT, name)`, only answers when a +// GL library sits in the global symbol scope -- the usual arrangement on a host +// without glvnd's local dispatch. This host is the other kind, so the case +// promotes libGL into that scope itself. The promotion is one-way and +// process-wide, which is exactly why it happens in a forked child: the parent's +// namespace is untouched, and the child's exit() flushes the counters for the +// branch it walked. +PLOT_TEST(a_globally_visible_gl_library_satisfies_the_fallback_resolver) { + std::fflush(nullptr); + const pid_t child = ::fork(); + PLOT_CHECK(child >= 0); + if (child == 0) { + int rc = 0; + try { + void* handle = ::dlopen("libGL.so.1", RTLD_NOW | RTLD_GLOBAL); + // Not every host ships a GL library to promote; where there is none the + // case simply cannot reach the branch rather than pretending it did. + if (handle != nullptr && ::dlsym(RTLD_DEFAULT, "glGetString") != nullptr) { + PLOT_CHECK(plot::gl::load(nullptr)); + PLOT_CHECK(plot::gl::available()); + } + } catch (...) { + rc = 1; + } + std::exit(rc); + } + int status = 0; + PLOT_CHECK(::waitpid(child, &status, 0) == child); + PLOT_CHECK(WIFEXITED(status)); + PLOT_CHECK(WEXITSTATUS(status) == 0); +} + PLOT_TEST(headless_context_provides_gl33) { plottest::HeadlessContext ctx; plot::Status st; @@ -425,3 +669,288 @@ PLOT_TEST(text_pipeline_draws_a_glyph) { PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR); ctx.destroy(); } + +// The loader's legacy scanner: a 2.1 version string routes `caps()` down the +// pre-3.0 path, the only one that reads `GL_EXTENSIONS` as one space-separated +// string, so these probes go through `has_token` exactly as a real 2.1 driver +// would. The near-misses are the assertions that matter -- a `strstr`-only +// scanner would set the cap for both of them. +PLOT_TEST(legacy_extension_string_is_scanned_a_whole_token_at_a_time) { + PLOT_CHECK(probe_legacy_caps("GL_ARB_other GL_ARB_buffer_storage GL_ARB_more").buffer_storage); + // At the very start and the very end: the boundaries the scanner exists for. + PLOT_CHECK(probe_legacy_caps("GL_ARB_buffer_storage GL_ARB_more").buffer_storage); + PLOT_CHECK(probe_legacy_caps("GL_ARB_more GL_ARB_buffer_storage").buffer_storage); + // Prefix-only near-misses must not count. + PLOT_CHECK(!probe_legacy_caps("GL_ARB_buffer_storageX").buffer_storage); + PLOT_CHECK(!probe_legacy_caps("XGL_ARB_buffer_storage").buffer_storage); + // No string at all: the null guard. + PLOT_CHECK(!probe_legacy_caps(nullptr).buffer_storage); + + const plot::gl::Caps caps = probe_legacy_caps("GL_ARB_buffer_storage"); + PLOT_CHECK(caps.major == 2 && caps.minor == 1); // the stub really was read + PLOT_CHECK(caps.buffer_storage && caps.persistent_map); + PLOT_CHECK(std::strcmp(caps.renderer, "fake renderer") == 0); +} + +// The 3.0+ indexed path: `GL_NUM_EXTENSIONS` and the profile mask come from +// `glGetIntegerv`, each name from `glGetStringi`. A null `glGetStringi` means +// the list is unavailable, and the probe must then refuse -- the legacy string +// is illegal on 3.0+ core -- rather than quietly report no support. +PLOT_TEST(indexed_extension_list_is_probed_on_a_3x_context) { + PLOT_CHECK(probe_indexed_caps(1).buffer_storage); + PLOT_CHECK(!probe_indexed_caps(2).buffer_storage); // other names only + PLOT_CHECK(!probe_indexed_caps(0).buffer_storage); // the entries are null + { + plot::gl::load(nullptr); + EntryGuard string_guard(plot::gl::glGetString, + &fake_get_string); + EntryGuard int_guard(plot::gl::glGetIntegerv, + &fake_get_integer_v); + EntryGuard stringi_guard(plot::gl::glGetStringi, nullptr); + g_fake_version = "3.3"; + g_fake_version_missing = false; + g_fake_extensions = "GL_ARB_buffer_storage"; // present, but must not be consulted + PLOT_CHECK(!plot::gl::caps().buffer_storage); + } + const plot::gl::Caps caps = probe_indexed_caps(1); + PLOT_CHECK(caps.major == 3 && caps.minor == 3); +} + +// Neither a context that answers no version string nor one whose version does +// not parse may leave stale caps behind: both must report the zeroed probe. +PLOT_TEST(caps_are_zeroed_when_the_version_is_missing_or_unparsable) { + { + plot::gl::load(nullptr); + EntryGuard guard(plot::gl::glGetString, &fake_get_string); + g_fake_version_missing = true; + const plot::gl::Caps caps = plot::gl::caps(); + PLOT_CHECK(caps.major == 0 && caps.minor == 0); + PLOT_CHECK(caps.renderer != nullptr && caps.renderer[0] == '\0'); + } + { + plot::gl::load(nullptr); + EntryGuard guard(plot::gl::glGetString, &fake_get_string); + g_fake_version_missing = false; + g_fake_version = "not a version"; + const plot::gl::Caps caps = plot::gl::caps(); + PLOT_CHECK(caps.major == 0 && caps.minor == 0); + } + g_fake_version = "2.1"; + g_fake_version_missing = false; + g_fake_extensions = ""; +} + +// Every fallible constructor's refusal branches: a driver that hands out name 0 +// (`GLFailure`), a table with the entry point absent (`Unsupported`), and the +// argument checks. Each must report the documented code and leave an object +// that is empty yet safe to destroy. The stubs save and restore the slot, so a +// failure cannot leak into the next block. +PLOT_TEST(fallible_handles_report_a_refused_driver_name_and_stay_empty) { + plottest::HeadlessContext ctx; + plot::Status st; + PLOT_CHECK(ctx.create(&st)); + PLOT_CHECK(plot::gl::load(egl_loader())); + + { + EntryGuard guard(plot::gl::glGenBuffers, &zero_gen_buffers); + plot::gl::Buffer buffer; + PLOT_CHECK(!buffer.create(&st)); + PLOT_CHECK(st.code == plot::ErrorCode::GLFailure); + PLOT_CHECK(std::strcmp(st.message, "glGenBuffers returned 0") == 0); + PLOT_CHECK(buffer.id() == 0); + } + { + EntryGuard guard(plot::gl::glGenBuffers, nullptr); + plot::gl::Buffer buffer; + PLOT_CHECK(!buffer.create(&st)); + PLOT_CHECK(st.code == plot::ErrorCode::Unsupported); + PLOT_CHECK(buffer.id() == 0); + } + { + EntryGuard guard(plot::gl::glGenVertexArrays, + &zero_gen_vertex_arrays); + plot::gl::VertexArray vao; + PLOT_CHECK(!vao.create(&st)); + PLOT_CHECK(st.code == plot::ErrorCode::GLFailure); + PLOT_CHECK(std::strcmp(st.message, "glGenVertexArrays returned 0") == 0); + PLOT_CHECK(vao.id() == 0); + } + { + EntryGuard guard(plot::gl::glGenVertexArrays, nullptr); + plot::gl::VertexArray vao; + PLOT_CHECK(!vao.create(&st)); + PLOT_CHECK(st.code == plot::ErrorCode::Unsupported); + PLOT_CHECK(vao.id() == 0); + } + { + EntryGuard guard(plot::gl::glGenTextures, + &zero_gen_textures); + plot::gl::Texture tex; + PLOT_CHECK(!tex.create_rgba(4, 4, &st)); + PLOT_CHECK(st.code == plot::ErrorCode::GLFailure); + PLOT_CHECK(std::strcmp(st.message, "glGenTextures returned 0") == 0); + PLOT_CHECK(tex.id() == 0); + PLOT_CHECK(!tex.create_r8(4, 4, &st)); + PLOT_CHECK(st.code == plot::ErrorCode::GLFailure); + PLOT_CHECK(tex.id() == 0); + } + { + EntryGuard guard(plot::gl::glGenTextures, nullptr); + plot::gl::Texture tex; + PLOT_CHECK(!tex.create_rgba(4, 4, &st)); + PLOT_CHECK(st.code == plot::ErrorCode::Unsupported); + PLOT_CHECK(!tex.create_r8(4, 4, &st)); + PLOT_CHECK(st.code == plot::ErrorCode::Unsupported); + } + { + plot::gl::Texture tex; + PLOT_CHECK(!tex.create_rgba(0, 4, &st)); + PLOT_CHECK(st.code == plot::ErrorCode::InvalidArgument); + PLOT_CHECK(!tex.create_r8(4, 0, &st)); + PLOT_CHECK(st.code == plot::ErrorCode::InvalidArgument); + PLOT_CHECK(tex.id() == 0); + } + { + EntryGuard guard(plot::gl::glGenFramebuffers, + &zero_gen_framebuffers); + plot::gl::Framebuffer fbo; + PLOT_CHECK(!fbo.create(&st)); + PLOT_CHECK(st.code == plot::ErrorCode::GLFailure); + PLOT_CHECK(std::strcmp(st.message, "glGenFramebuffers returned 0") == 0); + PLOT_CHECK(fbo.id() == 0); + } + { + EntryGuard guard(plot::gl::glGenFramebuffers, nullptr); + plot::gl::Framebuffer fbo; + PLOT_CHECK(!fbo.create(&st)); + PLOT_CHECK(st.code == plot::ErrorCode::Unsupported); + PLOT_CHECK(fbo.id() == 0); + } + { + EntryGuard guard(plot::gl::glFenceSync, nullptr); + plot::gl::Fence fence; + PLOT_CHECK(!fence.create(&st)); + PLOT_CHECK(st.code == plot::ErrorCode::Unsupported); + PLOT_CHECK(fence.id() == nullptr); + } + + // Program: a table without glCreateShader, a null source, a shader the driver + // refuses to create and a program it refuses to create. + { + EntryGuard guard(plot::gl::glCreateShader, nullptr); + plot::Status bad; + plot::gl::Program program = plot::gl::Program::from_source(kVertexSrc, kFragmentSrc, &bad); + PLOT_CHECK(!bad); + PLOT_CHECK(bad.code == plot::ErrorCode::Unsupported); + PLOT_CHECK(program.id() == 0); + } + { + plot::Status bad; + plot::gl::Program program = plot::gl::Program::from_source(nullptr, kFragmentSrc, &bad); + PLOT_CHECK(!bad); + PLOT_CHECK(bad.code == plot::ErrorCode::InvalidArgument); + PLOT_CHECK(program.id() == 0); + } + { + EntryGuard guard(plot::gl::glCreateShader, + &zero_create_shader); + plot::Status bad; + plot::gl::Program program = plot::gl::Program::from_source(kVertexSrc, kFragmentSrc, &bad); + PLOT_CHECK(!bad); + PLOT_CHECK(bad.code == plot::ErrorCode::GLFailure); + PLOT_CHECK(std::strcmp(bad.message, "glCreateShader returned 0") == 0); + PLOT_CHECK(program.id() == 0); + } + { + EntryGuard guard(plot::gl::glCreateProgram, + &zero_create_program); + plot::Status bad; + plot::gl::Program program = plot::gl::Program::from_source(kVertexSrc, kFragmentSrc, &bad); + PLOT_CHECK(!bad); + PLOT_CHECK(bad.code == plot::ErrorCode::GLFailure); + PLOT_CHECK(std::strcmp(bad.message, "glCreateProgram returned 0") == 0); + PLOT_CHECK(program.id() == 0); + } + plot::gl::Program empty; + empty.set_uniform_int("u_value", 1); // an empty program's setter is a no-op + PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR); + ctx.destroy(); +} + +// A program whose stages each compile but cannot be linked together: the +// constructor has to delete the program and report the driver's own log, not a +// bare failure. This is the path the link-failure branch implements. +PLOT_TEST(a_program_that_cannot_link_reports_the_driver_log) { + plottest::HeadlessContext ctx; + plot::Status st; + PLOT_CHECK(ctx.create(&st)); + PLOT_CHECK(plot::gl::load(egl_loader())); + + plot::Status bad; + plot::gl::Program program = + plot::gl::Program::from_source(kMismatchedVertexSrc, kMismatchedFragmentSrc, &bad); + PLOT_CHECK(!bad); + PLOT_CHECK(bad.code == plot::ErrorCode::GLFailure); + PLOT_CHECK(bad.message != nullptr); + PLOT_CHECK(std::strncmp(bad.message, "program link failed", std::strlen("program link failed")) == + 0); + // The driver's log follows the prefix: a real reason, not an empty tail. Mesa's + // linker names the mismatched varying. + PLOT_CHECK(std::strlen(bad.message) > std::strlen("program link failed: ")); + PLOT_CHECK(std::strstr(bad.message, "v_value") != nullptr); + PLOT_CHECK(program.id() == 0); + ctx.destroy(); +} + +// A vertex array that could not be fully created must not be left behind: the +// pair fails when the vertex array itself is refused, and again when the +// instance buffer is refused after the vertex array exists, and in both cases +// the object reports nothing to draw. +PLOT_TEST(vertex_arrays_leave_nothing_behind_when_create_fails) { + plottest::HeadlessContext ctx; + plot::Status st; + PLOT_CHECK(ctx.create(&st)); + PLOT_CHECK(plot::gl::load(egl_loader())); + + { + EntryGuard guard(plot::gl::glGenVertexArrays, + &zero_gen_vertex_arrays); + plot::Status bad; + plot::render::LineVertexArray lines; + PLOT_CHECK(!lines.create(&bad)); + PLOT_CHECK(!lines.valid()); + plot::render::TextVertexArray glyphs; + PLOT_CHECK(!glyphs.create(&bad)); + PLOT_CHECK(!glyphs.valid()); + } + { + EntryGuard guard(plot::gl::glGenBuffers, &zero_gen_buffers); + plot::Status bad; + plot::render::LineVertexArray lines; + PLOT_CHECK(!lines.create(&bad)); + PLOT_CHECK(!lines.valid()); + plot::render::TextVertexArray glyphs; + PLOT_CHECK(!glyphs.create(&bad)); + PLOT_CHECK(!glyphs.valid()); + } + PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR); + ctx.destroy(); +} + +// An empty batch -- a null pointer or a count of zero, with no vertex array +// created -- stages nothing and draws nothing, without touching the GL table. +PLOT_TEST(vertex_arrays_stage_nothing_for_an_empty_batch) { + plot::render::LineVertexArray lines; + lines.upload(nullptr, 3); + PLOT_CHECK(lines.instance_count() == 0); + const f32 segments[4] = {0.0f, 0.0f, 1.0f, 1.0f}; + lines.upload(segments, 0); + PLOT_CHECK(lines.instance_count() == 0); + + plot::render::TextVertexArray glyphs; + glyphs.upload(nullptr, 4); + PLOT_CHECK(glyphs.instance_count() == 0); + const f32 glyph_data[6] = {0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f}; + glyphs.upload(glyph_data, 0); + PLOT_CHECK(glyphs.instance_count() == 0); +}