Files
ligent/tests/test_spinner.c
T
2026-10-03 14:43:43 +08:00

230 lines
8.0 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* test_spinner.c - 等待动画的两段式标签
*
* 动画写的是 stderr, 所以这里把 stderr 挪到一个临时文件, 驱动几轮之后
* 再把内容读回来断言. spinner_init(1) 是显式开关, 测试里不依赖 isatty.
*/
#include "spinner.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <time.h>
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;
}