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); } }