xo-umbrella2/xo-indentlog2/utest/LogBuffer.test.cpp
Roland Conybeare ca846d00e3 xo-arena stack: backpointer to DArena from storage.
Refactor everything above DArena for less fragility
2026-09-19 15:25:10 -04:00

333 lines
12 KiB
C++

/** @file LogBuffer.test.cpp
*
* @author Roland Conybeare, Jul 2026
**/
#include "LogBuffer.hpp"
#include <xo/testutil/try_test_array.hpp>
#include <xo/arena/ArenaConfig.hpp>
#include <catch2/catch.hpp>
#include <cstring>
#include <sstream>
#include <string>
#include <vector>
namespace ut {
using xo::LogBuffer;
using xo::mm::ArenaConfig;
using xo::UtestRehearser;
using xo::flatstring;
using std::vector;
using std::size_t;
/** Unit test setup.
* Test sequence:
* write text_ into a fresh LogBuffer via write_span(),
* then expect:
* - used_span() holds exactly the bytes of text_
* - lpos() (column on current line) == exp_lpos_
**/
struct TestCase_LogBuffer {
TestCase_LogBuffer(std::vector<const char *> text_v,
size_t exp_used,
size_t exp_lpos,
size_t exp_viz_lpos)
: text_v_{text_v},
exp_used_{exp_used},
exp_lpos_{exp_lpos},
exp_viz_lpos_{exp_viz_lpos}
{}
/** text written to the buffer **/
std::vector<const char *> text_v_;
/** expected number of chars used in buffer **/
size_t exp_used_ = 0;
/** expected column (local_ppos - solpos) after writing text_ **/
size_t exp_lpos_ = 0;
/** expected visible column
* (local_npos - solpos - color_escape_chars) after writing text_
**/
size_t exp_viz_lpos_ = 0;
};
vector<TestCase_LogBuffer>
s_logbuffer_testcase_v = {
/* test case: trivial input */
TestCase_LogBuffer({""}, 0, 0, 0),
/* test case: trivial input */
TestCase_LogBuffer({"", "", "", ""}, 0, 0, 0),
/* test case: single line, no newline: column advances by #chars */
TestCase_LogBuffer({"abc", "def", "ghi"}, 9, 9, 9),
/* test case: two lines: column counts from char after the '\n' */
TestCase_LogBuffer({"hello\nworld"}, 11, 5, 5),
/* test case: color escape */
TestCase_LogBuffer({"\033[0m"}, 4, 4, 0),
/* test case: color escape */
TestCase_LogBuffer({"\033[31;10m"}, 8, 8, 0),
/* test case: color escape */
TestCase_LogBuffer({"\033[38;5;64m"}, 10, 10, 0),
/* test case: color escape */
TestCase_LogBuffer({"\033[38;2;80;80;80m"}, 16, 16, 0),
};
void
logbuffer_test_fn(const TestCase_LogBuffer & tc,
UtestRehearser * p_rh)
{
ArenaConfig cfg { .name_ = flatstring("utest.LogBuffer"),
.size_ = 64*1024 };
LogBuffer buf(cfg, p_rh->enable_debug());
REHEARSE(*p_rh, buf.debug_flag() == p_rh->enable_debug());
/* concatenation of all writes -- expected buffer contents */
std::ostringstream expected_ss;
for (const char * text : tc.text_v_) {
buf.write_span(LogBuffer::ConstSpan::from_cstr(text));
expected_ss << text;
}
std::string expected = expected_ss.str();
LogBuffer::Span used = buf.char_used_span();
LogBuffer::Span committed = buf.char_committed_span();
LogBuffer::Span available = buf.char_available_span();
REHEARSE(*p_rh, used.is_subspan_of(committed));
REHEARSE(*p_rh, available.is_subspan_of(committed));
REHEARSE(*p_rh, used.hi() == available.lo());
REHEARSE(*p_rh, available.hi() == committed.hi());
/* refresh line accountant from buffered content (write_span leaves it dirty) */
buf._check_update_local_state(used.hi());
REHEARSE(*p_rh, used.size() == expected.size());
REHEARSE(*p_rh,
std::memcmp(used.lo(), expected.data(), expected.size()) == 0);
REHEARSE(*p_rh, buf.lpos() == tc.exp_lpos_);
REHEARSE(*p_rh, buf.viz_lpos() == tc.exp_viz_lpos_);
/** grow buffer (1byte ask will round up to 1page) **/
buf.char_expand_to(buf.char_committed_span().size() + 1);
LogBuffer::Span used2 = buf.char_used_span();
LogBuffer::Span committed2 = buf.char_committed_span();
LogBuffer::Span available2 = buf.char_available_span();
REHEARSE(*p_rh, used == used2);
REHEARSE(*p_rh, committed.size() < committed2.size());
REHEARSE(*p_rh, committed.is_subspan_of(committed2));
REHEARSE(*p_rh, used2.hi() == available.lo());
REHEARSE(*p_rh, available.hi() == committed.hi());
buf.reset_buffer();
REHEARSE(*p_rh, buf.char_used_span().empty());
REHEARSE(*p_rh, buf.char_used_span().is_subspan_of(buf.char_committed_span()));
REHEARSE(*p_rh, buf.char_available_span().is_subspan_of(buf.char_committed_span()));
REHEARSE(*p_rh, buf.lpos() == 0);
REHEARSE(*p_rh, buf.viz_lpos() == 0);
}
TEST_CASE("LogBuffer", "[LogBuffer]")
{
try_test_array(s_logbuffer_testcase_v,
&logbuffer_test_fn);
}
/** Drain semantics: with a streambuf attached via set_dest_sbuf(), the
* not-yet-flushed extent [bpptr_, pptr_) is copied out on flush()/
* reset_buffer() and the watermark advances, so nothing is re-emitted.
* With no dest attached, LogBuffer is a pure accumulator (existing test).
**/
TEST_CASE("LogBuffer-drain", "[LogBuffer]")
{
ArenaConfig cfg { .name_ = flatstring("utest.LogBuffer.drain"),
.size_ = 64*1024 };
auto wr = [](LogBuffer & b, const char * s) {
b.write_span(LogBuffer::ConstSpan::from_cstr(s));
};
SECTION("flush drains written bytes to dest") {
std::ostringstream oss;
LogBuffer buf(cfg, false);
buf.set_dest_sbuf(oss.rdbuf());
wr(buf, "hello");
buf.flush();
REQUIRE(oss.str() == "hello");
}
SECTION("watermark: successive flushes don't re-emit") {
std::ostringstream oss;
LogBuffer buf(cfg, false);
buf.set_dest_sbuf(oss.rdbuf());
wr(buf, "A");
buf.flush();
wr(buf, "B");
buf.flush(); /* must send only "B", not "AB" again */
REQUIRE(oss.str() == "AB");
}
SECTION("flush with nothing pending is a no-op") {
std::ostringstream oss;
LogBuffer buf(cfg, false);
buf.set_dest_sbuf(oss.rdbuf());
wr(buf, "x");
buf.flush();
buf.flush(); /* bpptr_ == pptr_: nothing to send */
REQUIRE(oss.str() == "x");
}
SECTION("reset_buffer flushes pending first, then rewinds") {
std::ostringstream oss;
LogBuffer buf(cfg, false);
buf.set_dest_sbuf(oss.rdbuf());
wr(buf, "hello"); /* no explicit flush */
buf.reset_buffer();
REQUIRE(oss.str() == "hello");
REQUIRE(buf.char_used_span().empty());
}
SECTION("reset_buffer after flush doesn't double-emit") {
std::ostringstream oss;
LogBuffer buf(cfg, false);
buf.set_dest_sbuf(oss.rdbuf());
wr(buf, "x");
buf.flush();
buf.reset_buffer();
REQUIRE(oss.str() == "x");
}
SECTION("newline_indent '\\n' is included in drained output") {
std::ostringstream oss;
LogBuffer buf(cfg, false);
buf.set_dest_sbuf(oss.rdbuf());
/* newline_indent advances pptr_ past the '\n' before the accounting
* call; the flush spans the whole extent so the '\n' is not lost.
*/
wr(buf, "a");
buf.newline_indent(2);
wr(buf, "b");
buf.reset_buffer();
REQUIRE(oss.str() == "a\n b");
}
SECTION("multi-record: dest accumulates across resets") {
std::ostringstream oss;
LogBuffer buf(cfg, false);
buf.set_dest_sbuf(oss.rdbuf());
wr(buf, "rec1");
buf.reset_buffer();
REQUIRE(buf.char_used_span().empty());
wr(buf, "rec2");
buf.reset_buffer();
REQUIRE(oss.str() == "rec1rec2");
}
SECTION("nullptr dest: pure buffer, flush is a no-op") {
LogBuffer buf(cfg, false); /* no dest attached */
wr(buf, "hello");
buf.flush(); /* no-op, no crash */
REQUIRE(buf.char_used_span().size() == 5);
buf.reset_buffer(); /* rewinds without draining */
REQUIRE(buf.char_used_span().empty());
}
SECTION("detach mid-stream: post-detach content not drained") {
std::ostringstream oss;
LogBuffer buf(cfg, false);
buf.set_dest_sbuf(oss.rdbuf());
wr(buf, "kept");
buf.flush();
buf.set_dest_sbuf(nullptr); /* detach */
wr(buf, "dropped");
buf.reset_buffer(); /* flush is now a no-op */
REQUIRE(oss.str() == "kept");
}
SECTION("drain survives in-place expand_to with watermark past origin") {
std::ostringstream oss;
LogBuffer buf(cfg, false);
buf.set_dest_sbuf(oss.rdbuf());
wr(buf, "pre");
buf.flush(); /* bpptr_ now past porigin_ */
std::size_t cap0 = buf.char_committed_span().size();
std::string big(cap0 + 1000, 'z'); /* exceeds capacity -> expand_to */
wr(buf, big.c_str());
buf.flush();
REQUIRE(buf.char_committed_span().size() > cap0); /* grew in place */
REQUIRE(oss.str() == "pre" + big); /* "pre" not re-sent */
}
SECTION("per-line reclaim: multi-line record drains all lines, buffer stays bounded") {
std::ostringstream oss;
std::ostringstream expect;
LogBuffer buf(cfg, false);
buf.set_dest_sbuf(oss.rdbuf());
/* with a dest attached, newline_indent() drains + reclaims each
* completed line, so a record of many lines never grows the buffer
* past its initial extent (total content here >> that extent).
*/
const std::size_t cap0 = buf.char_committed_span().size();
const char * line = "line-content-abcdefghijklmnopqrstuvwxyz"; /* 39 chars */
const int n = 1000;
for (int i = 0; i < n; ++i) {
wr(buf, line);
expect << line;
if (i + 1 < n) {
buf.newline_indent(2);
expect << "\n ";
}
}
buf.reset_buffer(); /* drains the final line */
REQUIRE(oss.str() == expect.str()); /* every line reached the sink */
REQUIRE(buf.char_committed_span().size() == cap0); /* buffer never had to grow */
}
SECTION("no reclaim without a dest: multi-line content accumulates in the buffer") {
LogBuffer buf(cfg, false); /* no dest attached */
/* pure-buffer contract: newline_indent must NOT reclaim, so all
* lines stay in the buffer for used_span()/output() to read.
*/
wr(buf, "aaa");
buf.newline_indent(2);
wr(buf, "bbb");
LogBuffer::Span used = buf.char_used_span();
REQUIRE(used.size() == 3 + 1 + 2 + 3); /* "aaa" "\n" " " "bbb" */
REQUIRE(std::string(used.lo(), used.hi()) == "aaa\n bbb");
}
}
}
/* end LogBuffer.test.cpp */