| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176 |
- <?php
- /** Device dashboard integration checks. Fixtures are transactional; --http only reads live data. */
- require dirname(__DIR__) . '/vendor/autoload.php';
- \Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->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";
|