normalize.spec.ts 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. import { ParseApi } from './parse-api';
  2. /**
  3. * 字段归一单测(2026-10-20 新增兼容层)
  4. *
  5. * 🔴 背景:实测 `agent-node-lj-test` 容器上报的字段名/结构与平台契约不符:
  6. * · `channels` 上报数组 `["wecom"]`(契约要求对象)→ 导致 matrixReady=false
  7. * · `host` 上报 `cpuLoad1`(契约要求 `cpuPercent`)→ CPU 不显示
  8. * · `heartbeatAt` 缺失(容器用 `host.probedAt`)
  9. *
  10. * 本测试覆盖 `mapAgent()` 内的三个归一函数(经 fetchAgents 走通)。
  11. */
  12. describe('parse-api 字段归一(兼容非标准容器)', () => {
  13. let api: ParseApi;
  14. let calls: { url: string; init: RequestInit }[] = [];
  15. function stubFetch(body: unknown): void {
  16. calls = [];
  17. spyOn(window, 'fetch').and.callFake((input: RequestInfo | URL, init?: RequestInit) => {
  18. calls.push({ url: String(input), init: init ?? {} });
  19. return Promise.resolve({
  20. ok: true,
  21. status: 200,
  22. json: () => Promise.resolve(body),
  23. } as unknown as Response);
  24. });
  25. }
  26. beforeEach(() => {
  27. api = new ParseApi(() => 'r:t', () => 'all');
  28. });
  29. describe('channels 数组 → 对象', () => {
  30. it('["wecom"] → {wecom:true}(matrix 未点亮)', async () => {
  31. stubFetch({ code: 200, agents: [{ agentId: 'a', channels: '["wecom"]' }] });
  32. const [a] = await api.fetchAgents();
  33. expect(a.channels?.wecom).toBe(true);
  34. expect(a.channels?.matrix).toBeFalsy();
  35. });
  36. it('["matrix","wecom"] → 两者都点亮', async () => {
  37. stubFetch({ code: 200, agents: [{ agentId: 'a', channels: '["matrix","wecom"]' }] });
  38. const [a] = await api.fetchAgents();
  39. expect(a.channels?.matrix).toBe(true);
  40. expect(a.channels?.wecom).toBe(true);
  41. });
  42. it('🔴 数组含 matrix → matrixReady=true(修复"未接入"误判)', async () => {
  43. stubFetch({ code: 200, agents: [{ agentId: 'a', channels: '["matrix"]' }] });
  44. const [a] = await api.fetchAgents();
  45. expect(a.matrixReady).toBe(true);
  46. });
  47. it('对象形态(契约标准)原样通过', async () => {
  48. stubFetch({
  49. code: 200,
  50. agents: [
  51. {
  52. agentId: 'a',
  53. channels: '{"matrix":true,"homeserver":"https://matrix.fmode.cn","matrixConfig":{"userId":"@x:fmode.cn"}}',
  54. },
  55. ],
  56. });
  57. const [a] = await api.fetchAgents();
  58. expect(a.channels?.matrix).toBe(true);
  59. expect(a.channels?.matrixConfig?.userId).toBe('@x:fmode.cn');
  60. expect(a.matrixReady).toBe(true);
  61. });
  62. it('空数组 → undefined(不是"已接入")', async () => {
  63. stubFetch({ code: 200, agents: [{ agentId: 'a', channels: '[]' }] });
  64. const [a] = await api.fetchAgents();
  65. expect(a.channels).toBeUndefined();
  66. expect(a.matrixReady).toBe(false);
  67. });
  68. });
  69. describe('host 归一:cpuLoad1 → cpuPercent', () => {
  70. it('🔴 cpuLoad1=8, cpuCores=8 → cpuPercent=100', async () => {
  71. stubFetch({
  72. code: 200,
  73. agents: [{ agentId: 'a', host: '{"cpuLoad1":8,"cpuCores":8,"memPercent":50,"diskPercent":30}' }],
  74. });
  75. const [a] = await api.fetchAgents();
  76. expect(a.host?.cpuPercent).toBe(100);
  77. });
  78. it('🔴 核数未知时不算 cpuPercent(不编假数字)', async () => {
  79. // 实测契约口径是 cpuLoad1 / 核数;核数缺失时宁可"未上报",也不假定核数
  80. stubFetch({
  81. code: 200,
  82. agents: [{ agentId: 'a', host: '{"cpuLoad1":8,"memPercent":50,"diskPercent":30}' }],
  83. });
  84. const [a] = await api.fetchAgents();
  85. expect(a.host?.cpuPercent).toBeUndefined();
  86. expect(a.host?.memPercent).toBe(50); // 其余指标仍保留
  87. });
  88. it('cpuLoad1=9.2, cpuCores=56 → cpuPercent≈16.4', async () => {
  89. stubFetch({
  90. code: 200,
  91. agents: [
  92. { agentId: 'a', host: '{"cpuLoad1":9.2,"cpuCores":56,"memPercent":55.6,"diskPercent":33.3}' },
  93. ],
  94. });
  95. const [a] = await api.fetchAgents();
  96. expect(a.host?.cpuPercent).toBeCloseTo(16.4, 1);
  97. });
  98. it('契约标准的 cpuPercent 优先(不被覆盖)', async () => {
  99. stubFetch({
  100. code: 200,
  101. agents: [{ agentId: 'a', host: '{"cpuPercent":42,"cpuLoad1":99,"memPercent":10}' }],
  102. });
  103. const [a] = await api.fetchAgents();
  104. expect(a.host?.cpuPercent).toBe(42);
  105. });
  106. it('越界负载被夹到 0~100', async () => {
  107. stubFetch({ code: 200, agents: [{ agentId: 'a', host: '{"cpuLoad1":999,"cpuCores":2}' }] });
  108. const [a] = await api.fetchAgents();
  109. expect(a.host?.cpuPercent).toBe(100);
  110. });
  111. it('三项全无 → undefined(UI 显示"未上报"而非 0%)', async () => {
  112. stubFetch({ code: 200, agents: [{ agentId: 'a', host: '{"hostname":"x"}' }] });
  113. const [a] = await api.fetchAgents();
  114. expect(a.host).toBeUndefined();
  115. });
  116. });
  117. describe('heartbeatAt 归一', () => {
  118. it('契约字段 heartbeatAt 优先', async () => {
  119. stubFetch({
  120. code: 200,
  121. agents: [{ agentId: 'a', heartbeatAt: '2026-10-20T10:00:00Z', host: '{"probedAt":"2026-01-01T00:00:00Z"}' }],
  122. });
  123. const [a] = await api.fetchAgents();
  124. expect(a.heartbeatAt).toBe('2026-10-20T10:00:00Z');
  125. });
  126. it('缺失时回落 host.probedAt(容器实际用的键)', async () => {
  127. stubFetch({
  128. code: 200,
  129. agents: [{ agentId: 'a', host: '{"probedAt":"2026-10-20T10:34:07+08:00"}' }],
  130. });
  131. const [a] = await api.fetchAgents();
  132. expect(a.heartbeatAt).toBe('2026-10-20T10:34:07+08:00');
  133. });
  134. it('再缺失时回落 updatedAtClient.iso', async () => {
  135. stubFetch({
  136. code: 200,
  137. agents: [{ agentId: 'a', updatedAtClient: { iso: '2026-10-20T02:33:55.213Z' } }],
  138. });
  139. const [a] = await api.fetchAgents();
  140. expect(a.heartbeatAt).toBe('2026-10-20T02:33:55.213Z');
  141. });
  142. });
  143. describe('balance 归一:容器复刻脚本键名', () => {
  144. it('🔴 容器发 {"cny":91.46} → remainCny 有值(修复余额不显示)', async () => {
  145. stubFetch({ code: 200, agents: [{ agentId: 'a', balance: '{"source":"session","cny":91.46}' }] });
  146. const [a] = await api.fetchAgents();
  147. expect(a.balance?.remainCny).toBe(91.46);
  148. });
  149. it('契约标准 remainCny 优先于 cny', async () => {
  150. stubFetch({ code: 200, agents: [{ agentId: 'a', balance: '{"remainCny":10,"cny":99}' }] });
  151. const [a] = await api.fetchAgents();
  152. expect(a.balance?.remainCny).toBe(10);
  153. });
  154. it('旧键 remainRmb 仍兼容', async () => {
  155. stubFetch({ code: 200, agents: [{ agentId: 'a', balance: '{"remainRmb":7.5}' }] });
  156. const [a] = await api.fetchAgents();
  157. expect(a.balance?.remainCny).toBe(7.5);
  158. });
  159. it('source=none 无金额 → remainCny 为 undefined(UI 显示 —)', async () => {
  160. stubFetch({ code: 200, agents: [{ agentId: 'a', balance: '{"source":"none"}' }] });
  161. const [a] = await api.fetchAgents();
  162. expect(a.balance?.remainCny).toBeUndefined();
  163. });
  164. });
  165. describe('usage 映射(此前完全缺失 → Token 用量永远空白)', () => {
  166. it('🔴 容器上报 inputTokens/outputTokens → 映射为 prompt/completion', async () => {
  167. stubFetch({
  168. code: 200,
  169. agents: [
  170. {
  171. agentId: 'a',
  172. usage: '{"totalTokens":2238055,"inputTokens":1856406,"outputTokens":381649,"apiCalls":487}',
  173. },
  174. ],
  175. });
  176. const [a] = await api.fetchAgents();
  177. expect(a.usage?.totalTokens).toBe(2238055);
  178. expect(a.usage?.promptTokens).toBe(1856406);
  179. expect(a.usage?.completionTokens).toBe(381649);
  180. });
  181. it('契约旧字段 promptTokens 优先', async () => {
  182. stubFetch({
  183. code: 200,
  184. agents: [{ agentId: 'a', usage: '{"promptTokens":10,"inputTokens":99}' }],
  185. });
  186. const [a] = await api.fetchAgents();
  187. expect(a.usage?.promptTokens).toBe(10);
  188. });
  189. it('usage 全空 → undefined(不是 0)', async () => {
  190. stubFetch({ code: 200, agents: [{ agentId: 'a', usage: '{}' }] });
  191. const [a] = await api.fetchAgents();
  192. expect(a.usage).toBeUndefined();
  193. });
  194. });
  195. describe('deliverable 派生字段(此前 filename/size 从未赋值 → 渲染「· 」)', () => {
  196. it('🔴 从 artifacts[0] 推导 filename / fileType', async () => {
  197. stubFetch({
  198. code: 200,
  199. deliverables: [
  200. {
  201. objectId: 'd1',
  202. title: '报告',
  203. artifacts: [{ name: 'report.pdf', url: 'https://x/1', type: 'pdf' }],
  204. },
  205. ],
  206. });
  207. const [d] = await api.fetchDeliverables();
  208. expect(d.filename).toBe('report.pdf');
  209. expect(d.fileType).toBe('PDF');
  210. });
  211. it('无 artifacts → filename undefined(UI 显示「暂无附件」)', async () => {
  212. stubFetch({ code: 200, deliverables: [{ objectId: 'd1', title: '空成果', artifacts: [] }] });
  213. const [d] = await api.fetchDeliverables();
  214. expect(d.filename).toBeUndefined();
  215. });
  216. });
  217. describe('综合:非标准容器的完整映射', () => {
  218. it('数组 channels + cpuLoad1 + probedAt → 全部归一正确', async () => {
  219. stubFetch({
  220. code: 200,
  221. agents: [
  222. {
  223. agentId: 'agent-node-lj-test',
  224. botName: 'lj-test',
  225. status: 'running',
  226. currentTask: 'periodic status reporter',
  227. channels: '["matrix","wecom"]',
  228. host: '{"hostname":"e14260fdce46","cpuLoad1":9.2,"cpuCores":56,"memPercent":55.6,"diskPercent":33.3,"probedAt":"2026-10-20T10:34:07+08:00"}',
  229. balance: '{"source":"none"}',
  230. },
  231. ],
  232. });
  233. const [a] = await api.fetchAgents();
  234. expect(a.id).toBe('agent-node-lj-test');
  235. expect(a.matrixReady).toBe(true); // ← 修复前是 false
  236. expect(a.host?.cpuPercent).toBeCloseTo(16.4, 1); // ← 修复前是 undefined
  237. expect(a.host?.memPercent).toBe(55.6);
  238. expect(a.heartbeatAt).toBe('2026-10-20T10:34:07+08:00'); // ← 修复前是 undefined
  239. });
  240. });
  241. });