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.50, 'spatial_y' => 0.50]); $this->ev($repo, ['track_id' => 2, 'spatial_x' => 0.55, 'spatial_y' => 0.52, '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]); $this->ev($repo, ['track_id' => 2, '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]); $this->ev($repo, ['track_id' => 2, 'occurred_at' => (float)(self::NOW - 30)]); $w = new MultiDownWatch($repo); self::assertCount(1, $w->run(self::NOW)); $this->ev($repo, ['track_id' => 3, 'occurred_at' => (float)(self::NOW - 10)]); $again = $w->run(self::NOW + 5); self::assertCount(1, $again); self::assertSame(3, $again[0]['people']); } 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']); $this->ev($repo, ['track_id' => 2, 'event_type' => 'no_motion', 'occurred_at' => (float)(self::NOW - 30)]); self::assertCount(1, (new MultiDownWatch($repo))->run(self::NOW)); } }