Next healthcheck due date, plus a test suite (hc-nkq)

Lets a technician record when a site should next be looked at, and surfaces
that in the dashboard so it can be used for planning.

Server:
- migration 0002 adds healthchecks.next_due, a VARCHAR(10) 'YYYY-MM-DD'
  calendar date rather than a timestamp — it's a diary date with no
  time-of-day, and a timestamp would render as the wrong day off-server.
- Kept per-session rather than on a sites table, so the history of what was
  scheduled when is preserved. A site's current next-due is the value on its
  most recent session: if the latest visit scheduled nothing, the site reads
  as unscheduled rather than showing the just-completed visit as overdue.
- Validate::optionalDate() round-trips through createFromFormat, which
  rejects both '2026-2-3' and '2026-02-30' (silently rolled to March 2nd).
- next_due follows the same missing-vs-null PUT contract as finished_at, and
  lives in its own Store::setNextDue() so finishing or reopening a session
  never disturbs the date and vice versa.
- Dashboard shows it on the site list, the per-site session table and the
  session detail, flagged overdue / today / soon (within a fortnight).

Plugin:
- Date control on both the active and finished panels — the moment you know
  when to return is often wrap-up, after the report is generated.
- Write-through like every other mutation. Bad input is rejected with a
  notice rather than silently clearing an existing date.
- Included in both the Markdown and HTML reports.

Tests (new — tests/, export-ignored from the plugin zip):
- Hand-rolled harness, no composer/PHPUnit, in keeping with the repo.
- 42 tests: plugin-side date parsing units, plus server integration tests
  driven over real HTTP against a temp `php -S` instance with a throwaway
  SQLite DB, so a real server/config.php is never touched.
- Mutation-checked: dropping the array_key_exists guard on PUT fails three
  tests, as intended.
This commit is contained in:
2026-07-23 12:02:39 +01:00
parent f551b64e2e
commit 631385721f
17 changed files with 1116 additions and 12 deletions

109
tests/lib/harness.php Normal file
View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
/**
* Minimal test harness — no composer, no PHPUnit, matching the rest of the repo.
*
* Usage:
* test('description', function () { assert_same($expected, $actual, 'why'); });
*
* Failures are recorded and reported at the end; the process exits non-zero if
* anything failed, so this is usable as a CI/pre-push gate.
*/
final class TestRun {
/** @var array<int, array{name:string, error:string}> */
public static array $failures = [];
public static int $passed = 0;
public static int $assertions = 0;
public static string $group = '';
}
final class AssertionFailed extends RuntimeException {}
function group(string $name): void {
TestRun::$group = $name;
echo "\n\033[1m" . $name . "\033[0m\n";
}
function test(string $name, callable $fn): void {
try {
$fn();
TestRun::$passed++;
echo " \033[32m✓\033[0m " . $name . "\n";
} catch (AssertionFailed $e) {
TestRun::$failures[] = ['name' => TestRun::$group . ' ' . $name, 'error' => $e->getMessage()];
echo " \033[31m✗ " . $name . "\033[0m\n " . str_replace("\n", "\n ", $e->getMessage()) . "\n";
} catch (Throwable $e) {
$where = basename($e->getFile()) . ':' . $e->getLine();
TestRun::$failures[] = ['name' => TestRun::$group . ' ' . $name, 'error' => get_class($e) . ': ' . $e->getMessage() . ' @ ' . $where];
echo " \033[31m✗ " . $name . "\033[0m\n " . get_class($e) . ': ' . $e->getMessage() . ' @ ' . $where . "\n";
}
}
function assert_same($expected, $actual, string $message = ''): void {
TestRun::$assertions++;
if ($expected !== $actual) {
throw new AssertionFailed(
($message !== '' ? $message . "\n" : '')
. 'expected: ' . compact_export($expected) . "\n"
. 'actual: ' . compact_export($actual)
);
}
}
function assert_true($actual, string $message = ''): void {
assert_same(true, $actual, $message !== '' ? $message : 'expected true');
}
function assert_null($actual, string $message = ''): void {
assert_same(null, $actual, $message !== '' ? $message : 'expected null');
}
function assert_contains(string $needle, string $haystack, string $message = ''): void {
TestRun::$assertions++;
if (strpos($haystack, $needle) === false) {
throw new AssertionFailed(
($message !== '' ? $message . "\n" : '')
. 'expected to find: ' . $needle . "\n"
. 'in: ' . truncate($haystack, 600)
);
}
}
function assert_not_contains(string $needle, string $haystack, string $message = ''): void {
TestRun::$assertions++;
if (strpos($haystack, $needle) !== false) {
throw new AssertionFailed(
($message !== '' ? $message . "\n" : '')
. 'did NOT expect to find: ' . $needle . "\n"
. 'in: ' . truncate($haystack, 600)
);
}
}
function compact_export($value): string {
if (is_string($value)) return "'" . $value . "'";
if ($value === null) return 'null';
if (is_bool($value)) return $value ? 'true' : 'false';
if (is_array($value)) return truncate(json_encode($value, JSON_UNESCAPED_SLASHES), 400);
return (string) $value;
}
function truncate(string $s, int $max): string {
return strlen($s) <= $max ? $s : substr($s, 0, $max) . '… (' . strlen($s) . ' bytes)';
}
function report_and_exit(): void {
$failed = count(TestRun::$failures);
echo "\n" . str_repeat('─', 60) . "\n";
if ($failed === 0) {
echo "\033[32mPASS\033[0m — " . TestRun::$passed . ' tests, ' . TestRun::$assertions . " assertions\n";
exit(0);
}
echo "\033[31mFAIL\033[0m — " . $failed . ' failed, ' . TestRun::$passed . " passed\n\n";
foreach (TestRun::$failures as $f) {
echo "" . $f['name'] . "\n " . str_replace("\n", "\n ", $f['error']) . "\n\n";
}
exit(1);
}