move_detect/server/tests/ReportTest.php
유창욱 d8cef54fe4 feat(report): safety operations report page + event-type filter
The benefit made visible: /report folds everything the system collects into
the one printable page a 안전관리자 takes into the weekly safety meeting.

- GET /api/report?days=N (7/30/90): severity totals, per-type counts,
  worst-camera ranking, response time (TTA - acknowledged critical/high only,
  FP-tagged rows excluded: dismissing a false alarm is not an emergency
  response; clock-skew guarded), the near-miss pyramid (near_fall vs
  fall/no_motion - the Heinrich argument in one ratio), FP rate, summed
  posture-load exposure, occupancy-rule violation counts. ergo_report rows
  feed the sums but are excluded from "감지 N건" (bookkeeping, not detection).
  Fail-closed site scoping throughout.
- /report view: KPI band (중대 이벤트 / 평균 대응 / 니어미스:사고 / 오탐율,
  each with its one-line reading), bar lists for types and cameras (critical
  presence highlighted), ergo bands as share-of-observed-time, rule tiles.
  Print CSS strips the app chrome - the 인쇄 button produces the meeting
  handout. New 리포트 nav tab.
- Events console: event-type filter beside severity - the INFO-volume types
  (near_fall, ergo_report) can now be folded out of the live stream, or a
  single type isolated for investigation.

Verified: PHPUnit 138 pass (3 new: aggregation math incl. TTA/FP-exclusion/
ergo sums, site scope, auth) + Chrome real-render of the full report (KPIs,
bar lists, rule tiles, footer) and the type filter isolating one type.
2026-08-07 07:17:48 +09:00

99 lines
4.8 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 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);
}
}