condensation_is_clear(18,92)===false 단언이 결로경보 활성(METRIC_DEWPOINT_MARGIN_C>0) 전제였으나, 로컬 설정이 결로경보를 끄면(=0) is_clear 가 항상 true 로 단락돼 오탐 실패. 바로 아래 evaluate_metric_thresholds 결로 단언과 동일하게 MARGIN>0 가드로 감싸 어떤 운영 임계에서도 green 유지(파일 상단 '설정 독립' 원칙 정합).
146 lines
9.4 KiB
PHP
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";
|