import { TestBed } from '@angular/core/testing'; import { Deliverable, DeliverableGroup } from './api/models'; import { FmodeApi } from './api/fmode-api'; import { DeliverablesService } from './deliverables.service'; /** * 成果服务。 * * 重点回归: * ① 空态与错误态**必须区分**(AC-08.4:失败不能伪装成「暂无成果」) * ② 搜索过滤后,分组计数要跟着变(否则显示「3 条」实际只有 1 条) * ③ 月份分组按时间倒序 */ describe('DeliverablesService', () => { const apiStub = { fetchDeliverables: jasmine.createSpy('fetchDeliverables'), fetchDeliverablesByProject: jasmine.createSpy('fetchDeliverablesByProject'), }; let svc: DeliverablesService; function d(id: string, title: string, deliveredAt: string, extra: Partial = {}): Deliverable { return { id, agentId: 'a1', title, artifacts: [], tags: [], deliveredAt, state: 'delivered', ...extra, }; } beforeEach(() => { apiStub.fetchDeliverables.calls.reset(); apiStub.fetchDeliverablesByProject.calls.reset(); apiStub.fetchDeliverables.and.returnValue(Promise.resolve([])); apiStub.fetchDeliverablesByProject.and.returnValue(Promise.resolve([])); TestBed.resetTestingModule(); TestBed.configureTestingModule({ providers: [DeliverablesService, { provide: FmodeApi, useValue: apiStub }], }); svc = TestBed.inject(DeliverablesService); }); describe('加载与状态', () => { it('空列表 → state=empty(不是 ready)', async () => { await svc.load(); expect(svc.state()).toBe('empty'); expect(svc.empty()).toBeTrue(); }); it('有数据 → state=ready', async () => { apiStub.fetchDeliverables.and.returnValue(Promise.resolve([d('1', '报告', '2026-10-01')])); await svc.load(); expect(svc.state()).toBe('ready'); expect(svc.all().length).toBe(1); }); it('🔴 接口失败 → state=error,且不伪装成「暂无成果」', async () => { apiStub.fetchDeliverables.and.returnValue(Promise.reject(new Error('网络异常'))); await svc.load(); expect(svc.state()).toBe('error'); expect(svc.error()).toContain('网络异常'); // 关键:错误态时 empty 必须为 false,否则 UI 会显示「暂无成果」 expect(svc.empty()).toBeFalse(); }); it('ensureLoaded 加载过就不再重复请求', async () => { apiStub.fetchDeliverables.and.returnValue(Promise.resolve([d('1', 'x', '2026-10-01')])); await svc.ensureLoaded(); await svc.ensureLoaded(); expect(apiStub.fetchDeliverables.calls.count()).toBe(1); }); it('refresh 强制重新拉取(跳过缓存)', async () => { apiStub.fetchDeliverables.and.returnValue(Promise.resolve([d('1', 'x', '2026-10-01')])); await svc.ensureLoaded(); await svc.refresh(); expect(apiStub.fetchDeliverables.calls.count()).toBe(2); }); }); describe('搜索过滤', () => { beforeEach(async () => { apiStub.fetchDeliverables.and.returnValue( Promise.resolve([ d('1', '登录页设计', '2026-10-01', { projectName: '项目A' }), d('2', '接口文档', '2026-10-02', { projectName: '项目A' }), d('3', '登录页原型', '2026-09-15', { projectName: '项目B' }), ]), ); await svc.load(); }); it('无关键字 → 全部返回', () => { expect(svc.filtered().length).toBe(3); }); it('按标题过滤', () => { svc.keyword.set('登录'); expect(svc.filtered().map((x) => x.id)).toEqual(['1', '3']); }); it('按项目名过滤', () => { svc.keyword.set('项目B'); expect(svc.filtered().map((x) => x.id)).toEqual(['3']); }); it('大小写不敏感', () => { svc.keyword.set('ABC'); expect(svc.filtered().length).toBe(0); }); it('🔴 无结果时 filteredEmpty 为真(供 UI 显示「没有匹配」而不是「暂无成果」)', () => { svc.keyword.set('不存在的东西'); expect(svc.filteredEmpty()).toBeTrue(); // 但整体 empty 仍是 false(确实有成果,只是没匹配上) expect(svc.empty()).toBeFalse(); }); }); describe('🔴 分组计数跟随搜索(曾在 UI 上显示错的条数)', () => { beforeEach(async () => { apiStub.fetchDeliverables.and.returnValue(Promise.resolve([])); apiStub.fetchDeliverablesByProject.and.returnValue( Promise.resolve([ { project: 'p1', projectName: '项目A', count: 3, items: [d('1', '登录页', '2026-10-01'), d('2', '接口', '2026-10-02'), d('3', '报表', '2026-10-03')], } as DeliverableGroup, ]), ); await svc.load(); }); it('无关键字 → 计数是原始值', () => { expect(svc.groups()[0].count).toBe(3); }); it('过滤后 count 跟着变(不是恒为 3)', () => { svc.keyword.set('登录'); expect(svc.groups()[0].items.length).toBe(1); expect(svc.groups()[0].count).toBe(1); // 关键断言 }); it('组内全被过滤掉 → 整组消失', () => { svc.keyword.set('完全不存在'); expect(svc.groups().length).toBe(0); }); }); describe('月份分组', () => { it('按 YYYY-MM 分组,且倒序', async () => { apiStub.fetchDeliverables.and.returnValue( Promise.resolve([ d('1', 'a', '2026-09-10'), d('2', 'b', '2026-10-05'), d('3', 'c', '2026-10-20'), ]), ); await svc.load(); const groups = svc.monthGroups(); expect(groups.map((g) => g.key)).toEqual(['2026-10', '2026-09']); expect(groups[0].items.length).toBe(2); }); }); describe('按 agent / 项目 / id 查询', () => { beforeEach(async () => { apiStub.fetchDeliverables.and.returnValue( Promise.resolve([ d('1', 'a', '2026-10-01', { agentId: 'lj', projectId: 'p1' }), d('2', 'b', '2026-10-02', { agentId: 'lj-test', projectId: 'p1' }), d('3', 'c', '2026-10-03', { agentId: 'lj', projectId: 'p2' }), ]), ); await svc.load(); }); it('byAgent 只返回该 agent 的', () => { expect(svc.byAgent('lj').map((x) => x.id)).toEqual(['1', '3']); }); it('byProject 只返回该项目的', () => { expect(svc.byProject('p1').map((x) => x.id)).toEqual(['1', '2']); }); it('byId 命中 / 未命中', () => { expect(svc.byId('2')?.title).toBe('b'); expect(svc.byId('nope')).toBeUndefined(); }); }); });