insert($over + [ 'event_uuid' => bin2hex(random_bytes(16)), 'device_id' => 'pi-01', 'site' => 'site-a', 'camera_id' => 'cam-01', 'event_type' => 'fall', 'severity' => 'high', 'track_id' => 1, 'zone_id' => null, 'occurred_at' => (float)(self::NOW - 60), 'duration_s' => 0.0, 'metrics' => [], 'spatial_x' => 0.50, 'spatial_y' => 0.50, ]); } public function testTwoPeopleDownInTheSameSpotFilesOneCriticalEvent(): void { $repo = new InMemoryEventRepository(); $this->ev($repo, ['track_id' => 1, 'spatial_x' => 0.40, 'spatial_y' => 0.50]); $this->ev($repo, ['track_id' => 2, 'spatial_x' => 0.58, 'spatial_y' => 0.55, 'occurred_at' => (float)(self::NOW - 30)]); $filed = (new MultiDownWatch($repo))->run(self::NOW); self::assertCount(1, $filed); self::assertSame(2, $filed[0]['people']); $row = $repo->find($filed[0]['event_id']); self::assertSame('multi_down', $row['event_type']); self::assertSame('critical', $row['severity']); self::assertCount(2, $row['metrics']['origin_event_ids']); } public function testOnePersonFallingTwiceIsNotAGroup(): void { // 넘어졌다 회복했다 다시 넘어진 것은 한 명이다. $repo = new InMemoryEventRepository(); $this->ev($repo, ['track_id' => 7]); $this->ev($repo, ['track_id' => 7, 'occurred_at' => (float)(self::NOW - 20)]); self::assertSame([], (new MultiDownWatch($repo))->run(self::NOW)); } public function testFarApartFallsAreNotAGroup(): void { // 같은 카메라라도 화면 반대편은 다른 사건이다. $repo = new InMemoryEventRepository(); $this->ev($repo, ['track_id' => 1, 'spatial_x' => 0.05, 'spatial_y' => 0.05]); $this->ev($repo, ['track_id' => 2, 'spatial_x' => 0.95, 'spatial_y' => 0.95, 'occurred_at' => (float)(self::NOW - 30)]); self::assertSame([], (new MultiDownWatch($repo))->run(self::NOW, ['radius' => 0.35])); } public function testFallsOutsideTheWindowAreNotAGroup(): void { $repo = new InMemoryEventRepository(); $this->ev($repo, ['track_id' => 1, 'occurred_at' => (float)(self::NOW - 1000)]); $this->ev($repo, ['track_id' => 2, 'occurred_at' => (float)(self::NOW - 10)]); self::assertSame([], (new MultiDownWatch($repo))->run(self::NOW, ['window_s' => 120])); } public function testSameZoneGroupsEvenWithoutSpatialProximity(): void { // 운영자가 "여기가 한 공간"이라고 선언한 것이 좌표 거리보다 정확하다. $repo = new InMemoryEventRepository(); $this->ev($repo, ['track_id' => 1, 'zone_id' => 'z-tank', 'spatial_x' => 0.05, 'spatial_y' => 0.05]); $this->ev($repo, ['track_id' => 2, 'zone_id' => 'z-tank', 'spatial_x' => 0.95, 'spatial_y' => 0.95, 'occurred_at' => (float)(self::NOW - 20)]); $filed = (new MultiDownWatch($repo))->run(self::NOW); self::assertCount(1, $filed); self::assertSame('zone:z-tank', $filed[0]['scope']); } public function testRepeatedRunsDoNotDuplicate(): void { $repo = new InMemoryEventRepository(); $this->ev($repo, ['track_id' => 1, 'spatial_x' => 0.40]); $this->ev($repo, ['track_id' => 2, 'spatial_x' => 0.58, 'occurred_at' => (float)(self::NOW - 30)]); $w = new MultiDownWatch($repo); self::assertCount(1, $w->run(self::NOW)); self::assertSame([], $w->run(self::NOW + 60)); self::assertSame([], $w->run(self::NOW + 90)); } public function testAThirdPersonRaisesItAgain(): void { // 사람이 늘어난 것은 상황이 악화된 것이다 — 한 번 더 올라와야 한다. $repo = new InMemoryEventRepository(); $this->ev($repo, ['track_id' => 1, 'spatial_x' => 0.30]); $this->ev($repo, ['track_id' => 2, 'spatial_x' => 0.50, 'occurred_at' => (float)(self::NOW - 30)]); $w = new MultiDownWatch($repo); self::assertCount(1, $w->run(self::NOW)); $this->ev($repo, ['track_id' => 3, 'spatial_x' => 0.62, 'occurred_at' => (float)(self::NOW - 10)]); $again = $w->run(self::NOW + 5); self::assertCount(1, $again); self::assertSame(3, $again[0]['people']); } // --- 한 사람을 여러 명으로 세지 않는다 --------------------------------------------- // // 이 묶음이 이 클래스에서 가장 중요하다. 잘못 세면 다친 한 사람에게 // "공기호흡기 없이 진입하지 마십시오"를 보내 **구조를 늦춘다** — 기능의 가치가 // 정반대로 뒤집히는 경우라 단순한 오탐보다 훨씬 나쁘다. public function testTrackerReAcquisitionOfOneVictimIsNotTwoPeople(): void { // 트래커는 1초만 가려져도 트랙을 버리고 새 id 를 준다(edge/tracking.py max_age=1.0). // 바닥에 누운 사람은 새 트랙에서도 즉시 prone 이라 같은 사람이 두 번째 fall 을 만든다. $repo = new InMemoryEventRepository(); $this->ev($repo, ['track_id' => 7, 'spatial_x' => 0.40, 'spatial_y' => 0.60]); $this->ev($repo, ['track_id' => 12, 'spatial_x' => 0.41, 'spatial_y' => 0.60, 'occurred_at' => (float)(self::NOW - 20)]); self::assertSame([], (new MultiDownWatch($repo))->run(self::NOW), '같은 자리의 재획득을 두 명으로 세면 구조가 늦어진다'); } public function testGhostIsNotCountedAsAPerson(): void { // ghost 는 이미 쓰러진 사람의 후속 사건이다. 세면 한 희생자가 두 명이 된다. $repo = new InMemoryEventRepository(); $this->ev($repo, ['track_id' => 7, 'spatial_x' => 0.40]); $this->ev($repo, ['track_id' => 7, 'event_type' => 'ghost', 'spatial_x' => 0.75, 'occurred_at' => (float)(self::NOW - 20)]); self::assertSame([], (new MultiDownWatch($repo))->run(self::NOW)); } public function testFutureTimestampedEventCannotManufactureAGroup(): void { // 폐쇄망은 NTP 가 없는 경우가 흔해 장치 시계가 앞서 나간다. $repo = new InMemoryEventRepository(); $this->ev($repo, ['track_id' => 1, 'spatial_x' => 0.40, 'occurred_at' => (float)(self::NOW + 600)]); // 시계 10분 빠름 $this->ev($repo, ['track_id' => 2, 'spatial_x' => 0.60, 'occurred_at' => (float)(self::NOW - 10)]); self::assertSame([], (new MultiDownWatch($repo))->run(self::NOW)); } public function testPrefixCameraIdsAreNotMerged(): void { // 문자열 접두사로 묶으면 cam-1 과 cam-10 이 한 사건이 된다. $repo = new InMemoryEventRepository(); $this->ev($repo, ['track_id' => 1, 'camera_id' => 'cam-10', 'spatial_x' => 0.50]); $this->ev($repo, ['track_id' => 2, 'camera_id' => 'cam-1', 'spatial_x' => 0.52, 'occurred_at' => (float)(self::NOW - 30)]); self::assertSame([], (new MultiDownWatch($repo))->run(self::NOW)); } public function testVictimWithoutCoordinatesStillGroups(): void { // 좌표가 없는 사건(ghost 경로 등)이 섞였다고 진짜 동시 다발을 놓치면 안 된다. $repo = new InMemoryEventRepository(); $this->ev($repo, ['track_id' => 1, 'spatial_x' => null, 'spatial_y' => null]); $this->ev($repo, ['track_id' => 2, 'spatial_x' => 0.50, 'spatial_y' => 0.50, 'occurred_at' => (float)(self::NOW - 20)]); self::assertCount(1, (new MultiDownWatch($repo))->run(self::NOW)); } public function testSlidingWindowDoesNotRefileTheSameIncident(): void { // 창이 미끄러지면 group[0] 이 바뀌어 uuid 가 달라진다. 그것만으로는 같은 사고가 // 매 분 새 위급 건으로 쏟아지고, 인원이 줄면 앞선 알림을 스스로 뒤집는다. $repo = new InMemoryEventRepository(); $this->ev($repo, ['track_id' => 1, 'spatial_x' => 0.30, 'occurred_at' => (float)(self::NOW - 100)]); $this->ev($repo, ['track_id' => 2, 'spatial_x' => 0.50, 'occurred_at' => (float)(self::NOW - 60)]); $this->ev($repo, ['track_id' => 3, 'spatial_x' => 0.62, 'occurred_at' => (float)(self::NOW - 20)]); $w = new MultiDownWatch($repo); $total = 0; for ($t = 0; $t <= 600; $t += 60) { // 크론 10분치 $total += count($w->run(self::NOW + $t)); } self::assertLessThanOrEqual(2, $total, "한 사고가 $total 건으로 올라갔다"); self::assertGreaterThanOrEqual(1, $total); } public function testNonDownTypesAreIgnored(): void { // 니어미스·자세부담은 사람이 쓰러진 것이 아니다. $repo = new InMemoryEventRepository(); $this->ev($repo, ['track_id' => 1, 'event_type' => 'near_fall']); $this->ev($repo, ['track_id' => 2, 'event_type' => 'ergo_report', 'occurred_at' => (float)(self::NOW - 30)]); self::assertSame([], (new MultiDownWatch($repo))->run(self::NOW)); } public function testFallFromHeightCountsAsDown(): void { $repo = new InMemoryEventRepository(); $this->ev($repo, ['track_id' => 1, 'event_type' => 'fall_from_height', 'spatial_x' => 0.35]); $this->ev($repo, ['track_id' => 2, 'event_type' => 'no_motion', 'spatial_x' => 0.60, 'occurred_at' => (float)(self::NOW - 30)]); self::assertCount(1, (new MultiDownWatch($repo))->run(self::NOW)); } }