420 lines
19 KiB
Python
420 lines
19 KiB
Python
"""Check the explicit preference wire layout and bounded STM32 staging."""
|
|
from pathlib import Path
|
|
import os
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
|
|
from test_companion_preferences_transaction import HARNESS, esp_recovery_helpers
|
|
from test_radio_receive_contract import method
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
# Published field order and sizes, independent of the implementation's emitter
|
|
# and the class's in-memory member order or padding. None represents zero pad.
|
|
WIRE_FIELDS = (
|
|
('airtime_factor', 4), ('node_name', 32), (None, 4),
|
|
('@lat', 8), ('@lon', 8), ('freq', 4), ('sf', 1), ('cr', 1),
|
|
('client_repeat', 1), ('manual_add_contacts', 1), ('bw', 4),
|
|
('tx_power_dbm', 1), ('telemetry_mode_base', 1), ('telemetry_mode_loc', 1),
|
|
('telemetry_mode_env', 1), ('rx_delay_base', 4), ('advert_loc_policy', 1),
|
|
('multi_acks', 1), ('path_hash_mode', 1), (None, 1), ('ble_pin', 4),
|
|
('buzzer_quiet', 1), ('gps_enabled', 1), ('gps_interval', 4),
|
|
('autoadd_config', 1), ('autoadd_max_hops', 1), ('rx_boosted_gain', 1),
|
|
('default_scope_name', 31), ('default_scope_key', 16),
|
|
('radio_fem_rxgain', 1), ('radio_fem_rxgain_override', 1),
|
|
('vibe_quiet', 1), ('radio_fem_txgain', 1), ('rx_powersaving_enabled', 1),
|
|
('rx_ps_rx_us', 4), ('rx_ps_sleep_us', 4), ('rx_ps_level', 1),
|
|
('rx_ps_preamble', 1), ('powersaving_enabled', 1), ('wifi_enabled', 1),
|
|
('powersaving_policy_version', 1), ('usb_logging_enabled', 1),
|
|
('bluetooth_name', 32), ('display_rotation_degrees', 2), ('cad_enabled', 1),
|
|
('cad_scan_timeout_ms', 2), ('cad_retry_delay_ms', 2),
|
|
('cad_max_duration_ms', 2), ('bluetooth_mac_mode', 1), ('bluetooth_mac', 6),
|
|
('bluetooth_stealth_peer_type', 1), ('bluetooth_stealth_peer', 6),
|
|
('bluetooth_stealth_mode', 1), ('tx_delay_factor', 4),
|
|
('direct_tx_delay_factor', 4), ('interference_threshold', 1),
|
|
('agc_reset_interval', 1), ('tz_offset', 1),
|
|
)
|
|
TAIL_FIELDS = (
|
|
('flood_retry_attempts', 1), ('flood_retry_max_path', 1),
|
|
('flood_retry_group_max_path', 1), ('flood_retry_advert_enabled', 1),
|
|
('one_key_dm_enabled', 1), ('bluetooth_enabled', 1),
|
|
('gps_sync_interval_hours', 2), ('usb_debug_enabled', 1),
|
|
('lost_reply', 1),
|
|
)
|
|
OPTIONAL_FIELDS = (
|
|
('defined(TBEAM_1W)', (('fan_mode', 5), ('fan_lo', 1), ('fan_hi', 1))),
|
|
('defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)',
|
|
(('wifi_ssid', 33), ('wifi_pwd', 64))),
|
|
)
|
|
|
|
TESTS = r'''
|
|
#include <algorithm>
|
|
#include <vector>
|
|
|
|
static void seed(CompanionNodePrefs& prefs, uint32_t& random) {
|
|
auto fill = [&](void* destination, size_t size) {
|
|
auto* bytes = static_cast<uint8_t*>(destination);
|
|
for (size_t i = 0; i < size; ++i) {
|
|
random = random * 1664525u + 1013904223u;
|
|
bytes[i] = static_cast<uint8_t>(random >> 24);
|
|
}
|
|
};
|
|
@SEED@
|
|
prefs.tx_media = random % 5;
|
|
prefs.tx_ssh = (random >> 8) % 5;
|
|
}
|
|
|
|
static std::vector<uint8_t> golden(const CompanionNodePrefs& prefs,
|
|
double lat, double lon) {
|
|
std::vector<uint8_t> bytes;
|
|
auto append = [&](const void* data, size_t size) {
|
|
const auto* first = static_cast<const uint8_t*>(data);
|
|
bytes.insert(bytes.end(), first, first + size);
|
|
};
|
|
@GOLDEN@
|
|
#if defined(TBEAM_1W)
|
|
assert(bytes.size() == 243);
|
|
#elif defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
|
|
assert(bytes.size() == 333);
|
|
#else
|
|
assert(bytes.size() == 236);
|
|
#endif
|
|
// Independent wire definition of the new checked traffic footer. Keep
|
|
// every historical byte above unchanged and encode the public policy tags.
|
|
bytes.insert(bytes.end(), {'T', 'X', 'R', 1,
|
|
uint8_t(prefs.tx_media ? 0xa0 | prefs.tx_media : 0),
|
|
uint8_t(prefs.tx_ssh ? 0xa0 | prefs.tx_ssh : 0)});
|
|
uint16_t crc = 0xffff;
|
|
for(size_t i=bytes.size()-6;i<bytes.size();++i){
|
|
crc ^= uint16_t(bytes[i]) << 8;
|
|
for(unsigned bit=0;bit<8;++bit)
|
|
crc = (crc&0x8000) ? uint16_t((crc<<1)^0x1021) : uint16_t(crc<<1);
|
|
}
|
|
bytes.push_back(uint8_t(crc>>8));bytes.push_back(uint8_t(crc));
|
|
return bytes;
|
|
}
|
|
|
|
static void unchanged(const DataStore& store,
|
|
const std::vector<uint8_t>& before) {
|
|
assert(store.fs.files.at("/new_prefs") == before);
|
|
assert(!store.fs.exists("/new_prefs.tmp"));
|
|
}
|
|
|
|
int main() {
|
|
uint32_t random = 0x31415926;
|
|
unsigned comparisons = 0;
|
|
for (unsigned trial = 0; trial < 512; ++trial) {
|
|
CompanionNodePrefs prefs;
|
|
// Exercise every persisted byte, not a memcpy of the nontrivial class.
|
|
// Raw invalid values are intentionally retained by the writer; validation
|
|
// and migration remain the loader/CLI's existing responsibility.
|
|
seed(prefs, random);
|
|
DataStore store;
|
|
assert(store.savePrefs(prefs, 47.125, -122.25));
|
|
assert(store.fs.files.at("/new_prefs") == golden(prefs, 47.125, -122.25));
|
|
assert(!store.fs.exists("/new_prefs.tmp"));
|
|
++comparisons;
|
|
}
|
|
|
|
CompanionNodePrefs original;
|
|
assert(original.lost_reply == 0);
|
|
assert(original.tx_media == mesh::RADIO_TX_AUTO && original.tx_ssh == mesh::RADIO_TX_AUTO);
|
|
strcpy(original.node_name, "durable image");
|
|
original.freq = 915.25f; original.ble_pin = 123456;
|
|
original.bluetooth_enabled = 1; original.usb_debug_enabled = 1;
|
|
original.gps_sync_interval_hours = 336;
|
|
original.lost_reply = 2;
|
|
original.tx_media = mesh::RADIO_TX_SECONDARY;
|
|
original.tx_ssh = mesh::RADIO_TX_OFF;
|
|
DataStore initial;
|
|
assert(initial.savePrefs(original, 47.125, -122.25));
|
|
const auto before = golden(original, 47.125, -122.25);
|
|
const size_t historical_size=before.size()-mesh::companion::traffic::FooterSize;
|
|
assert(initial.fs.files.at("/new_prefs") == before);
|
|
|
|
// Every complete historical wire boundary stays readable, and an old
|
|
// image always disables the new opt-in response even if live RAM was on.
|
|
const std::vector<size_t> legacy_sizes = {
|
|
84, 85, 90, 91, 92, 93, 140, 141, 142, 143, 144, 155,
|
|
156, 157, 158, 159, 191, 193, 200, 207, 214, 215, 226,
|
|
#if defined(TBEAM_1W)
|
|
// The generic 231-byte compatibility boundary also ends at TBeam's
|
|
// complete fan-mode string; preserve that existing accepted image.
|
|
231, 233, 237, 238, 239, 241, 242
|
|
#elif defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
|
|
323, 327, 328, 329, 331, 332
|
|
#else
|
|
230, 231, 232, 234, 235
|
|
#endif
|
|
};
|
|
for (size_t length : legacy_sizes) {
|
|
DataStore reboot;
|
|
auto historical = before; historical.resize(length);
|
|
reboot.fs.files["/new_prefs"] = historical;
|
|
CompanionNodePrefs live; live.lost_reply = 2;
|
|
live.tx_media=mesh::RADIO_TX_BOTH;live.tx_ssh=mesh::RADIO_TX_PRIMARY;
|
|
live.getCustom()->setByKey("local_tag", "unchanged");
|
|
double lat = 1, lon = 2;
|
|
assert(reboot.loadPrefsInt("/new_prefs", live, lat, lon));
|
|
assert(live.lost_reply == 0 && live.ble_pin == original.ble_pin);
|
|
assert(live.tx_media==mesh::RADIO_TX_AUTO&&live.tx_ssh==mesh::RADIO_TX_AUTO);
|
|
assert(live.freq == original.freq && !strcmp(live.node_name, original.node_name));
|
|
assert(lat == 47.125 && lon == -122.25);
|
|
assert(reboot.fs.files.at("/new_prefs") == historical);
|
|
char tag[16] = {};
|
|
assert(live.getCustom()->getByKey("local_tag", tag, sizeof(tag)));
|
|
assert(!strcmp(tag, "unchanged"));
|
|
++comparisons;
|
|
}
|
|
// The accepted-size table is an exact allowlist. Check every length through
|
|
// a full extra byte range, including sizes that could alias legacy values
|
|
// if the underlying file size were accidentally narrowed with the table.
|
|
std::vector<size_t> accepted_sizes = legacy_sizes;
|
|
accepted_sizes.push_back(historical_size);
|
|
accepted_sizes.push_back(before.size());
|
|
for (size_t length = 0; length <= before.size() + 256; ++length) {
|
|
DataStore reboot;
|
|
auto candidate = before;
|
|
candidate.resize(length);
|
|
reboot.fs.files["/new_prefs"] = candidate;
|
|
CompanionNodePrefs live;
|
|
live.freq = 920.5f;
|
|
live.lost_reply = 1;
|
|
live.getCustom()->setByKey("length_tag", "unchanged");
|
|
double lat = 1, lon = 2;
|
|
const bool expected = std::find(accepted_sizes.begin(), accepted_sizes.end(),
|
|
length) != accepted_sizes.end();
|
|
assert(reboot.loadPrefsInt("/new_prefs", live, lat, lon) == expected);
|
|
if (expected) {
|
|
assert(live.freq == original.freq);
|
|
assert(live.lost_reply == (length >= historical_size ? 2 : 0));
|
|
assert(live.tx_media == (length == before.size() ? mesh::RADIO_TX_SECONDARY : mesh::RADIO_TX_AUTO));
|
|
assert(live.tx_ssh == (length == before.size() ? mesh::RADIO_TX_OFF : mesh::RADIO_TX_AUTO));
|
|
assert(lat == 47.125 && lon == -122.25);
|
|
} else {
|
|
assert(live.freq == 920.5f && live.lost_reply == 1);
|
|
assert(lat == 1 && lon == 2);
|
|
}
|
|
char tag[16] = {};
|
|
assert(live.getCustom()->getByKey("length_tag", tag, sizeof(tag)));
|
|
assert(!strcmp(tag, "unchanged"));
|
|
assert(reboot.fs.files.at("/new_prefs") == candidate);
|
|
++comparisons;
|
|
}
|
|
// All 256 byte encodings are preserved by the append-only serializer.
|
|
// Only off/no/yes are accepted on reboot; corrupt values normalize off
|
|
// without erasing radio settings or changing the committed image.
|
|
for (unsigned value = 0; value < 256; ++value) {
|
|
CompanionNodePrefs saved;
|
|
assert(saved.copyPersistedValuesFrom(original));
|
|
saved.lost_reply = static_cast<uint8_t>(value);
|
|
DataStore writer;
|
|
assert(writer.savePrefs(saved, 47.125, -122.25));
|
|
const auto disk = writer.fs.files.at("/new_prefs");
|
|
assert(disk[historical_size-1] == value);
|
|
assert(std::equal(disk.begin(), disk.begin()+historical_size-1, before.begin()));
|
|
assert(std::equal(disk.begin()+historical_size, disk.end(), before.begin()+historical_size));
|
|
DataStore reboot; reboot.fs.files["/new_prefs"] = disk;
|
|
CompanionNodePrefs live; live.lost_reply = 2;
|
|
double lat = 1, lon = 2;
|
|
assert(reboot.loadPrefsInt("/new_prefs", live, lat, lon));
|
|
assert(live.lost_reply == (value <= 2 ? value : 0));
|
|
assert(live.ble_pin == original.ble_pin && live.freq == original.freq);
|
|
assert(live.gps_sync_interval_hours == 336 && live.usb_debug_enabled == 1);
|
|
assert(lat == 47.125 && lon == -122.25);
|
|
assert(reboot.fs.files.at("/new_prefs") == disk);
|
|
++comparisons;
|
|
}
|
|
{
|
|
DataStore torn; torn.fs.files["/new_prefs"] = before;
|
|
torn.fs.fail_read_after = static_cast<int>(before.size() - 1);
|
|
CompanionNodePrefs live; live.lost_reply = 1; live.freq = 920.5f;
|
|
strcpy(live.node_name, "live unchanged");
|
|
double lat = 1, lon = 2;
|
|
assert(!torn.loadPrefsInt("/new_prefs", live, lat, lon));
|
|
assert(live.lost_reply == 1 && live.freq == 920.5f);
|
|
assert(!strcmp(live.node_name, "live unchanged") && lat == 1 && lon == 2);
|
|
assert(torn.fs.files.at("/new_prefs") == before);
|
|
++comparisons;
|
|
}
|
|
CompanionNodePrefs changed;
|
|
assert(changed.copyPersistedValuesFrom(original));
|
|
strcpy(changed.node_name, "replacement image");
|
|
changed.freq = 910.5f; changed.usb_debug_enabled = 0;
|
|
changed.gps_sync_interval_hours = 24;
|
|
changed.lost_reply = 1;
|
|
const auto after = golden(changed, 42.25, -121.125);
|
|
|
|
// Every possible short write and readback, including every tail byte,
|
|
// must preserve the old durable image. STM32 issues only one image write;
|
|
// the other supported platforms preserve their scalar-write path.
|
|
for (size_t quota = 0; quota < before.size(); ++quota) {
|
|
DataStore write;
|
|
write.fs.files["/new_prefs"] = before;
|
|
write.fs.fail_write_after = static_cast<int>(quota);
|
|
assert(!write.savePrefs(changed, 42.25, -121.125));
|
|
unchanged(write, before);
|
|
write.fs.fail_write_after = -1;
|
|
assert(write.savePrefs(changed, 42.25, -121.125));
|
|
assert(write.fs.files.at("/new_prefs") == after);
|
|
DataStore read;
|
|
read.fs.files["/new_prefs"] = before;
|
|
read.fs.fail_read_after = static_cast<int>(quota);
|
|
assert(!read.savePrefs(changed, 42.25, -121.125));
|
|
unchanged(read, before);
|
|
++comparisons;
|
|
}
|
|
for (unsigned fault = 0; fault < 3; ++fault) {
|
|
DataStore store;
|
|
store.fs.files["/new_prefs"] = before;
|
|
if (fault == 0) store.fs.fail_write_open = true;
|
|
if (fault == 1) store.fs.fail_write = true;
|
|
if (fault == 2) store.fs.fail_read_open = true;
|
|
assert(!store.savePrefs(changed, 42.25, -121.125));
|
|
unchanged(store, before);
|
|
++comparisons;
|
|
}
|
|
#if defined(STM32_PLATFORM) || defined(NRF52_PLATFORM)
|
|
const unsigned rename_steps = 1;
|
|
#else
|
|
const unsigned rename_steps = 2;
|
|
#endif
|
|
for (unsigned failure = 1; failure <= rename_steps; ++failure) {
|
|
DataStore store;
|
|
store.fs.files["/new_prefs"] = before;
|
|
store.fs.fail_rename = static_cast<int>(failure);
|
|
assert(!store.savePrefs(changed, 42.25, -121.125));
|
|
unchanged(store, before);
|
|
++comparisons;
|
|
}
|
|
assert(initial.savePrefs(changed, 42.25, -121.125));
|
|
CompanionNodePrefs loaded;
|
|
double lat = 0, lon = 0;
|
|
assert(initial.loadPrefs(loaded, lat, lon));
|
|
assert(!strcmp(loaded.node_name, changed.node_name));
|
|
assert(loaded.freq == changed.freq && loaded.ble_pin == changed.ble_pin);
|
|
assert(loaded.gps_sync_interval_hours == 24 && loaded.usb_debug_enabled == 0);
|
|
assert(loaded.lost_reply == 1);
|
|
assert(loaded.tx_media == original.tx_media && loaded.tx_ssh == original.tx_ssh);
|
|
assert(lat == 42.25 && lon == -121.125);
|
|
|
|
#if defined(STM32_PLATFORM)
|
|
// A future field or wrong staging capacity must fail closed, not overrun
|
|
// the stack or silently publish a truncated/extra-byte format.
|
|
for (bool undersized : {false, true}) {
|
|
DataStore store;
|
|
store.fs.files["/new_prefs"] = before;
|
|
assert(!(undersized ? store.savePrefsTooSmall(changed, lat, lon)
|
|
: store.savePrefsTooLarge(changed, lat, lon)));
|
|
unchanged(store, before);
|
|
++comparisons;
|
|
}
|
|
#endif
|
|
printf("PASS: %u independent wire/fault comparisons\n", comparisons);
|
|
}
|
|
'''
|
|
|
|
|
|
def field_code(rows, seed=False):
|
|
result = []
|
|
for name, size in rows:
|
|
if seed:
|
|
if name is not None and not name.startswith('@'):
|
|
result.append(f'fill(&prefs.{name}, sizeof(prefs.{name}));')
|
|
elif name is None:
|
|
result.append(f'bytes.insert(bytes.end(), {size}, uint8_t(0));')
|
|
else:
|
|
value = name[1:] if name.startswith('@') else 'prefs.' + name
|
|
result.append(f'static_assert(sizeof({value}) == {size}, "published field size");')
|
|
result.append(f'append(&{value}, {size});')
|
|
return '\n '.join(result)
|
|
|
|
|
|
class CompanionPrefsImageTests(unittest.TestCase):
|
|
def test_explicit_wire_layout_and_atomic_staging(self):
|
|
source = (ROOT / 'examples/companion_radio/DataStore.cpp').read_text()
|
|
save = method(source, 'bool DataStore::savePrefs(')
|
|
methods = esp_recovery_helpers(source) + '\n'.join(
|
|
method(source, signature) for signature in (
|
|
'bool DataStore::loadPrefs(', 'bool DataStore::loadPrefsInt(')
|
|
) + '\n' + save
|
|
for suffix, capacity in (('TooSmall', 235), ('TooLarge', 237)):
|
|
variant = save.replace('DataStore::savePrefs(', 'DataStore::savePrefs' + suffix + '(', 1)
|
|
self.assertIn('uint8_t image[236];', variant)
|
|
variant = variant.replace('uint8_t image[236];', f'uint8_t image[{capacity}];', 1)
|
|
methods += '\n#if defined(STM32_PLATFORM)\n' + variant + '\n#endif\n'
|
|
code = HARNESS.split('int main(')[0].replace('@METHODS@', methods)
|
|
code = code.replace(' bool savePrefs(const CompanionNodePrefs&, double, double);',
|
|
' bool savePrefs(const CompanionNodePrefs&, double, double);\n'
|
|
' bool savePrefsTooSmall(const CompanionNodePrefs&, double, double);\n'
|
|
' bool savePrefsTooLarge(const CompanionNodePrefs&, double, double);')
|
|
self.assertEqual(sum(size for _, size in WIRE_FIELDS), 226)
|
|
self.assertEqual(sum(size for _, size in TAIL_FIELDS), 10)
|
|
generated = {}
|
|
for seed in (False, True):
|
|
rows = [field_code(WIRE_FIELDS, seed)]
|
|
for condition, fields in OPTIONAL_FIELDS:
|
|
rows.append('#if ' + condition + '\n' + field_code(fields, seed) + '\n#endif')
|
|
rows.append(field_code(TAIL_FIELDS, seed))
|
|
generated['@SEED@' if seed else '@GOLDEN@'] = '\n '.join(rows)
|
|
tests = TESTS
|
|
for placeholder, value in generated.items():
|
|
tests = tests.replace(placeholder, value)
|
|
with tempfile.TemporaryDirectory(prefix='meshcore-prefs-image-') as directory:
|
|
work = Path(directory)
|
|
(work / 'Utils.h').write_text('''#pragma once
|
|
#include <Arduino.h>
|
|
#include <cassert>
|
|
namespace mesh { struct Utils {
|
|
static void printHex(Stream&,const uint8_t*,size_t){assert(false);}
|
|
static void fromHex(uint8_t*,size_t,const char*){assert(false);}
|
|
}; }
|
|
''')
|
|
(work / 'platform_shim.h').write_text('''#include <cstdlib>
|
|
#include <cstdio>
|
|
inline char* utoa(unsigned int value,char* output,int base){
|
|
if(base!=10)abort();sprintf(output,"%u",value);return output;
|
|
}
|
|
''')
|
|
transaction = (ROOT / 'src/helpers/ContactFileTransaction.h').read_text()
|
|
(work / 'ContactFileTransaction.h').write_text(transaction.replace(
|
|
'#include "IdentityStore.h"', '#include <helpers/IdentityStore.h>'))
|
|
# Reuse the existing memory-filesystem boundary, adding only an
|
|
# observable open failure needed to exercise the actual writer.
|
|
fs = (ROOT / 'test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h').read_text()
|
|
fs = fs.replace(' bool fail_write = false;',
|
|
' bool fail_write = false, fail_write_open = false;')
|
|
fs = fs.replace(' if (*mode == \'w\') files[path].clear();',
|
|
' if (*mode == \'w\' && fail_write_open) return {};\n'
|
|
' if (*mode == \'w\') files[path].clear();')
|
|
(work / 'helpers').mkdir()
|
|
(work / 'helpers/IdentityStore.h').write_text(fs)
|
|
cpp = work / 'test.cpp'
|
|
cpp.write_text(code + tests)
|
|
for platform in ('STM32_PLATFORM', 'NRF52_PLATFORM', 'ESP32_PLATFORM',
|
|
'RP2040_PLATFORM', 'ESP32_PLATFORM,TBEAM_1W',
|
|
'RP2040_PLATFORM,ENABLE_WIFI_INTERFACE'):
|
|
with self.subTest(platform=platform):
|
|
binary = work / 'test'
|
|
sanitizers = [] if os.name == 'nt' else [
|
|
'-fsanitize=address,undefined', '-fno-sanitize-recover=all',
|
|
'-fno-pie', '-no-pie']
|
|
built = subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17',
|
|
*['-D' + flag + '=1' for flag in platform.split(',')],
|
|
*sanitizers, '-include', str(work / 'platform_shim.h'),
|
|
'-I', str(work), '-I', str(ROOT / 'test/fixtures/radio_profiles/mocks'),
|
|
'-I', str(ROOT / 'test/mocks'), '-I', str(ROOT / 'src'),
|
|
'-I', str(ROOT / 'src/helpers'), '-I', str(ROOT), str(cpp),
|
|
*[str(ROOT / 'src/helpers' / name) for name in (
|
|
'ConfigSerializer.cpp', 'DynamicConfigSerializer.cpp',
|
|
'CommonRadioPrefs.cpp', 'TxtDataHelpers.cpp')],
|
|
'-o', str(binary)], capture_output=True, text=True)
|
|
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
|
|
ran = subprocess.run([str(binary)], capture_output=True, text=True)
|
|
self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|