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";