move_detect/server/tests/ReportTest.php
유창욱 d8b8e1f381 feat(analytics): weekday-hour pattern, precursor-spike alert, period trends
The accumulated data starts predicting instead of just recording:

- Time pattern (weekday x hour density grid): WHEN the site is dangerous, on
  one card - shift-change, post-lunch, Thursday-afternoon hotspots become
  visible. Bucketing runs in PHP on the server-local clock (site timezone)
  so the SQL stays portable (the sqlite harness has no FROM_UNIXTIME).
- Precursor-spike alert: last-7-days count of precursor signals (near-miss,
  zone entries, occupancy violations - signals, not injuries) vs the prior
  4-week weekly baseline. Ratio >= 1.5 with at least 5 samples raises a
  banner above the KPIs: an alarm on the PRECURSOR of an accident, with a
  concrete suggestion (staffing/route/rest review). Below the sample floor
  it never fires - a ratio over 2 events is noise, not statistics.
- Period-over-period trends on every KPI: reportSummary gained an optional
  upper bound so the identical aggregation runs over the preceding window.
  Worsening turns red, improving turns accent - except the near-miss count,
  which stays NEUTRAL: more near-misses may mean better detection, not more
  danger, and the report must not editorialize that.

Verified: PHPUnit 140 pass (2 new: window separation + pattern bucketing,
spike ratio + sample floor) + Chrome real-render (alert banner, KPI trend
lines, Thursday-afternoon hotspot readable in the grid).
2026-08-07 07:35:30 +09:00

146 lines
7.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests;
use App\Http\Request;
use App\Auth\ArraySessionStore;
use App\Dashboard\ReportController;
use App\Tests\Support\InMemoryEventRepository;
use PHPUnit\Framework\TestCase;
final class ReportTest extends TestCase
{
private function sess(?string $site = null): ArraySessionStore
{
$s = new ArraySessionStore();
$s->set('user_id', 1);
if ($site !== null) $s->set('site_scope', $site);
return $s;
}
private function repo(): InMemoryEventRepository
{
$r = new InMemoryEventRepository();
$now = (float)time();
// 확인까지 60초/120초 걸린 중대 이벤트 2건 (TTA 평균 90초)
$a = $r->insert(['camera_id' => 'cam-1', 'event_type' => 'fall', 'severity' => 'high',
'site' => 'a', 'occurred_at' => $now - 600, 'metrics' => []]);
$r->rows[$a]['acknowledged_by'] = 'u'; $r->rows[$a]['acknowledged_at'] = ($now - 600) + 60;
$b = $r->insert(['camera_id' => 'cam-1', 'event_type' => 'no_motion', 'severity' => 'critical',
'site' => 'a', 'occurred_at' => $now - 500, 'metrics' => []]);
$r->rows[$b]['acknowledged_by'] = 'u'; $r->rows[$b]['acknowledged_at'] = ($now - 500) + 120;
// FP 태그된 중대 이벤트 — TTA에서 제외되어야 한다
$c = $r->insert(['camera_id' => 'cam-2', 'event_type' => 'fall', 'severity' => 'high',
'site' => 'a', 'occurred_at' => $now - 400, 'metrics' => []]);
$r->markFalsePositive($c, 'u');
// 니어미스 1건, 미확인
$r->insert(['camera_id' => 'cam-1', 'event_type' => 'near_fall', 'severity' => 'info',
'site' => 'a', 'occurred_at' => $now - 300, 'metrics' => []]);
// 존 규칙 위반
$r->insert(['camera_id' => 'cam-2', 'event_type' => 'lone_worker', 'severity' => 'high',
'site' => 'a', 'occurred_at' => $now - 200, 'metrics' => []]);
// ergo 리포트 2건 — 합산되고, "감지 N건"에는 포함되지 않아야 한다
$r->insert(['camera_id' => 'cam-1', 'event_type' => 'ergo_report', 'severity' => 'info',
'site' => 'a', 'occurred_at' => $now - 100,
'metrics' => ['observed_s' => 1000.0, 'trunk_high_s' => 400.0]]);
$r->insert(['camera_id' => 'cam-1', 'event_type' => 'ergo_report', 'severity' => 'info',
'site' => 'a', 'occurred_at' => $now - 50,
'metrics' => ['observed_s' => 500.0, 'trunk_high_s' => 100.0, 'squat_s' => 30.0]]);
// 다른 사이트의 이벤트 — 스코프 밖
$r->insert(['camera_id' => 'cam-9', 'event_type' => 'fall', 'severity' => 'high',
'site' => 'b', 'occurred_at' => $now - 100, 'metrics' => []]);
return $r;
}
private function get(ReportController $c, string $days = '7'): array
{
$res = $c->json(new Request('GET', '/api/report', [], '', ['days' => $days], []));
$this->assertSame(200, $res->status());
return json_decode($res->body(), true);
}
public function testRequiresLogin(): void
{
$c = new ReportController($this->repo(), new ArraySessionStore());
$this->assertSame(401, $c->json(new Request('GET', '/api/report', [], '', [], []))->status());
}
public function testAggregatesManagementIndicators(): void
{
$data = $this->get(new ReportController($this->repo(), $this->sess()));
$this->assertSame(7, $data['days']);
// 감지 총계: ergo_report 2건 제외, 사이트 b 포함(전역 스코프) → 6건
$this->assertSame(6, $data['totals']['events']);
$this->assertSame(1, $data['totals']['critical']);
$this->assertSame(4, $data['totals']['high']);
// TTA: FP 제외한 확인된 중대 2건, 평균 90초
$this->assertSame(2, $data['tta']['acked']);
$this->assertEquals(90.0, $data['tta']['avg_s']);
$this->assertEquals(120.0, $data['tta']['max_s']);
// 니어미스 피라미드와 규칙 위반
$this->assertSame(1, $data['near_miss']['near_fall']);
$this->assertSame(1, $data['occupancy']['lone_worker']);
// 오탐 1건
$this->assertSame(1, $data['fp']['tagged']);
// ergo 합산 (건수 아님)
$this->assertEquals(1500.0, $data['ergo']['observed_s']);
$this->assertEquals(500.0, $data['ergo']['trunk_high_s']);
$this->assertEquals(30.0, $data['ergo']['squat_s']);
$this->assertArrayNotHasKey('ergo_report', $data['by_type']);
}
public function testPreviousWindowTrendAndPattern(): void
{
$r = new InMemoryEventRepository();
$now = (float)time();
// 이번 7일: 중대 1건 / 직전 7일(7~14일 전): 중대 3건
$r->insert(['camera_id' => 'c', 'event_type' => 'fall', 'severity' => 'high',
'occurred_at' => $now - 3600, 'metrics' => []]);
foreach ([8, 9, 10] as $d) {
$r->insert(['camera_id' => 'c', 'event_type' => 'fall', 'severity' => 'high',
'occurred_at' => $now - $d * 86400, 'metrics' => []]);
}
$data = $this->get(new ReportController($r, $this->sess()));
$this->assertSame(1, $data['totals']['high']);
$this->assertSame(3, $data['previous']['high']); // 직전 기간만, 이번 기간과 겹치지 않음
// 시간 패턴: 이번 기간 1건이 그 발생 시각의 요일×시간 셀에 찍힌다
$ts = (int)($now - 3600);
$d = (int)date('N', $ts) - 1;
$h = (int)date('G', $ts);
$this->assertSame(1, $data['pattern']['grid'][$d][$h]);
$this->assertSame(1, $data['pattern']['max']);
}
public function testPrecursorSpikeAlert(): void
{
$r = new InMemoryEventRepository();
$now = (float)time();
// 기준선: 이전 4주에 주당 2건 (총 8건) / 최근 7일: 6건 → 3배, 표본 충분 → 경보
for ($i = 0; $i < 8; $i++) {
$r->insert(['camera_id' => 'c', 'event_type' => 'zone_warning', 'severity' => 'warning',
'occurred_at' => $now - (8 + $i * 3) * 86400, 'metrics' => []]);
}
for ($i = 0; $i < 6; $i++) {
$r->insert(['camera_id' => 'c', 'event_type' => 'near_fall', 'severity' => 'info',
'occurred_at' => $now - $i * 3600 - 60, 'metrics' => []]);
}
$data = $this->get(new ReportController($r, $this->sess()));
$this->assertTrue($data['precursor_alert']['alert']);
$this->assertSame(6, $data['precursor_alert']['this_week']);
$this->assertEquals(2.0, $data['precursor_alert']['baseline_weekly_avg']);
// 표본 미달이면 비율이 높아도 경보 없음
$quiet = new InMemoryEventRepository();
$quiet->insert(['camera_id' => 'c', 'event_type' => 'near_fall', 'severity' => 'info',
'occurred_at' => $now - 60, 'metrics' => []]);
$qd = $this->get(new ReportController($quiet, $this->sess()));
$this->assertFalse($qd['precursor_alert']['alert']);
}
public function testSiteScopeFiltersReport(): void
{
$data = $this->get(new ReportController($this->repo(), $this->sess('a')));
$this->assertSame(5, $data['totals']['events']); // 사이트 b의 fall 제외
$cams = array_column($data['by_camera'], 'camera_id');
$this->assertNotContains('cam-9', $cams);
}
}