move_detect/server/tests/MultiDownWatchTest.php
유창욱 93f896af87 fix(audit): 1.2.0 4차 재감사 — 확정 23건 전량 해소
4개 관점 병렬 감사 후 각 발견을 반증 시도해 확정. 반증 5건은 문서에 남겼다.

이번 감사의 요지: **구현도 단위테스트도 초록인데 납품 경로에서 동작하지 않는**
결함이 셋 있었다. 전부 같은 모양이다 — 테스트가 기본값이 아닌 설정을 썼거나,
값이 존재해도 되는지 결정하는 상류 허용 목록을 보지 않았다.

CRITICAL 4건:

R4-1 추락 승격이 기본 설정에서 한 번도 발화하지 않았다. link_s(3.0)를 now
기준으로 쟀는데 넘어짐은 prone_confirm_s(3.0) 지속 후 확정되므로 확정 시점에는
항상 3초 넘게 지나 있다. 다른 테스트가 전부 prone_confirm_s=0.5 를 써서
초록이었다. 착지(prone 시작) 시각과 비교하도록 고치고, 기본값 그대로 도는
회귀 테스트를 넣었다.

R4-2 max_workers:0 이 상류 두 곳에서 막혀 있었다 — 서버 규칙 경계가 [1,50]
이라 저장이 400 이고, 엣지 파서가 v<=0 으로 규칙 전체를 무효화해 같이 넣은
2인1조까지 사라졌다. 앞 커밋에서 고친 네 곳은 전부 이 둘보다 하류라 도달
불가였다.

R4-3 고소작업 프리셋(elevated)이 완전히 무동작이었다. 서버 경계에 키가 없어
400, 통과해도 엣지 파서가 침묵 삭제. 프리셋 안내문과 커밋 메시지가 시스템이
하지 않는 일을 약속하고 있었다.

R4-4 동시 다발이 한 사람을 두 명으로 셀 수 있었다. 트래커는 1초 가려짐에
트랙을 버리고 새 id 를 주고(max_age=1.0) 누운 사람은 새 트랙에서도 즉시
prone 이라 두 번째 fall 이 난다. 그러면 다친 한 사람에게 "공기호흡기 없이
진입 금지"가 나가 구조가 늦어진다 — 기능의 가치가 정반대로 뒤집힌다.
ghost 를 인원 집계에서 빼고, 거의 같은 자리의 사건은 같은 사람으로 본다.

HIGH 5건: 구역 그룹핑 사문화(엣지가 zone_id 를 안 실어 카메라가 다른 두
희생자가 절대 안 묶였다 — 밀폐공간이 정확히 그 상황), 창 미끄러짐으로 한
사고가 매 분 새 위급 건으로 쏟아지고 인원이 줄면 앞선 알림을 스스로 뒤집던
문제, 무장 미만료·forget 누락·이중 발보, 업그레이드 안내의 마이그레이션 2개
누락(신규 유형 INSERT 가 전부 DB CHECK 에 걸린다), BUILD-INFO 커밋 스탬프.

BUILD-INFO 는 1.1.0 에서 고친 R3-6 의 재발이다. 절차로 막으려다 두 번
실패했으므로 HEAD 일치를 검사하는 테스트로 막았다. README 마이그레이션
목록도 같은 이유로 테스트를 붙였다.

MEDIUM 14건 중 눈에 띄는 것: 정비 억제가 SCBA 경고를 삼켰고(허가가 발급된
그 구역·그 시간이 질식이 일어나는 자리다) 위급 3종을 억제 예외로 뺐다.
데모 시더 심각도가 제품과 7종 어긋나 fall 이 위급으로 찍혀 1.2.0 의 요점인
넘어짐/추락 구분이 데모에서 지워져 있었다 — 라벨과 달리 화면이 안 깨져
눈으로는 못 잡으므로 엣지를 단일 출처로 강제하는 테스트를 붙였다.
문서의 "17종"은 실제 20종(엣지 16 + 서버 4)으로 정정하고 신규 2종을
소개서 감지 표에 넣었다.

검증: pytest 335 passed / phpunit 269 passed. 신규 테스트 30건.
dist 는 이 커밋 이후 재생성한다(BUILD-INFO 가 HEAD 를 가리켜야 한다).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
2026-09-05 23:11:57 +09:00

220 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests;
use App\Alert\MultiDownWatch;
use App\Tests\Support\InMemoryEventRepository;
use PHPUnit\Framework\TestCase;
/**
* 같은 자리에서 짧은 시간에 둘이 쓰러지면 그것은 넘어짐 두 건이 아니라 질식·가스·폭발이다.
*
* 이 테스트가 지키는 두 가지: (1) 진짜 무리를 놓치지 않는다, (2) 무관한 넘어짐을 묶지 않는다.
* 넓은 공장에서 2분 안의 무관한 넘어짐 두 건은 실제로 일어나므로 (2)가 (1)만큼 중요하다.
*/
final class MultiDownWatchTest extends TestCase
{
private const NOW = 1_700_000_000;
private function ev(InMemoryEventRepository $repo, array $over = []): int
{
return $repo->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));
}
}