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.
110 lines
3.9 KiB
PHP
110 lines
3.9 KiB
PHP
<?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);
|
||
}
|