load(); \support\App::loadAllConfig(['route']); date_default_timezone_set('Asia/Shanghai'); use app\service\water\DeviceReport; use plugin\saiadmin\app\controller\water\WaterController; use plugin\saiadmin\app\model\water\{WaterDevice, WaterDeviceUsage, WaterSku, WaterAccount}; use plugin\saiadmin\app\service\{WaterBusiness, WaterDeviceDashboard}; use plugin\saiadmin\exception\ApiException; $checks = 0; function check(bool $condition, string $message): void { global $checks; $checks++; if (!$condition) { throw new RuntimeException($message); } } function rejects(callable $callback, string $message): void { try { $callback(); } catch (ApiException $error) { check(true, $message); return; } throw new RuntimeException($message); } function workbook(string $content): array { $path = tempnam(sys_get_temp_dir(), 'device-test-'); $reader = new \OpenSpout\Reader\XLSX\Reader(); try { file_put_contents($path, $content); $reader->open($path); $rows = []; foreach ($reader->getSheetIterator() as $sheet) { foreach ($sheet->getRowIterator() as $row) { $rows[] = $row->toArray(); } } return $rows; } finally { $reader->close(); unlink($path); } } $db = (new WaterDevice())->getConnection(); $tag = 'DEVICE-UI-' . bin2hex(random_bytes(5)); $now = new DateTimeImmutable(); $service = new WaterDeviceDashboard($now); $business = new WaterBusiness(); $counts = [WaterDevice::query()->count(), WaterDeviceUsage::query()->count()]; $db->beginTransaction(); try { $baseline = $service->dashboard([]); $enterprise = $business->save('enterprise', ['name' => $tag . '-enterprise', 'contact' => 'test', 'phone' => 'TEST', 'address' => 'TEST', 'region' => $tag], 1)['id']; $sku = $business->save('sku', ['name' => $tag, 'liters' => 18.9], 1)['id']; $ids = []; for ($i = 0; $i < 10; $i++) { $ids[] = $business->save('device', [ 'name' => ($i === 0 ? '=' : '') . $tag . '-' . $i, 'sn' => $tag . '-' . $i, 'enterprise_id' => $enterprise, 'sku_id' => $sku, 'firmware' => $i % 2 ? 'v-test-2' : 'v-test-1', ], 1)['id']; } WaterDevice::query()->whereIn('id', $ids)->update(['created_at' => $now->format('Y-m-d H:i:s')]); WaterDevice::query()->whereIn('id', array_slice($ids, 0, 4))->update(['last_seen' => $now->format('Y-m-d H:i:s')]); WaterDevice::query()->where('id', $ids[4])->update(['last_seen' => $now->modify('-15 minutes')->format('Y-m-d H:i:s')]); WaterDevice::query()->where('id', $ids[5])->update(['last_seen' => $now->modify('-901 seconds')->format('Y-m-d H:i:s')]); WaterDevice::query()->where('id', $ids[6])->update(['last_seen' => $now->modify('-25 hours')->format('Y-m-d H:i:s')]); WaterDevice::query()->where('id', $ids[7])->update(['fault' => '流量上报异常', 'last_seen' => $now->format('Y-m-d H:i:s')]); WaterDevice::query()->where('id', $ids[8])->update(['fault' => '流量上报异常', 'last_seen' => $now->modify('-26 hours')->format('Y-m-d H:i:s')]); WaterDevice::query()->where('id', $ids[0])->update(['today_liters' => 0, 'telemetry_date' => $now->format('Y-m-d')]); WaterDevice::query()->where('id', $ids[1])->update(['today_liters' => 8, 'telemetry_date' => $now->modify('-1 day')->format('Y-m-d')]); $first = $service->dashboard(['keyword' => ' ' . $tag . ' ', 'limit' => 8]); $second = $service->dashboard(['keyword' => $tag, 'page' => 2, 'limit' => 8]); check($first['list']['total'] === 10 && count($first['list']['data']) === 8 && count($second['list']['data']) === 2, 'eight-item pagination'); $rows = array_merge($first['list']['data'], $second['list']['data']); check(array_column($rows, 'id') === array_reverse($ids), 'stable pagination without duplicates'); check($first['summary'] === $second['summary'], 'global cards independent of pagination'); check($first['summary'] === $service->dashboard(['keyword' => $tag . '-missing'])['summary'], 'global cards independent of search'); foreach (['total' => 10, 'online' => 5, 'offline' => 2, 'fault' => 2, 'unconnected' => 1, 'offline_long' => 1, 'new_month' => 10] as $key => $delta) { check($first['summary'][$key] - $baseline['summary'][$key] === $delta, 'summary delta ' . $key); } check(array_sum(array_intersect_key($first['summary'], array_flip(['online', 'offline', 'fault', 'unconnected']))) === $first['summary']['total'], 'exclusive state counters reconcile'); $byId = array_column($rows, null, 'id'); check($byId[$ids[4]]['connection_status'] === 'online' && $byId[$ids[5]]['connection_status'] === 'offline', '15-minute boundary'); check($byId[$ids[7]]['connection_status'] === 'fault' && $byId[$ids[8]]['connection_status'] === 'fault', 'fault takes precedence over connectivity'); check($byId[$ids[9]]['last_seen_seconds'] === null && $byId[$ids[9]]['connection_status'] === 'unconnected', 'no heartbeat is unconnected'); check($byId[$ids[0]]['today_liters'] === 0.0 && $byId[$ids[1]]['today_liters'] === null, 'zero usage differs from missing current-day data'); check($service->dashboard(['keyword' => $tag . '-enterprise', 'status' => 'offline'])['list']['total'] === 2, 'enterprise and status filter'); check($service->dashboard(['keyword' => $tag, 'region' => $tag, 'firmware' => 'v-test-2'])['list']['total'] === 5, 'combined region and version filters'); check($service->dashboard(['keyword' => $tag, 'region' => 'nonexistent'])['list']['total'] === 0, 'empty filter result'); check(in_array($tag, $first['options']['regions'], true), 'region options from existing devices'); check(array_column($first['options']['firmwares'], 'total', 'version')['v-test-1'] === 5, 'firmware distribution'); $faultRows = array_column($first['anomalies'], null, 'label'); check(($faultRows['流量上报异常']['total'] ?? 0) >= 2, 'anomalies use recorded descriptions'); check(count($first['trend']) === 30 && $first['trend'][0]['day'] === $now->modify('-29 days')->format('Y-m-d'), '30 calendar days'); foreach ([-30 => 999, -29 => 2, -1 => 9] as $offset => $liters) { WaterDeviceUsage::query()->insert(['device_id' => $ids[0], 'day' => $now->modify($offset . ' days')->format('Y-m-d'), 'liters' => $liters]); } $reporter = new DeviceReport(); $usage = ['sn' => $tag . '-0', 'kind' => 'usage', 'reported_at' => date('Y-m-d H:i:s', time() - 10), 'today_liters' => 12.5]; $reporter->accept($usage); $reporter->accept($usage); $reporter->accept(array_replace($usage, ['today_liters' => 8])); $daily = WaterDeviceUsage::query()->where('device_id', $ids[0])->where('day', $now->format('Y-m-d')); check($daily->count() === 1 && (float) $daily->value('liters') === 12.5, 'duplicate and lower snapshots do not add usage'); $reporter->accept(array_replace($usage, ['today_liters' => 15])); check((float) $daily->value('liters') === 15.0, 'higher snapshot advances daily total'); $trend = $service->dashboard([])['trend']; check($trend[0]['liters'] - ($baseline['trend'][0]['liters'] ?? 0) === 2.0, 'first day included, preceding day excluded'); check($trend[28]['liters'] - ($baseline['trend'][28]['liters'] ?? 0) === 9.0, 'historical day retained'); check($trend[29]['liters'] - ($baseline['trend'][29]['liters'] ?? 0) === 15.0, 'today sums cumulative reports'); check($trend[1] === $baseline['trend'][1], 'unreported date remains unchanged'); check((int) WaterSku::query()->find($sku)->available === 0 && WaterAccount::query()->where('enterprise_id', $enterprise)->count() === 0, 'telemetry does not mutate stock or rights'); $book = workbook($service->export(['keyword' => $tag, 'limit' => 1, 'page' => 2])); check(count($book) === 11 && count($book[0]) === 10, 'complete export ignores pagination'); check($book[10][0] === "'=" . $tag . '-0', 'export formula prefix escaped'); check(count(workbook($service->export(['keyword' => $tag, 'status' => 'offline']))) === 3, 'export reuses status filter'); check(count(workbook($service->export(['keyword' => $tag . '-missing']))) === 1, 'empty export still has headers'); rejects(fn () => $service->dashboard(['keyword' => str_repeat('字', 121)]), 'keyword length validated'); rejects(fn () => $service->dashboard(['status' => 'invented']), 'unknown state rejected'); rejects(fn () => $service->export(['region' => []]), 'malformed filter rejected'); $reflection = new ReflectionClass(WaterController::class); $controller = $reflection->newInstanceWithoutConstructor(); $reflection->getProperty('adminId')->setValue($controller, 99999999); $request = new \support\Request("GET /core/water/devices/dashboard HTTP/1.1\r\nHost: localhost\r\n\r\n"); rejects(fn () => $controller->deviceDashboard($request), 'dashboard read authorization'); rejects(fn () => $controller->deviceExport($request), 'export read authorization'); $reflection->getProperty('adminId')->setValue($controller, 1); $body = json_encode(['keyword' => $tag]); $request = new \support\Request("POST /core/water/devices/export HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: " . strlen($body) . "\r\n\r\n" . $body); check(count(workbook($controller->deviceExport($request)->rawBody())) === 11, 'authorized JSON export controller'); } finally { $db->rollBack(); } check([WaterDevice::query()->count(), WaterDeviceUsage::query()->count()] === $counts, 'all device and history fixtures rolled back'); if (in_array('--http', $argv, true)) { $token = \Tinywan\Jwt\JwtToken::generateToken(['id' => 1, 'username' => 'admin', 'plat' => 'saiadmin', 'type' => 'web', 'client' => $tag, 'access_exp' => 120, 'refresh_exp' => 120])['access_token']; foreach (['devices/dashboard?limit=1', 'devices/export'] as $path) { $ch = curl_init('http://127.0.0.1:8787/core/water/' . $path); $options = [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $token]]; if ($path === 'devices/export') { $options += [CURLOPT_POST => true, CURLOPT_POSTFIELDS => '{}']; } curl_setopt_array($ch, $options); $raw = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); check($status === 200, 'HTTP status ' . $path); if ($path === 'devices/export') { check(substr($raw, 0, 2) === 'PK' && count(workbook($raw)) >= 1, 'HTTP device workbook'); } else { $json = json_decode($raw, true); check(($json['code'] ?? 0) === 200 && count($json['data']['trend'] ?? []) === 30 && count($json['data']['list']['data'] ?? []) <= 1, 'HTTP dashboard contract'); } } unset($token); } echo "PASS: $checks device dashboard checks; all fixtures rolled back.\n";