/* test_spinner.c - 等待动画的两段式标签 * * 动画写的是 stderr, 所以这里把 stderr 挪到一个临时文件, 驱动几轮之后 * 再把内容读回来断言. spinner_init(1) 是显式开关, 测试里不依赖 isatty. */ #include "spinner.h" #include #include #include #include #include static int failures = 0; static int checks = 0; #define CHECK(cond, ...) \ do { \ checks++; \ if (!(cond)) { \ failures++; \ printf(" ✗ %s:%d: ", __FILE__, __LINE__); \ printf(__VA_ARGS__); \ printf("\n"); \ } \ } while (0) /* 把 stderr 接到一个临时文件上, 返回文件描述符; *saved 收原来的 stderr. * 用完必须调用 capture_end 还原, 否则后面的失败信息也会被吞掉. */ static int capture_begin(char *path, size_t pathlen, int *saved) { snprintf(path, pathlen, "/tmp/ligent_spin_XXXXXX"); int fd = mkstemp(path); if (fd < 0) return -1; *saved = dup(STDERR_FILENO); if (*saved < 0) { close(fd); return -1; } dup2(fd, STDERR_FILENO); return fd; } static void capture_end(int fd, int saved, char *buf, size_t buflen) { fflush(stderr); dup2(saved, STDERR_FILENO); close(saved); lseek(fd, 0, SEEK_SET); ssize_t n = read(fd, buf, buflen - 1); buf[n > 0 ? (size_t)n : 0] = '\0'; close(fd); } static void test_two_phase(void) { puts("两段式标签: 等待响应 → 思考中"); char path[64]; int saved = -1; int fd = capture_begin(path, sizeof path, &saved); CHECK(fd >= 0, "无法重定向 stderr"); if (fd < 0) return; spinner_init(1); spinner_start("等待响应"); usleep(400 * 1000); /* 每来一片思维链都会调一次: 第一次生效, 后面几次都是同一标签 */ spinner_set_label("思考中"); usleep(120 * 1000); spinner_set_label("思考中"); usleep(120 * 1000); spinner_set_label("思考中"); usleep(200 * 1000); spinner_stop(); char buf[8192]; capture_end(fd, saved, buf, sizeof buf); unlink(path); const char *a = strstr(buf, "等待响应"); const char *b = strstr(buf, "思考中"); CHECK(a != NULL, "应打印\"等待响应\", 实际 [%.120s]", buf); CHECK(b != NULL, "应打印\"思考中\", 实际 [%.120s]", buf); CHECK(a && b && a < b, "先\"等待响应\"后\"思考中\", 顺序反了"); CHECK(strstr(buf, "\r\033[K") != NULL, "每帧应先回车清行再打印"); /* 停在"思考中"之后就不该再有"等待响应"冒出来 */ if (b) CHECK(strstr(b, "等待响应") == NULL, "换标签后不应回退到\"等待响应\""); } static void test_idempotent(void) { puts("未开始/未启用时的行为"); char path[64]; int saved = -1; int fd = capture_begin(path, sizeof path, &saved); CHECK(fd >= 0, "无法重定向 stderr"); if (fd < 0) return; /* 没 start 就 set: 只记标签, 不该打印任何东西, 也不该崩 */ spinner_init(1); spinner_set_label("思考中"); usleep(50 * 1000); /* 没 start 就 stop: 幂等 */ spinner_stop(); /* start 两次: 第二次忽略, 不该起第二个线程 */ spinner_start("等待响应"); spinner_start("思考中"); usleep(300 * 1000); spinner_stop(); spinner_stop(); char buf[8192]; capture_end(fd, saved, buf, sizeof buf); unlink(path); CHECK(strstr(buf, "等待响应") != NULL, "start 后应开始转"); CHECK(strstr(buf, "思考中") == NULL, "已在转时再次 start 应被忽略, 标签不该被改掉"); /* enabled = 0 时全部是空操作: 连重定向都不需要 */ spinner_init(0); spinner_start("等待响应"); spinner_set_label("思考中"); spinner_stop(); CHECK(1, "关闭状态下调用不崩溃"); } static double now_sec(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9; } /* spinner_stop 要 pthread_join worker. 如果 worker 是 nanosleep 一整帧, * 这里就得干等最多 250ms —— 而这笔账会加在每一轮模型往返、每一次工具 * 来回的用户感知延迟上. */ static void test_stop_is_prompt(void) { puts("stop 不该等满一帧 (250ms)"); char path[64]; int saved = -1; int fd = capture_begin(path, sizeof path, &saved); CHECK(fd >= 0, "无法重定向 stderr"); if (fd < 0) return; spinner_init(1); double worst = 0.0; for (int i = 0; i < 5; i++) { double t0 = now_sec(); spinner_start("等待响应"); spinner_stop(); double dt = now_sec() - t0; if (dt > worst) worst = dt; } char buf[4096]; capture_end(fd, saved, buf, sizeof buf); unlink(path); CHECK(worst < 0.10, "stop 应立即返回, 最慢一次 %.3fs (帧间隔是 0.25s)", worst); } /* 帧率必须受 250ms 限制. 曾经踩过一次: pthread_cond_timedwait 默认按 * CLOCK_REALTIME 解释超时, 而传进去的是 CLOCK_MONOTONIC 的绝对时间 * (开机时长永远小于 epoch 时间), 于是超时永远"已经过了" —— worker * 变成疯狂重绘的忙循环, 把 pty 刷屏. */ static void test_frame_rate_is_bounded(void) { puts("帧率受 250ms 限制, 不能狂刷"); char path[64]; int saved = -1; int fd = capture_begin(path, sizeof path, &saved); CHECK(fd >= 0, "无法重定向 stderr"); if (fd < 0) return; spinner_init(1); spinner_start("等待响应"); usleep(600 * 1000); /* 0.6 秒 ≈ 2~3 帧 */ spinner_stop(); char buf[65536]; capture_end(fd, saved, buf, sizeof buf); unlink(path); int frames = 0; for (const char *p = buf; (p = strstr(p, "等待响应")); p++) frames++; CHECK(frames >= 1, "至少该画一帧, 实际 %d", frames); CHECK(frames <= 5, "0.6 秒内不该超过 5 帧, 实际 %d (忙循环?)", frames); } /* spinner_notice 会先停、打印一条提示、再**接着转**. 恢复时它传的是 g_label * 自己 —— 而 spinner_start 曾经先执行 g_label[0] = '\0' 再读那个 label, 于是 * 标签被自己清空、退化成默认的"思考中". 症状: 每次 HTTP 重试退避之后, 屏幕上 * 的"等待响应"都会变成"思考中", 而这两段等待的含义恰恰是它要区分的. */ static void test_notice_keeps_label(void) { puts("notice 之后标签保持不变"); char path[64]; int saved = -1; int fd = capture_begin(path, sizeof path, &saved); CHECK(fd >= 0, "无法重定向 stderr"); if (fd < 0) return; spinner_init(1); spinner_start("自定义标签A"); usleep(300 * 1000); /* 让它画至少一帧 */ spinner_notice("一条插进来的提示"); usleep(300 * 1000); spinner_stop(); char buf[8192]; capture_end(fd, saved, buf, sizeof buf); unlink(path); const char *noticed = strstr(buf, "一条插进来的提示"); CHECK(noticed != NULL, "提示本身应该打出来, 实际 [%.200s]", buf); /* 提示之后剩下的帧里必须还是原来那个标签 */ if (noticed) CHECK(strstr(noticed, "自定义标签A") != NULL, "notice 之后标签被改写了 (应保持\"自定义标签A\"): \n%.300s", noticed); } int main(void) { test_two_phase(); test_idempotent(); test_stop_is_prompt(); test_frame_rate_is_bounded(); test_notice_keeps_label(); printf("\n%d 项检查, %d 项失败\n", checks, failures); return failures ? 1 : 0; }