diff --git a/.github/workflows/php-unit-tests.yml b/.github/workflows/php-unit-tests.yml
new file mode 100644
index 0000000..9ab561f
--- /dev/null
+++ b/.github/workflows/php-unit-tests.yml
@@ -0,0 +1,92 @@
+# +-------------------------------------------------------------------------+
+# | Copyright (C) 2004-2026 The Cacti Group |
+# | |
+# | This program is free software; you can redistribute it and/or |
+# | modify it under the terms of the GNU General Public License |
+# | as published by the Free Software Foundation; either version 2 |
+# | of the License, or (at your option) any later version. |
+# | |
+# | This program is distributed in the hope that it will be useful, |
+# | but WITHOUT ANY WARRANTY; without even the implied warranty of |
+# | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
+# | GNU General Public License for more details. |
+# +-------------------------------------------------------------------------+
+# | Cacti: The Complete RRDtool-based Graphing Solution |
+# +-------------------------------------------------------------------------+
+# | This code is designed, written, and maintained by the Cacti Group. See |
+# | about.php and/or the AUTHORS file for specific developer information. |
+# +-------------------------------------------------------------------------+
+# | http://www.cacti.net/ |
+# +-------------------------------------------------------------------------+
+
+
+name: PHP Unit Tests
+
+on:
+ push:
+ branches:
+ - main
+ - develop
+ pull_request:
+ branches:
+ - main
+ - develop
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ unit-test:
+ name: PHPUnit on PHP 8.1 (Docker)
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout Thold Plugin
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ # patch-coverage.php diffs against the base branch.
+ fetch-depth: 0
+
+ # The image is the same one developers run locally via
+ # `composer test:docker`, so a green run here is reproducible off-CI.
+ - name: Build test image
+ run: docker build --tag cacti-thold-test:php8.1 --file tests/docker/Dockerfile tests/docker
+
+ - name: Validate composer.json
+ run: docker run --rm --volume "$PWD":/cacti/plugins/thold cacti-thold-test:php8.1 composer validate --strict --no-check-lock
+
+ - name: Install dependencies
+ run: docker run --rm --volume "$PWD":/cacti/plugins/thold cacti-thold-test:php8.1 composer install --no-progress --no-ansi
+
+ # Same scripts a developer runs locally, and the same names Cacti core uses.
+ - name: Lint every PHP source file
+ run: docker run --rm --volume "$PWD":/cacti/plugins/thold cacti-thold-test:php8.1 composer lint
+
+ - name: Run unit tests with coverage
+ run: |
+ docker run --rm --volume "$PWD":/cacti/plugins/thold cacti-thold-test:php8.1 \
+ composer test:coverage
+
+ # Whole-file coverage is meaningless here: most of the plugin only runs
+ # inside a live Cacti. What is enforceable is that a change covers the
+ # lines it adds.
+ - name: Enforce coverage of changed lines
+ if: github.event_name == 'pull_request'
+ env:
+ BASE_REF: origin/${{ github.base_ref }}
+ run: |
+ docker run --rm --volume "$PWD":/cacti/plugins/thold --env BASE_REF \
+ cacti-thold-test:php8.1 \
+ sh -c 'git config --global --add safe.directory /cacti/plugins/thold && php tests/bin/patch-coverage.php coverage/clover.xml "$BASE_REF" 100'
+
+ - name: Upload coverage report
+ if: always()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ with:
+ name: coverage
+ path: coverage/
+ if-no-files-found: warn
diff --git a/.gitignore b/.gitignore
index eb71606..0806dc4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,3 +20,10 @@
# +-------------------------------------------------------------------------+
locales/po/*.mo
+
+/vendor/
+/composer.lock
+/.phpunit.cache/
+/coverage/
+/coverage/
+/.phpunit.result.cache
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..94aead2
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,49 @@
+{
+ "_comment": [
+ "+-------------------------------------------------------------------------+",
+ "| Copyright (C) 2004-2026 The Cacti Group |",
+ "| |",
+ "| This program is free software; you can redistribute it and/or |",
+ "| modify it under the terms of the GNU General Public License |",
+ "| as published by the Free Software Foundation; either version 2 |",
+ "| of the License, or (at your option) any later version. |",
+ "| |",
+ "| This program is distributed in the hope that it will be useful, |",
+ "| but WITHOUT ANY WARRANTY; without even the implied warranty of |",
+ "| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |",
+ "| GNU General Public License for more details. |",
+ "+-------------------------------------------------------------------------+",
+ "| Cacti: The Complete RRDtool-based Graphing Solution |",
+ "+-------------------------------------------------------------------------+",
+ "| http://www.cacti.net/ |",
+ "+-------------------------------------------------------------------------+"
+ ],
+ "name": "cacti/plugin-thold",
+ "description": "Thold Plugin for Cacti",
+ "type": "project",
+ "license": "GPL-2.0-only",
+ "require-dev": {
+ "overtrue/phplint": "^9.6",
+ "phpunit/phpunit": "^10.5.64"
+ },
+ "scripts": {
+ "lint": "phplint --no-cache --exclude=vendor ",
+ "test": "phpunit --display-warnings",
+ "test:coverage": "phpunit --display-warnings --coverage-clover=coverage/clover.xml",
+ "test:docker": "docker compose -f tests/docker/docker-compose.yml run --rm phpunit"
+ },
+ "config": {
+ "sort-packages": true,
+ "vendor-dir": "vendor",
+ "platform": {
+ "php": "8.1.0"
+ },
+ "platform-check": true
+ },
+ "autoload-dev": {
+ "classmap": [
+ "tests/Helpers/",
+ "tests/TestCase.php"
+ ]
+ }
+}
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..731e8bb
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+ ./tests/Unit
+
+
+
+
+
+
+ thold_functions.php
+
+
+
diff --git a/tests/Helpers/CactiStubs.php b/tests/Helpers/CactiStubs.php
new file mode 100644
index 0000000..fdb2726
--- /dev/null
+++ b/tests/Helpers/CactiStubs.php
@@ -0,0 +1,214 @@
+}>
+ */
+ public static $calls = [];
+
+ /**
+ * Queued return values, keyed by function name. Each call shifts one value
+ * off the front; an exhausted queue falls back to the type default.
+ *
+ * @var array>
+ */
+ public static $returns = [];
+
+ /**
+ * Return values chosen by a fragment of the SQL, keyed by function name.
+ * Each entry is [fragment, value]. Consulted before $returns.
+ *
+ * @var array>
+ */
+ public static $matchedReturns = [];
+
+ /**
+ * Values handed back on every call, keyed by function name. Consulted last.
+ *
+ * @var array
+ */
+ public static $stickyReturns = [];
+
+ /**
+ * Values handed back by the get_*_request_var() family, keyed by var name.
+ *
+ * @var array
+ */
+ public static $requestVars = [];
+
+ /**
+ * Values handed back by read_config_option(), keyed by option name.
+ *
+ * @var array
+ */
+ public static $configOptions = [];
+
+ /**
+ * Messages passed to cacti_log(), in order.
+ *
+ * @var array
+ */
+ public static $log = [];
+
+ /**
+ * Mail handed to Cacti's mailer(), in order.
+ *
+ * @var array
+ */
+ public static $mail = [];
+
+ /**
+ * Clear all recorded and programmed state.
+ *
+ * @return void
+ */
+ public static function reset() {
+ self::$calls = [];
+ self::$returns = [];
+ self::$matchedReturns = [];
+ self::$stickyReturns = [];
+ self::$requestVars = [];
+ self::$configOptions = [];
+ self::$log = [];
+ self::$mail = [];
+ }
+
+ /**
+ * Record one Cacti function call.
+ *
+ * @param string $fn Cacti function name.
+ * @param string $sql SQL text, or '' for non-query calls.
+ * @param array $params Bound parameters, if any.
+ *
+ * @return void
+ */
+ public static function record($fn, $sql = '', array $params = []) {
+ self::$calls[] = ['fn' => $fn, 'sql' => $sql, 'params' => $params];
+ }
+
+ /**
+ * Hand back $value for every call to $fn.
+ *
+ * @param string $fn Cacti function name.
+ * @param mixed $value Value to hand back.
+ *
+ * @return void
+ */
+ public static function willAlwaysReturn($fn, $value) {
+ self::$stickyReturns[$fn] = $value;
+ }
+
+ /**
+ * Queue one return value for the next call to $fn.
+ *
+ * @param string $fn Cacti function name.
+ * @param mixed $value Value to hand back.
+ *
+ * @return void
+ */
+ public static function willReturn($fn, $value) {
+ self::$returns[$fn][] = $value;
+ }
+
+ /**
+ * Answer any call to $fn whose SQL contains $fragment with $value.
+ *
+ * A function such as db_fetch_cell_prepared is called many times with
+ * different queries in one run, so a positional queue would break as soon
+ * as the code under test reordered a lookup. Matching on the query keeps
+ * the fixture readable and stable.
+ *
+ * @param string $fn Cacti function name.
+ * @param string $fragment Distinctive substring of the SQL.
+ * @param mixed $value Value to hand back.
+ *
+ * @return void
+ */
+ public static function willReturnFor($fn, $fragment, $value) {
+ self::$matchedReturns[$fn][] = [$fragment, $value];
+ }
+
+ /**
+ * Take the return value for a call: a SQL match first, then the queue, then
+ * the type default.
+ *
+ * @param string $fn Cacti function name.
+ * @param mixed $default Fallback when nothing matches.
+ * @param string $sql SQL the caller passed, for matching.
+ *
+ * @return mixed
+ */
+ public static function nextReturn($fn, $default, $sql = '') {
+ if ($sql !== '' && !empty(self::$matchedReturns[$fn])) {
+ $flat = preg_replace('/\s+/', ' ', $sql);
+
+ foreach (self::$matchedReturns[$fn] as $entry) {
+ if (strpos($flat, preg_replace('/\s+/', ' ', $entry[0])) !== false) {
+ return $entry[1];
+ }
+ }
+ }
+
+ if (!empty(self::$returns[$fn])) {
+ return array_shift(self::$returns[$fn]);
+ }
+
+ if (array_key_exists($fn, self::$stickyReturns)) {
+ return self::$stickyReturns[$fn];
+ }
+
+ return $default;
+ }
+
+ /**
+ * All recorded calls to $fn.
+ *
+ * @param string $fn Cacti function name.
+ *
+ * @return array}>
+ */
+ public static function callsTo($fn) {
+ return array_values(array_filter(self::$calls, function ($call) use ($fn) {
+ return $call['fn'] === $fn;
+ }));
+ }
+
+ /**
+ * The recorded call log reduced to function names, in order. Useful for
+ * asserting transaction sequencing.
+ *
+ * @return array
+ */
+ public static function callSequence() {
+ return array_column(self::$calls, 'fn');
+ }
+}
diff --git a/tests/TestCase.php b/tests/TestCase.php
new file mode 100644
index 0000000..6734256
--- /dev/null
+++ b/tests/TestCase.php
@@ -0,0 +1,65 @@
+
+ */
+ public static function suffixProvider() {
+ return [
+ 'femto' => ['5f', 5.0e-15],
+ 'pico' => ['5p', 5.0e-12],
+ 'nano' => ['5n', 5.0e-9],
+ 'micro' => ['5u', 5.0e-6],
+ 'milli' => ['5m', 5.0e-3],
+ 'kilo' => ['5K', 5.0e3],
+ 'mega' => ['5M', 5.0e6],
+ 'giga' => ['5G', 5.0e9],
+ 'tera' => ['5T', 5.0e12],
+ 'peta' => ['5P', 5.0e15],
+ 'exa' => ['5E', 5.0e18],
+ 'zetta' => ['5Z', 5.0e21],
+ 'yotta' => ['5Y', 5.0e24],
+ ];
+ }
+
+ /**
+ * @dataProvider suffixProvider
+ *
+ * @param string $typed
+ * @param float $stored
+ *
+ * @return void
+ */
+ public function testEachSuffixScalesByItsSiFactor($typed, $stored): void {
+ $this->assertEqualsWithDelta($stored, thold_display_to_raw($typed, 'thold_hi'), abs($stored) * 1.0e-9);
+ }
+
+ /**
+ * @dataProvider suffixProvider
+ *
+ * @param string $typed
+ * @param float $stored
+ *
+ * @return void
+ */
+ public function testEachStoredValueRendersWithItsSiSuffix($typed, $stored): void {
+ $this->assertSame($typed, thold_raw_to_display($stored));
+ }
+
+ /**
+ * Opening a threshold and saving it again must not change it. This is the
+ * failure that mattered: a value stored at 1e-12 rendered as 5f, which
+ * parsed back as 1e-15, so every visit to the form divided it by a
+ * thousand.
+ *
+ * @dataProvider suffixProvider
+ *
+ * @param string $typed
+ * @param float $stored
+ *
+ * @return void
+ */
+ public function testAValueSurvivesBeingDisplayedAndReEntered($typed, $stored): void {
+ $round_tripped = thold_display_to_raw(thold_raw_to_display($stored), 'thold_hi');
+
+ $this->assertEqualsWithDelta($stored, $round_tripped, abs($stored) * 1.0e-9);
+ }
+
+ /**
+ * @return void
+ */
+ public function testAPlainNumberIsLeftAlone(): void {
+ $this->assertSame('42', thold_display_to_raw('42', 'thold_hi'));
+ $this->assertSame('42', thold_raw_to_display(42));
+ }
+
+ /**
+ * @return void
+ */
+ public function testZeroIsLeftAlone(): void {
+ $this->assertSame('0', thold_raw_to_display(0));
+ }
+
+ /**
+ * @return void
+ */
+ public function testNegativeValuesKeepTheirSign(): void {
+ $this->assertSame('-5K', thold_raw_to_display(-5000));
+ $this->assertEqualsWithDelta(-5000, thold_display_to_raw('-5K', 'thold_hi'), 1.0e-6);
+ }
+
+ /**
+ * @return array
+ */
+ public static function rejectedInputProvider() {
+ return [
+ 'unknown suffix' => ['5x'],
+ 'letters only' => ['abc'],
+ 'empty' => [''],
+ ];
+ }
+
+ /**
+ * @dataProvider rejectedInputProvider
+ *
+ * @param string $typed
+ *
+ * @return void
+ */
+ public function testUnusableInputIsRejectedAndFlagged($typed): void {
+ $this->assertFalse(thold_display_to_raw($typed, 'thold_hi'));
+ $this->assertArrayHasKey('thold_hi', $_SESSION['sess_error_fields']);
+ }
+
+ /**
+ * @return void
+ */
+ public function testNonNumericInputHasNoDisplayForm(): void {
+ $this->assertFalse(thold_raw_to_display('abc'));
+ }
+
+ /**
+ * Beyond the largest and smallest suffix there is nothing left to index,
+ * and the old code read past the end of the pattern and dropped the
+ * magnitude entirely.
+ *
+ * @return void
+ */
+ public function testMagnitudesBeyondTheLargestSuffixKeepTheirScale(): void {
+ $rendered = thold_raw_to_display(5.0e27);
+
+ $this->assertNotSame('5', $rendered);
+ $this->assertEqualsWithDelta(5.0e27, (float) thold_display_to_raw($rendered, 'thold_hi'), 5.0e18);
+ }
+
+ /**
+ * @return void
+ */
+ public function testMagnitudesBelowTheSmallestSuffixKeepTheirScale(): void {
+ $rendered = thold_raw_to_display(5.0e-18);
+
+ $this->assertNotSame('5', $rendered);
+ $this->assertEqualsWithDelta(5.0e-18, (float) thold_display_to_raw($rendered, 'thold_hi'), 5.0e-27);
+ }
+}
diff --git a/tests/bin/patch-coverage.php b/tests/bin/patch-coverage.php
new file mode 100644
index 0000000..83f9c73
--- /dev/null
+++ b/tests/bin/patch-coverage.php
@@ -0,0 +1,158 @@
+ [min-percent]
+ *
+ * Exits 1 if coverage is below the threshold, 2 on bad input.
+ */
+
+if ($argc < 3) {
+ fwrite(STDERR, "usage: patch-coverage.php [min-percent]\n");
+
+ exit(2);
+}
+
+$clover_path = $argv[1];
+$base_ref = $argv[2];
+$minimum = isset($argv[3]) ? (float) $argv[3] : 100.0;
+
+if (!is_readable($clover_path)) {
+ fwrite(STDERR, "cannot read coverage report: $clover_path\n");
+
+ exit(2);
+}
+
+/**
+ * Line numbers each measured file changed, keyed by repository-relative path.
+ *
+ * Only added and modified lines count. Deletions have nothing left to cover,
+ * and context lines were not part of this change.
+ *
+ * Paths stay repository-relative so the report can be produced in a container
+ * and evaluated on the host, where the absolute paths differ.
+ *
+ * @param string $base_ref Git ref to diff against.
+ *
+ * @return array>
+ */
+function changed_lines($base_ref) {
+ $command = 'git diff --no-ext-diff --unified=0 --no-color --diff-filter=AM ' . escapeshellarg($base_ref) . '...HEAD -- "*.php"';
+ $diff = shell_exec($command);
+
+ if ($diff === null) {
+ fwrite(STDERR, "git diff failed\n");
+
+ exit(2);
+ }
+
+ $changed = [];
+ $file = null;
+
+ foreach (explode("\n", $diff) as $line) {
+ if (strncmp($line, '+++ b/', 6) === 0) {
+ $file = substr($line, 6);
+ $changed[$file] = [];
+ } elseif (strncmp($line, '@@', 2) === 0 && $file !== null) {
+ if (preg_match('/\+(\d+)(?:,(\d+))?/', $line, $match)) {
+ $start = (int) $match[1];
+ $count = isset($match[2]) ? (int) $match[2] : 1;
+
+ for ($i = 0; $i < $count; $i++) {
+ $changed[$file][$start + $i] = true;
+ }
+ }
+ }
+ }
+
+ return $changed;
+}
+
+$changed = changed_lines($base_ref);
+$clover = simplexml_load_file($clover_path);
+
+if ($clover === false) {
+ fwrite(STDERR, "cannot parse coverage report: $clover_path\n");
+
+ exit(2);
+}
+
+$covered = 0;
+$total = 0;
+$missing = [];
+
+foreach ($clover->xpath('//file') as $file) {
+ $path = (string) $file['name'];
+ $relative = null;
+
+ foreach (array_keys($changed) as $candidate) {
+ if ($path === $candidate || substr($path, -strlen('/' . $candidate)) === '/' . $candidate) {
+ $relative = $candidate;
+
+ break;
+ }
+ }
+
+ if ($relative === null) {
+ continue;
+ }
+
+ foreach ($file->line as $line) {
+ $number = (int) $line['num'];
+
+ // Only statement lines are measurable; method markers double-count.
+ if ((string) $line['type'] !== 'stmt' || !isset($changed[$relative][$number])) {
+ continue;
+ }
+
+ $total++;
+
+ if ((int) $line['count'] > 0) {
+ $covered++;
+ } else {
+ $missing[] = $relative . ':' . $number;
+ }
+ }
+}
+
+if ($total === 0) {
+ print "Patch coverage: no measured lines changed.\n";
+
+ exit(0);
+}
+
+$percent = ($covered / $total) * 100;
+
+printf("Patch coverage: %.2f%% (%d/%d lines)\n", $percent, $covered, $total);
+
+if ($missing !== []) {
+ print "Uncovered changed lines:\n " . implode("\n ", $missing) . "\n";
+}
+
+if ($percent + 0.005 < $minimum) {
+ printf("FAIL: below the %.2f%% minimum.\n", $minimum);
+
+ exit(1);
+}
+
+exit(0);
diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php
new file mode 100644
index 0000000..dbb050e
--- /dev/null
+++ b/tests/bootstrap-unit.php
@@ -0,0 +1,521 @@
+ dirname(dirname(dirname(__DIR__))),
+ 'url_path' => '/cacti/',
+ 'cacti_version' => '1.2.31',
+ 'cacti_server_os' => 'unix',
+];
+
+// thold_expand_string() include_once()s library_path/variables.php at call time.
+$GLOBALS['config']['library_path'] = __DIR__ . '/fixtures/cacti-lib';
+
+// thold reads and writes this on every RPN evaluation.
+$GLOBALS['rpn_error'] = false;
+
+// Cacti's list of enabled plugins; thold_check_threshold() declares it global.
+$GLOBALS['plugins'] = [];
+
+// Cacti's debug flag, also declared global by thold_check_threshold().
+$GLOBALS['debug'] = false;
+
+if (!function_exists('db_execute')) {
+ function db_execute($sql, $log = true, $db_conn = false) {
+ CactiStubs::record('db_execute', $sql);
+
+ return CactiStubs::nextReturn('db_execute', true, $sql);
+ }
+}
+
+if (!function_exists('db_execute_prepared')) {
+ function db_execute_prepared($sql, $params = [], $log = true, $db_conn = false) {
+ CactiStubs::record('db_execute_prepared', $sql, $params);
+
+ return CactiStubs::nextReturn('db_execute_prepared', true, $sql);
+ }
+}
+
+if (!function_exists('db_fetch_assoc')) {
+ function db_fetch_assoc($sql, $log = true, $db_conn = false) {
+ CactiStubs::record('db_fetch_assoc', $sql);
+
+ return CactiStubs::nextReturn('db_fetch_assoc', [], $sql);
+ }
+}
+
+if (!function_exists('db_fetch_assoc_prepared')) {
+ function db_fetch_assoc_prepared($sql, $params = [], $log = true, $db_conn = false) {
+ CactiStubs::record('db_fetch_assoc_prepared', $sql, $params);
+
+ return CactiStubs::nextReturn('db_fetch_assoc_prepared', [], $sql);
+ }
+}
+
+if (!function_exists('db_fetch_row')) {
+ function db_fetch_row($sql, $log = true, $db_conn = false) {
+ CactiStubs::record('db_fetch_row', $sql);
+
+ return CactiStubs::nextReturn('db_fetch_row', [], $sql);
+ }
+}
+
+if (!function_exists('db_fetch_row_prepared')) {
+ function db_fetch_row_prepared($sql, $params = [], $log = true, $db_conn = false) {
+ CactiStubs::record('db_fetch_row_prepared', $sql, $params);
+
+ return CactiStubs::nextReturn('db_fetch_row_prepared', [], $sql);
+ }
+}
+
+/*
+ * Cacti's cell fetchers return false, not '', when the query matches no row.
+ * The difference matters on PHP 8: false coerces to 0 in arithmetic while ''
+ * raises a TypeError, so a stub returning '' invents failures that production
+ * does not have.
+ */
+if (!function_exists('db_fetch_cell')) {
+ function db_fetch_cell($sql, $col_name = '', $log = true, $db_conn = false) {
+ CactiStubs::record('db_fetch_cell', $sql);
+
+ return CactiStubs::nextReturn('db_fetch_cell', false, $sql);
+ }
+}
+
+if (!function_exists('db_fetch_cell_prepared')) {
+ function db_fetch_cell_prepared($sql, $params = [], $col_name = '', $log = true, $db_conn = false) {
+ CactiStubs::record('db_fetch_cell_prepared', $sql, $params);
+
+ return CactiStubs::nextReturn('db_fetch_cell_prepared', false, $sql);
+ }
+}
+
+if (!function_exists('db_qstr')) {
+ function db_qstr($string) {
+ return "'" . str_replace("'", "''", (string) $string) . "'";
+ }
+}
+
+if (!function_exists('db_begin_transaction')) {
+ function db_begin_transaction() {
+ CactiStubs::record('db_begin_transaction');
+
+ return CactiStubs::nextReturn('db_begin_transaction', true);
+ }
+}
+
+if (!function_exists('db_commit_transaction')) {
+ function db_commit_transaction() {
+ CactiStubs::record('db_commit_transaction');
+
+ return CactiStubs::nextReturn('db_commit_transaction', true);
+ }
+}
+
+if (!function_exists('db_rollback_transaction')) {
+ function db_rollback_transaction() {
+ CactiStubs::record('db_rollback_transaction');
+
+ return CactiStubs::nextReturn('db_rollback_transaction', true);
+ }
+}
+
+if (!function_exists('html_escape')) {
+ function html_escape($string) {
+ return htmlspecialchars((string) $string, ENT_QUOTES, 'UTF-8');
+ }
+}
+
+/*
+ * Mirrors Cacti 1.2 lib/functions.php. KEEP IN SYNC: if core tightens its
+ * checks, tests here would otherwise keep passing while production diverges.
+ */
+if (!function_exists('sanitize_unserialize_selected_items')) {
+ function sanitize_unserialize_selected_items($items) {
+ if (empty($items)) {
+ return false;
+ }
+
+ $data = unserialize($items, ['allowed_classes' => false]); // nosemgrep: php.lang.security.unserialize-use.unserialize-use -- test stub mirroring Cacti core; allowed_classes:false blocks object injection
+
+ if (!is_array($data)) {
+ return false;
+ }
+
+ foreach ($data as $value) {
+ if (!is_numeric($value)) {
+ return false;
+ }
+ }
+
+ return $data;
+ }
+}
+
+if (!function_exists('read_config_option')) {
+ function read_config_option($name, $force = false) {
+ return isset(CactiStubs::$configOptions[$name]) ? CactiStubs::$configOptions[$name] : '';
+ }
+}
+
+if (!function_exists('set_config_option')) {
+ function set_config_option($name, $value) {
+ CactiStubs::$configOptions[$name] = $value;
+ }
+}
+
+if (!function_exists('__')) {
+ function __($text) {
+ $args = array_slice(func_get_args(), 1);
+
+ // Cacti's __() accepts sprintf arguments after the format string.
+ return $args === [] ? $text : vsprintf($text, $args);
+ }
+}
+
+if (!function_exists('__esc')) {
+ function __esc($text) {
+ return htmlspecialchars(call_user_func_array('__', func_get_args()), ENT_QUOTES, 'UTF-8');
+ }
+}
+
+if (!function_exists('cacti_log')) {
+ function cacti_log($message, $output = false, $environ = 'CMDPHP', $level = 0) {
+ CactiStubs::$log[] = $message;
+ }
+}
+
+if (!function_exists('cacti_sizeof')) {
+ function cacti_sizeof($array) {
+ return (is_array($array) || $array instanceof Countable) ? count($array) : 0;
+ }
+}
+
+if (!function_exists('cacti_count')) {
+ function cacti_count($array) {
+ return cacti_sizeof($array);
+ }
+}
+
+if (!function_exists('get_request_var')) {
+ function get_request_var($name, $default = '') {
+ return isset(CactiStubs::$requestVars[$name]) ? CactiStubs::$requestVars[$name] : $default;
+ }
+}
+
+if (!function_exists('get_nfilter_request_var')) {
+ function get_nfilter_request_var($name, $default = '') {
+ return get_request_var($name, $default);
+ }
+}
+
+if (!function_exists('get_filter_request_var')) {
+ function get_filter_request_var($name, $filter = FILTER_VALIDATE_INT, $options = []) {
+ return get_request_var($name);
+ }
+}
+
+if (!function_exists('isset_request_var')) {
+ function isset_request_var($name) {
+ return isset(CactiStubs::$requestVars[$name]);
+ }
+}
+
+if (!function_exists('cacti_escapeshellarg')) {
+ function cacti_escapeshellarg($string, $quote = true) {
+ return escapeshellarg((string) $string);
+ }
+}
+
+if (!function_exists('api_plugin_hook_function')) {
+ function api_plugin_hook_function($name, $data = '') {
+ return $data;
+ }
+}
+
+if (!function_exists('get_simple_graph_perms')) {
+ function get_simple_graph_perms($user_id) {
+ return CactiStubs::nextReturn('get_simple_graph_perms', true);
+ }
+}
+
+if (!function_exists('get_policies')) {
+ function get_policies($user_id) {
+ return CactiStubs::nextReturn('get_policies', []);
+ }
+}
+
+if (!function_exists('get_policy_where')) {
+ function get_policy_where($graph_auth_method, $policies, $sql_where) {
+ CactiStubs::record('get_policy_where', $sql_where);
+
+ return CactiStubs::nextReturn('get_policy_where', $sql_where);
+ }
+}
+
+if (!function_exists('expand_title')) {
+ function expand_title($host_id, $snmp_query_id, $snmp_index, $title) {
+ CactiStubs::record('expand_title', $title);
+
+ return CactiStubs::nextReturn('expand_title', $title);
+ }
+}
+
+if (!function_exists('get_graph_title')) {
+ function get_graph_title($local_graph_id) {
+ return CactiStubs::nextReturn('get_graph_title', 'Traffic - eth0');
+ }
+}
+
+if (!function_exists('rrdtool_function_fetch')) {
+ function rrdtool_function_fetch($local_data_id, $start_time, $end_time, $resolution = 0, $show_unknown = false, $rrdtool_file = null) {
+ CactiStubs::record('rrdtool_function_fetch', (string) $local_data_id);
+
+ return CactiStubs::nextReturn('rrdtool_function_fetch', []);
+ }
+}
+
+if (!function_exists('get_data_source_path')) {
+ function get_data_source_path($local_data_id, $expand_paths = true) {
+ return '/var/lib/cacti/rra/test_' . (int) $local_data_id . '.rrd';
+ }
+}
+
+if (!function_exists('sql_save')) {
+ function sql_save($array_items, $table_name, $key_cols = 'id', $autoinc = true, $db_conn = false) {
+ CactiStubs::record('sql_save', $table_name, $array_items);
+
+ return CactiStubs::nextReturn('sql_save', 1);
+ }
+}
+
+if (!function_exists('db_affected_rows')) {
+ function db_affected_rows($db_conn = false) {
+ return CactiStubs::nextReturn('db_affected_rows', 1);
+ }
+}
+
+if (!function_exists('rrdtool_function_graph')) {
+ function rrdtool_function_graph($local_graph_id, $rra_id, $graph_data_array, $rrdtool_pipe = false, &$xport_meta = [], $user = 0) {
+ CactiStubs::record('rrdtool_function_graph', (string) $local_graph_id);
+
+ // A one-pixel PNG stands in for the rendered graph.
+ return base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==', true);
+ }
+}
+
+if (!function_exists('get_timespan')) {
+ function get_timespan(&$timespan, $time, $span, $first_weekdayid) {
+ $timespan['begin_now'] = $time - 86400;
+ $timespan['end_now'] = $time;
+ }
+}
+
+if (!function_exists('read_user_setting')) {
+ function read_user_setting($config_name, $default = false, $force = false, $user = 0) {
+ return CactiStubs::nextReturn('read_user_setting', $default);
+ }
+}
+
+if (!function_exists('get_selected_theme')) {
+ function get_selected_theme() {
+ return 'modern';
+ }
+}
+
+if (!function_exists('mailer')) {
+ function mailer($from, $to, $cc = '', $bcc = '', $replyto = '', $subject = '', $body = '', $body_text = '', $attachments = null, $headers = [], $html = true) {
+ CactiStubs::$mail[] = [
+ 'to' => is_array($to) ? implode(',', $to) : (string) $to,
+ 'bcc' => is_array($bcc) ? implode(',', $bcc) : (string) $bcc,
+ 'subject' => (string) $subject,
+ ];
+
+ return CactiStubs::nextReturn('mailer', '');
+ }
+}
+
+if (!function_exists('cacti_snmp_send')) {
+ function cacti_snmp_send($hostname, $version, $community, $oid, $value, $type = 's') {
+ CactiStubs::record('cacti_snmp_send', (string) $oid);
+
+ return true;
+ }
+}
+
+if (!function_exists('array_rekey')) {
+ function array_rekey($array, $key, $key_value) {
+ $ret_array = [];
+
+ if (is_array($array)) {
+ foreach ($array as $item) {
+ $item_key = $item[$key];
+
+ if (is_array($key_value)) {
+ foreach ($key_value as $value) {
+ $ret_array[$item_key][$value] = $item[$value];
+ }
+ } else {
+ $ret_array[$item_key] = $item[$key_value];
+ }
+ }
+ }
+
+ return $ret_array;
+ }
+}
+
+if (!function_exists('clean_up_name')) {
+ function clean_up_name($string) {
+ $string = preg_replace('/[\s\.]+/', '_', $string);
+ $string = preg_replace('/[^a-zA-Z0-9_]+/', '', $string);
+
+ return preg_replace('/_{2,}/', '_', $string);
+ }
+}
+
+if (!function_exists('plugin_maint_check_cacti_host')) {
+ function plugin_maint_check_cacti_host($host_id) {
+ return CactiStubs::nextReturn('plugin_maint_check_cacti_host', false);
+ }
+}
+
+if (!function_exists('api_plugin_is_enabled')) {
+ function api_plugin_is_enabled($plugin) {
+ return CactiStubs::nextReturn('api_plugin_is_enabled', false);
+ }
+}
+
+if (!function_exists('api_plugin_hook')) {
+ function api_plugin_hook($name, $data = '') {
+ CactiStubs::record('api_plugin_hook', $name);
+
+ return $data;
+ }
+}
+
+if (!function_exists('api_user_realm_auth')) {
+ function api_user_realm_auth($filename = '') {
+ return CactiStubs::nextReturn('api_user_realm_auth', true);
+ }
+}
+
+if (!function_exists('raise_message')) {
+ function raise_message($message_id, $message = '', $level = 0) {
+ CactiStubs::record('raise_message', (string) $message_id);
+ }
+}
+
+if (!function_exists('rrdtool_execute')) {
+ function rrdtool_execute($command, $log_to_stdout = false, $output_flag = 1, $rrdtool_pipe = false, $logopt = 'WEBLOG') {
+ CactiStubs::record('rrdtool_execute', $command);
+
+ return CactiStubs::nextReturn('rrdtool_execute', '');
+ }
+}
+
+if (!function_exists('rrdtool_function_interface_speed')) {
+ function rrdtool_function_interface_speed($data_local) {
+ return CactiStubs::nextReturn('rrdtool_function_interface_speed', 0);
+ }
+}
+
+if (!function_exists('get_timeinstate')) {
+ function get_timeinstate($host) {
+ return CactiStubs::nextReturn('get_timeinstate', '1 day');
+ }
+}
+
+if (!function_exists('get_daysfromtime')) {
+ function get_daysfromtime($timestamp) {
+ return CactiStubs::nextReturn('get_daysfromtime', '1 day');
+ }
+}
+
+if (!function_exists('number_format_i18n')) {
+ function number_format_i18n($number, $decimals = 0, $baseu = 1000) {
+ return number_format((float) $number, $decimals < 0 ? 0 : (int) $decimals);
+ }
+}
+
+if (!defined('FILTER_VALIDATE_IS_REGEX')) {
+ define('FILTER_VALIDATE_IS_REGEX', 99999);
+}
+
+// Device states, from Cacti include/global_constants.php.
+foreach (['HOST_UNKNOWN' => 0, 'HOST_DOWN' => 1, 'HOST_RECOVERING' => 2, 'HOST_UP' => 3, 'HOST_ERROR' => 4] as $name => $value) {
+ if (!defined($name)) {
+ define($name, $value);
+ }
+}
+
+if (!defined('RRDTOOL_OUTPUT_STDOUT')) {
+ define('RRDTOOL_OUTPUT_STDOUT', 1);
+}
+
+if (!defined('CACTI_DATE_TIME_FORMAT')) {
+ define('CACTI_DATE_TIME_FORMAT', 'Y-m-d H:i:s');
+}
+
+if (!defined('CACTI_PATH_BASE')) {
+ define('CACTI_PATH_BASE', $GLOBALS['config']['base_path']);
+}
+
+/**
+ * Load a plugin source file at global scope.
+ *
+ * Several plugin files (includes/arrays.php in particular) define their data
+ * as file-scope variables that the rest of the plugin reads as globals, and
+ * they read $config while doing so. Requiring them from inside a method would
+ * make both halves of that method-local, so the require happens here and any
+ * variable the file introduced is published to $GLOBALS.
+ *
+ * @param string $path Absolute path to the file.
+ *
+ * @return void
+ */
+function thold_test_load($path) {
+ global $config;
+
+ $__before = get_defined_vars();
+
+ require_once $path;
+
+ foreach (get_defined_vars() as $__name => $__value) {
+ if (!array_key_exists($__name, $__before) && strncmp($__name, '__', 2) !== 0) {
+ $GLOBALS[$__name] = $__value;
+ }
+ }
+}
diff --git a/tests/docker/Dockerfile b/tests/docker/Dockerfile
new file mode 100644
index 0000000..518f732
--- /dev/null
+++ b/tests/docker/Dockerfile
@@ -0,0 +1,29 @@
+# Test runner for the Thold plugin.
+#
+# Pinned to PHP 8.1 because that is the oldest interpreter the CI matrix
+# covers; what passes here passes on 8.2-8.4. pcov rather than Xdebug: line
+# coverage is the only debug feature the suite needs and pcov is far cheaper.
+FROM php:8.1-cli-alpine@sha256:7949370448b0b4d9787776dc5968e0fd8d48763292344b5fbf21539441228a98
+
+# git is needed by the changed-line coverage gate, which diffs against the
+# base branch.
+RUN apk add --no-cache git gmp-dev \
+ && docker-php-ext-install gmp \
+ && apk add --no-cache --virtual .build-deps $PHPIZE_DEPS \
+ && pecl install pcov \
+ && docker-php-ext-enable pcov \
+ && apk del .build-deps
+
+COPY --from=composer:2@sha256:4d71c3c2109c61d5415544264b59ad4087e4c5b7244481723664138fd36d5040 /usr/bin/composer /usr/bin/composer
+
+# The plugin lives where Cacti would put it, because thold_functions.php
+# resolves its own includes through $config['base_path'] . '/plugins/thold'.
+# No network or database is involved; the Cacti framework functions themselves
+# are stubbed in tests/bootstrap.php.
+WORKDIR /cacti/plugins/thold
+
+ENV COMPOSER_ALLOW_SUPERUSER=1 \
+ COMPOSER_NO_INTERACTION=1 \
+ COMPOSER_CACHE_DIR=/tmp/composer-cache
+
+CMD ["sh", "-c", "composer install --no-progress --no-ansi && composer test"]
diff --git a/tests/docker/docker-compose.yml b/tests/docker/docker-compose.yml
new file mode 100644
index 0000000..99d38b4
--- /dev/null
+++ b/tests/docker/docker-compose.yml
@@ -0,0 +1,15 @@
+# Local mirror of the unit-test CI job. `docker compose -f
+# tests/docker/docker-compose.yml run --rm phpunit` runs exactly what CI runs.
+services:
+ phpunit:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ image: cacti-thold-test:php8.1
+ working_dir: /cacti/plugins/thold
+ volumes:
+ - ../..:/cacti/plugins/thold
+ - composer-cache:/tmp/composer-cache
+
+volumes:
+ composer-cache:
diff --git a/tests/fixtures/cacti-lib/variables.php b/tests/fixtures/cacti-lib/variables.php
new file mode 100644
index 0000000..956faca
--- /dev/null
+++ b/tests/fixtures/cacti-lib/variables.php
@@ -0,0 +1,22 @@
+ 255) {
+ $s = substr($s, 0, 255);
+ }
+
+ $s = str_replace(["\0", '|', '{', '}'], '', $s);
+
+ return 'RLIKE ' . db_qstr($s, $db_conn);
+ }
+}
+
+if (!function_exists('get_total_row_data')) {
+ function get_total_row_data($user_id, $sql, $sql_params = [], $class = '', $timeout = 86400) {
+ CactiStubs::record('get_total_row_data', $sql, $sql_params);
+
+ return CactiStubs::nextReturn('get_total_row_data', 0);
+ }
+}
diff --git a/thold_functions.php b/thold_functions.php
index 018cf99..6d25c94 100644
--- a/thold_functions.php
+++ b/thold_functions.php
@@ -5279,6 +5279,40 @@ function thold_create_new_graph_from_template() {
* @param mixed $number
* @param mixed $field_name
*/
+/**
+ * SI suffixes thold accepts on a threshold bound, smallest first.
+ *
+ * thold_display_to_raw() and thold_raw_to_display() are inverses of each
+ * other, so they read the same table rather than each carrying their own
+ * copy. They previously disagreed: 'p' scaled by 1e-9 on the way in while
+ * 1e-12 rendered as 'f' on the way out, so a bound was divided by a thousand
+ * every time its form was opened and saved.
+ *
+ * @return array Suffix to the factor it multiplies by.
+ */
+function thold_unit_suffixes() {
+ static $suffixes = [
+ 'y' => 1e-24,
+ 'z' => 1e-21,
+ 'a' => 1e-18,
+ 'f' => 1e-15,
+ 'p' => 1e-12,
+ 'n' => 1e-9,
+ 'u' => 1e-6,
+ 'm' => 1e-3,
+ 'K' => 1e3,
+ 'M' => 1e6,
+ 'G' => 1e9,
+ 'T' => 1e12,
+ 'P' => 1e15,
+ 'E' => 1e18,
+ 'Z' => 1e21,
+ 'Y' => 1e24,
+ ];
+
+ return $suffixes;
+}
+
function thold_display_to_raw($number, $field_name) {
$number = trim($number);
@@ -5291,96 +5325,19 @@ function thold_display_to_raw($number, $field_name) {
return $number;
}
- $number = trim(substr($number, 0, -1));
+ $number = trim(substr($number, 0, -1));
+ $suffixes = thold_unit_suffixes();
- if (!is_numeric($number)) {
+ if (!is_numeric($number) || !isset($suffixes[$suffix])) {
$_SESSION['sess_error_fields'][$field_name] = $field_name;
raise_message(3);
return false;
}
- switch($suffix) {
- case 'f':
- return $number * 1e-15;
-
- break;
- case 'p':
- return $number * 1e-9;
-
- break;
- case 'u':
- return $number * 1e-6;
-
- break;
- case 'm':
- return $number * 1e-3;
-
- break;
- case 'K':
- return $number * 1e3;
-
- break;
- case 'M':
- return $number * 1e6;
-
- break;
- case 'G':
- return $number * 1e9;
-
- break;
- case 'T':
- return $number * 1e12;
-
- break;
- case 'P':
- return $number * 1e15;
-
- break;
- case 'E':
- return $number * 1e18;
-
- break;
- case 'Z':
- return $number * 1e21;
-
- break;
- case 'Y':
- return $number * 1e24;
-
- break;
- default:
- $_SESSION['sess_error_fields'][$field_name] = $field_name;
- raise_message(3);
-
- return false;
- }
+ return $number * $suffixes[$suffix];
}
-/**
- * thold_display_to_raw - Converts a displayed number to a raw
- * numeric value. This function converts number like '100M'
- * to the raw number 100,000,000, etc.
- *
- * Supported Units
- *
- * Unit Expression
- * ---- -------------------------------------
- * f Fermo (10e-12)
- * p Pico (10e-9)
- * u Micro (10e-6)
- * m Milli (10e-3)
- * K Killo (10e3)
- * M Mega (10e6)
- * G Giga (10e9)
- * T Terra (10e12)
- * P Peta (10e15)
- * E Exa (10e18)
- * Z Zeta (10e21)
- * Y Yota (10e24)
- *
- * @param mixed $number
- */
function thold_raw_to_display($number) {
if ($number != '') {
$number = trim($number);
@@ -5394,42 +5351,26 @@ function thold_raw_to_display($number) {
return trim($number);
}
- if ($number > 0) {
- $multiplier = 1;
- } else {
- $multiplier = -1;
- }
-
- $number = abs($number);
- $suffix = '';
-
- if ($number > 1) {
- $pattern = 'KMGTPEZY';
- $count = 0;
+ $multiplier = $number > 0 ? 1 : -1;
+ $number = abs($number);
- while ($number >= 1e3) {
- $count++;
- $number /= 1e3;
- }
+ // The largest scale that still leaves a value of one or more, where the
+ // empty suffix stands for a scale of one. Factors ascend, so the ratio
+ // falls monotonically and the last match is the one wanted.
+ $scales = thold_unit_suffixes();
+ $scales = array_slice($scales, 0, 8, true) + ['' => 1.0] + array_slice($scales, 8, null, true);
- if ($count > 0) {
- $suffix = $pattern[$count - 1];
- }
- } else {
- $pattern = 'mupf';
- $count = 0;
-
- while ($number < 1) {
- $count++;
- $number *= 1e3;
- }
+ $suffix = '';
+ $factor = 1.0;
- if ($count > 0) {
- $suffix = $pattern[$count - 1];
+ foreach ($scales as $candidate => $candidate_factor) {
+ if ($number / $candidate_factor >= 1) {
+ $suffix = $candidate;
+ $factor = $candidate_factor;
}
}
- return trim(($number * $multiplier) . $suffix);
+ return trim((($number / $factor) * $multiplier) . $suffix);
}
function save_thold() {