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).
146 lines
7.3 KiB
PHP
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);
|
|
}
|
|
}
|