xo-umbrella2/xo-pystringtable2/utest/test_pystringtable2.py

229 lines
9.1 KiB
Python

"""Unit tests for xo.stringtable2: subsystem configuration, and String.
Run by ctest through the generated xo-python wrapper, which is what puts the
extension modules on PYTHONPATH -- so a failure here is equally a failure of
that wrapper. See xo_emit_python_wrapper() in xo-cmake.
NB configuration is process-global and one-shot: xo.stringtable2.configure()
throws on a second call. So every case that needs a DIFFERENT configuration
runs in its own interpreter (see ConfigurationContractTestCase, which spawns
subprocesses); everything else shares the one established in setUpModule.
"""
import subprocess
import sys
import unittest
import xo.indentlog2 as il
import xo.facet as f
import xo.stringtable2 as st
FACET_CX = None
STRINGTABLE2_CX = None
def setUpModule():
global FACET_CX, STRINGTABLE2_CX
FACET_CX = f.configure_all()
STRINGTABLE2_CX = st.configure(st.Stringtable2Config(), FACET_CX)
class ImportTestCase(unittest.TestCase):
"""the wrapper puts every module this suite needs within reach"""
def test_modules_import(self):
for mod in (il, f, st):
self.assertTrue(hasattr(mod, "__file__"), mod)
def test_expected_types_are_registered(self):
self.assertTrue(hasattr(st, "Stringtable2Config"))
self.assertTrue(hasattr(st, "Stringtable2Appcx"))
self.assertTrue(hasattr(st, "String"))
def test_imported_types_are_reachable(self):
"""String.make and String.pretty name types this module does not own
AllocFlywheel comes from xo.facet, PpSink from xo.indentlog2, and
pybind permits one registration per c++ type -- so pystringtable2
imports both at init rather than registering them. Without those
imports the signatures below would not resolve.
"""
self.assertTrue(hasattr(f, "AllocFlywheel"))
self.assertTrue(hasattr(il, "PrettySink"))
class ConfigurationTestCase(unittest.TestCase):
"""configure() reports what it actually established"""
def test_context_type(self):
self.assertEqual(type(STRINGTABLE2_CX).__name__, "Stringtable2Appcx")
def test_config_is_readable(self):
"""without this, configuration is write-only from python
Stringtable2Config carries no settings and is not expected to gain any:
a StringTable's capacity belongs to whoever constructs one. So unlike
the equivalent in xo-pyreflect/utest, this one is not waiting to get
teeth -- it asserts the accessor exists and that is all there is.
"""
self.assertEqual(type(STRINGTABLE2_CX.config()).__name__,
"Stringtable2Config")
def test_visit_pools_is_empty_and_stays_empty(self):
"""xo-stringtable2 owns no pool, and this is not a placeholder
Distinct from ReflectAppcx.visit_pools and PrintJsonAppcx.visit_pools,
which are empty pending a DArena refactor and should start reporting
one day. This one should not: a StringTable has pools and its own
visit_pools, but it is owned by its constructor's caller. If this ever
stops being empty, something has taken ownership it was not meant to.
"""
self.assertEqual(STRINGTABLE2_CX.visit_pools(), [])
class StringTestCase(unittest.TestCase):
"""copying a string into an arena, and getting it back out
Mirrors FloatTestCase in xo-pyobject2/utest, which is the reference for how
a representation is bound (.xo-backlog/pyobject2/spec.md).
"""
def setUp(self):
self.fw = f.AllocFlywheel.make_default_app(FACET_CX)
def test_value_round_trips(self):
for v in ("", "a", "hello", "x" * 200):
self.assertEqual(st.String.make(self.fw, v).value(), v)
def test_str_agrees_with_value(self):
s = st.String.make(self.fw, "hello")
self.assertEqual(str(s), s.value())
def test_repr(self):
"""unquoted, like Float's -- __repr__ is the pretty rendering
Worth pinning because it is the surprising half: repr() of a python str
would quote. This one goes through TempPpSink, so it reports what the
object prints as, not what it would be typed as.
"""
self.assertEqual(repr(st.String.make(self.fw, "hello")), "hello")
def test_pretty_into_a_sink(self):
sink = il.PrettySink.make2str(il.PpConfig.scratch_plain(80))
st.String.make(self.fw, "hello").pretty(sink)
self.assertEqual(sink.output(), "hello")
def test_a_sink_accumulates(self):
"""pretty() must not complete the record, or composition breaks"""
sink = il.PrettySink.make2str(il.PpConfig.scratch_plain(80))
for v in ("ab", "cd", "ef"):
st.String.make(self.fw, v).pretty(sink)
self.fw.pretty(sink)
self.assertEqual(sink.output(), "abcdef<AllocFlywheel>")
def test_capacity_is_len_plus_nul(self):
"""DString reserves size+1 chars: the null terminator is real storage
_from_view_aux sets capacity_ = len + 1 (DString.cpp). So for anything
make() produces this accessor is derivable, and it only becomes
interesting against DString::empty(mm, cap), which python cannot reach.
"""
for v in ("", "a", "hello"):
self.assertEqual(st.String.make(self.fw, v).capacity(), len(v) + 1)
def test_len_is_bytes_not_characters(self):
"""the one place this wrapper can mislead
__len__ reports DString's byte extent; len(s.value()) counts python
codepoints. They agree for ascii and diverge for anything else, so a
case with both is the only way to keep the distinction honest.
"""
ascii_s = st.String.make(self.fw, "hello")
self.assertEqual(len(ascii_s), len(ascii_s.value()))
multi = st.String.make(self.fw, "\u00e9\u4e2d")
self.assertEqual(len(multi.value()), 2) # codepoints
self.assertEqual(len(multi), 5) # utf-8 bytes
def test_embedded_nul_survives(self):
"""size_ is the authority on extent, not the null terminator
The python half of what xo-stringtable2/utest/json_render.test.cpp pins
for the json printer: an accessor reading through
operator std::string_view() would truncate to "a" here.
"""
s = st.String.make(self.fw, "a\x00b")
self.assertEqual(s.value(), "a\x00b")
self.assertEqual(len(s), 3)
def test_handle_keeps_the_flywheel_alive(self):
"""the object outlives every other reference to its arena"""
import gc
x = st.String.make(self.fw, "survives")
self.fw = None
gc.collect()
self.assertEqual(x.value(), "survives")
class ConfigurationContractTestCase(unittest.TestCase):
"""cases needing a pristine process -- configuration is one-shot"""
PREAMBLE = ("import gc, weakref\n"
"import xo.facet as f, xo.stringtable2 as st\n"
"fcx = f.configure_all()\n")
def run_in_fresh_interpreter(self, body):
return subprocess.run([sys.executable, "-c", self.PREAMBLE + body],
capture_output=True, text=True)
def test_context_is_owned_by_the_caller(self):
"""configure() hands ownership to python; the module keeps nothing"""
res = self.run_in_fresh_interpreter(
"print('ST_ACCESSOR', hasattr(st, 'appcx'))\n"
"cx = st.configure(st.Stringtable2Config(), fcx)\n"
"print('OWNED', type(cx).__name__)\n")
self.assertIn("ST_ACCESSOR False", res.stdout)
self.assertIn("OWNED Stringtable2Appcx", res.stdout)
def test_second_configure_raises(self):
"""facet registration is process-wide, so a second context could not
register this subsystem's (facet, impl) pairs differently
"""
res = self.run_in_fresh_interpreter(
"st.configure(st.Stringtable2Config(), fcx)\n"
"try:\n"
" st.configure(st.Stringtable2Config(), fcx)\n"
" print('NO_RAISE')\n"
"except RuntimeError as e:\n"
" print('RAISED', 'already configured' in str(e))\n")
self.assertIn("RAISED True", res.stdout)
self.assertNotIn("NO_RAISE", res.stdout)
def test_context_keeps_its_dependency_alive(self):
"""keep_alive<0,2>: Stringtable2Appcx holds `const FacetAppcx &', so
dropping the caller's handle must not collect it
Observed through a weakref, not by reading the context afterwards:
pybind holds the patient in its own internals map, which python's gc
cannot see, so a weakref on the python object is the only observable
that discriminates.
Confirmed to discriminate by rebuilding xo_pystringtable2 with the
keep_alive removed: ALIVE then reports False.
"""
res = self.run_in_fresh_interpreter(
"w = weakref.ref(fcx)\n"
"stcx = st.configure(st.Stringtable2Config(), fcx)\n"
"del fcx\n"
"gc.collect()\n"
"print('ALIVE', w() is not None)\n"
"del stcx\n"
"gc.collect()\n"
"print('RELEASED', w() is None)\n")
self.assertIn("ALIVE True", res.stdout)
self.assertIn("RELEASED True", res.stdout)
if __name__ == "__main__":
unittest.main()