POSA_LEAKSMS/tests/threshold_sms_test.php
cw2007 86960a8586 test(threshold): 결로 판정 단언을 운영설정 독립으로 가드
condensation_is_clear(18,92)===false 단언이 결로경보 활성(METRIC_DEWPOINT_MARGIN_C>0)
전제였으나, 로컬 설정이 결로경보를 끄면(=0) is_clear 가 항상 true 로 단락돼 오탐 실패.
바로 아래 evaluate_metric_thresholds 결로 단언과 동일하게 MARGIN>0 가드로 감싸 어떤
운영 임계에서도 green 유지(파일 상단 '설정 독립' 원칙 정합).
2026-07-19 10:45:59 +09:00

146 lines
9.4 KiB
PHP

<?php
declare(strict_types=1);
// 온습도 임계 경보 판정 로직 회귀 테스트 (DB/네트워크 없이 순수 로직만).
// - evaluate_metric_thresholds(): 고온/저온/고습/저습 판정 + 대표 상태
// - metric_is_recovered(): 히스테리시스 적용 정상복귀 판정
// - metric_value_str(): 표시용 숫자 포맷
// 임계값은 config.php 의 METRIC_* 상수를 그대로 사용하며, 어떤 운영 임계 설정에서도
// 의미가 유지되도록 상대값(임계±여유, 중간값)으로 검증한다.
$root = dirname(__DIR__);
require_once $root . '/php/sms_send.php'; // config.php + 판정 함수 포함
$failures = [];
function check(bool $cond, string $msg): void {
global $failures;
if (!$cond) { $failures[] = $msg; }
}
function eq($a, $b, string $msg): void {
check($a === $b, $msg . ' (expected ' . var_export($b, true) . ', got ' . var_export($a, true) . ')');
}
// ── 함수 존재 ───────────────────────────────────────────────
check(function_exists('evaluate_metric_thresholds'), 'evaluate_metric_thresholds() should exist.');
check(function_exists('metric_is_recovered'), 'metric_is_recovered() should exist.');
check(function_exists('metric_value_str'), 'metric_value_str() should exist.');
$TH = (float)METRIC_TEMP_HIGH_C; $TL = (float)METRIC_TEMP_LOW_C;
$HH = (float)METRIC_RH_HIGH; $HL = (float)METRIC_RH_LOW;
check($TL < $TH, 'temperature thresholds sane (low < high).');
check($HL < $HH, 'humidity thresholds sane (low < high).');
$tMid = ($TL + $TH) / 2.0;
$hMid = ($HL + $HH) / 2.0;
// ── evaluate_metric_thresholds ──────────────────────────────
eq(evaluate_metric_thresholds($tMid, $hMid)['breaches'], [], 'normal temp+humidity -> no breaches');
eq(evaluate_metric_thresholds($tMid, $hMid)['status'], 'normal', 'normal -> status normal');
eq(evaluate_metric_thresholds($TH + 5, $hMid)['breaches'], ['high_temp'], 'above high temp -> high_temp');
eq(evaluate_metric_thresholds($TL - 5, $hMid)['breaches'], ['low_temp'], 'below low temp -> low_temp');
eq(evaluate_metric_thresholds($tMid, $HH + 5)['breaches'], ['high_humidity'], 'above high rh -> high_humidity');
eq(evaluate_metric_thresholds($tMid, $HL - 5)['breaches'], ['low_humidity'], 'below low rh -> low_humidity');
// 경계값 포함(>= / <=)
eq(evaluate_metric_thresholds($TH, $hMid)['breaches'], ['high_temp'], 'temp == high threshold -> high_temp (inclusive)');
eq(evaluate_metric_thresholds($TL, $hMid)['breaches'], ['low_temp'], 'temp == low threshold -> low_temp (inclusive)');
eq(evaluate_metric_thresholds($tMid, $HH)['breaches'], ['high_humidity'], 'rh == high threshold -> high_humidity (inclusive)');
eq(evaluate_metric_thresholds($tMid, $HL)['breaches'], ['low_humidity'], 'rh == low threshold -> low_humidity (inclusive)');
// 동시 위반 + 대표 상태(첫 breach)
eq(evaluate_metric_thresholds($TH + 5, $HH + 5)['breaches'], ['high_temp', 'high_humidity'], 'high temp+humidity -> both breaches');
eq(evaluate_metric_thresholds($TH + 5, $HH + 5)['status'], 'high_temp', 'status = first breach');
// 결측(null) 처리
eq(evaluate_metric_thresholds(null, $HH + 5)['breaches'], ['high_humidity'], 'null temp + high rh -> high_humidity only');
eq(evaluate_metric_thresholds($TH + 5, null)['breaches'], ['high_temp'], 'high temp + null rh -> high_temp only');
eq(evaluate_metric_thresholds(null, null)['breaches'], [], 'all null -> no breaches');
// ── metric_is_recovered (히스테리시스) ──────────────────────
check(metric_is_recovered($tMid, $hMid) === true, 'mid values are recovered');
check(metric_is_recovered($TH + 5, $hMid) === false, 'above high temp not recovered');
check(metric_is_recovered($tMid, $HH + 5) === false, 'above high rh not recovered');
// 임계 바로 안쪽(히스테리시스 데드밴드)은 복구로 보지 않음
check(metric_is_recovered($TH - ((float)METRIC_TEMP_HYSTERESIS_C / 2.0), $hMid) === false, 'temp inside hysteresis deadband not recovered');
check(metric_is_recovered($tMid, $HH - ((float)METRIC_RH_HYSTERESIS / 2.0)) === false, 'rh inside hysteresis deadband not recovered');
// 결측은 해당 지표 정상 간주
check(metric_is_recovered(null, $hMid) === true, 'null temp recovered when rh normal');
check(metric_is_recovered($tMid, null) === true, 'null rh recovered when temp normal');
// ── metric_value_str ────────────────────────────────────────
eq(metric_value_str(30.0), '30', '30.0 -> 30');
eq(metric_value_str(25.5), '25.5', '25.5 -> 25.5');
eq(metric_value_str(-3.0), '-3', '-3.0 -> -3');
eq(metric_value_str(100.0), '100', '100.0 -> 100');
eq(metric_value_str(0.0), '0', '0.0 -> 0');
// ── SMS 길이 캡(EUC-KR, sms_truncate_euckr) ─────────────────
check(function_exists('sms_truncate_euckr'), 'sms_truncate_euckr() should exist.');
check(defined('SMS_MAX_EUCKR_BYTES'), 'SMS_MAX_EUCKR_BYTES should be defined.');
eq(sms_truncate_euckr('abcdefghij', 5), 'abcde', 'ascii truncate to 5 bytes');
eq(sms_truncate_euckr('abc', 90), 'abc', 'message under cap is unchanged');
eq(sms_truncate_euckr('', 90), '', 'empty stays empty');
eq(sms_truncate_euckr('abc', 0), 'abc', 'non-positive cap is a no-op');
// EUC-KR 한글은 글자당 2바이트. 홀수 바이트 경계에서 멀티바이트 글자를 쪼개지 않아야 한다.
$euckr = iconv('UTF-8', 'EUC-KR', '가나다라마'); // 5글자 = 10바이트
check(strlen($euckr) === 10, 'precondition: 5 hangul = 10 EUC-KR bytes');
$cut5 = sms_truncate_euckr($euckr, 5); // 5바이트 → '가나'(4바이트)로 절단(글자 보존)
check(strlen($cut5) <= 5 && strlen($cut5) % 2 === 0, 'euckr truncate keeps 2-byte char boundary');
eq(iconv('EUC-KR', 'UTF-8', $cut5), '가나', 'euckr 10B truncated to 5B -> 가나');
$cut6 = sms_truncate_euckr($euckr, 6); // 6바이트 → '가나다'(6바이트)
eq(iconv('EUC-KR', 'UTF-8', $cut6), '가나다', 'euckr 10B truncated to 6B -> 가나다');
// ── 이슬점 / 결로 마진 ───────────────────────────────────────
check(function_exists('dew_point_c'), 'dew_point_c() should exist.');
check(function_exists('condensation_margin_c'), 'condensation_margin_c() should exist.');
check(function_exists('condensation_is_clear'), 'condensation_is_clear() should exist.');
// Magnus 근사: Td(20℃,50%) ≈ 9.3℃ (±0.5)
$td = dew_point_c(20.0, 50.0);
check($td !== null && abs($td - 9.3) < 0.5, 'dew point of 20C/50% ~= 9.3C (got ' . var_export($td, true) . ')');
// 결로마진 = T - Td ≈ 10.7℃
$m = condensation_margin_c(20.0, 50.0);
check($m !== null && abs($m - 10.7) < 0.6, 'condensation margin 20C/50% ~= 10.7C (got ' . var_export($m, true) . ')');
// 습도 0%/결측은 산출 불가 → null, 위험 없음
check(dew_point_c(20.0, 0.0) === null, 'dew point undefined at 0% RH');
check(condensation_margin_c(null, 50.0) === null, 'margin null when temp missing');
// 고온고습(margin 좁음)은 결로 위험으로 미해소 — 결로경보 활성(MARGIN>0)일 때만 의미가 있다.
// (비활성이면 condensation_is_clear 는 항상 true 로 단락 → 아래 여유값 해소 단언과 일관)
if (METRIC_DEWPOINT_MARGIN_C > 0) {
check(condensation_is_clear(18.0, 92.0) === false, '18C/92% has tight margin -> not clear');
}
// 여유 있는 값은 해소
check(condensation_is_clear(20.0, 50.0) === true, '20C/50% margin wide -> clear');
// 결측은 정상(해소)로 간주
check(condensation_is_clear(null, 50.0) === true, 'null temp -> condensation clear');
// evaluate_metric_thresholds 가 결로 위험을 별도 종류로 추가
if (METRIC_DEWPOINT_MARGIN_C > 0) {
check(in_array('condensation_risk', evaluate_metric_thresholds(18.0, 92.0)['breaches'], true),
'18C/92% should include condensation_risk breach');
check(!in_array('condensation_risk', evaluate_metric_thresholds($tMid, $hMid)['breaches'], true),
'normal mid values have no condensation_risk');
}
// ── 수신번호 마스킹 ─────────────────────────────────────────
check(function_exists('mask_phone'), 'mask_phone() should exist.');
eq(mask_phone('01012345678'), '010-****-5678', 'mask 11-digit mobile');
eq(mask_phone('010-1234-5678'), '010-****-5678', 'mask with hyphens');
eq(mask_phone('123'), '***', 'too short -> all masked');
eq(mask_phone('01011112222, 01033334444'), '010-****-2222, 010-****-4444', 'mask comma-separated list');
// ── 표준편차 (센서 고착 검출) ────────────────────────────────
check(function_exists('stddev_pop'), 'stddev_pop() should exist.');
check(stddev_pop([1.0, 1.0, 1.0, 1.0]) === 0.0, 'identical values -> stddev 0 (stuck signal)');
check(abs(stddev_pop([2, 4, 4, 4, 5, 5, 7, 9]) - 2.0) < 1e-9, 'known population stddev == 2.0');
check(stddev_pop([]) === 0.0, 'empty -> 0');
check(stddev_pop([23.45, 23.45, 23.45, 23.45, 23.45, 23.45]) === 0.0, 'flat SHT30 readings -> stuck');
if ($failures) {
fwrite(STDERR, "Threshold SMS logic test failures:\n");
foreach ($failures as $f) {
fwrite(STDERR, "- {$f}\n");
}
exit(1);
}
echo "Threshold SMS logic checks passed.\n";