humainflow-web/src/app/services/task-executions/task-executions.spec.ts

128 lines
4.3 KiB
TypeScript

import { TestBed } from '@angular/core/testing';
import { BiasImpactJob } from '@models/bias-impact';
import { TaskExecution } from '@models/task-execution';
import { lastValueFrom, of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { TaskExecutionsService } from './task-executions';
const job = (status: BiasImpactJob['status'], terminal: boolean): BiasImpactJob => ({
id: 'job-1',
status,
executionId: 'execution-1',
stepId: 'step-1',
createdAt: '2026-07-21T10:00:00',
startedAt: null,
completedAt: terminal ? '2026-07-21T10:00:10' : null,
reportId: null,
report: null,
errorCode: null,
errorMessage: null,
terminal
});
describe('TaskExecutionsService bias operations', () => {
let service: TaskExecutionsService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(TaskExecutionsService);
});
it('maps side-effect policy conflicts using the backend error code', async () => {
service.taskExecutionsCallService = {
runBiasImpactExperiment: () => throwError(() => ({
status: 409,
error: {
detail: 'External side effects are blocked',
errors: [{ code: 'BIAS_SIDE_EFFECT_BLOCKED', message: 'External side effects are blocked' }]
}
}))
} as unknown as typeof service.taskExecutionsCallService;
await expect(lastValueFrom(service.runBiasImpactExperiment('execution-1', 'step-1', {
annotationIds: ['annotation-1'],
repetitions: 3,
includeRawOutputs: true,
externalSideEffectPolicy: 'BLOCK',
confirmExternalSideEffects: false
}))).rejects.toEqual({
reason: 'SIDE_EFFECT_BLOCKED',
code: 'BIAS_SIDE_EFFECT_BLOCKED',
message: 'External side effects are blocked'
});
expect(service.biasExperimentInProgress()).toBe(false);
});
it('polls until a terminal job and stops after completion', async () => {
vi.useFakeTimers();
const getBiasImpactJob = vi.fn()
.mockReturnValueOnce(of(job('QUEUED', false)))
.mockReturnValueOnce(of(job('RUNNING', false)))
.mockReturnValueOnce(of(job('COMPLETED', true)));
service.taskExecutionsCallService = { getBiasImpactJob } as unknown as typeof service.taskExecutionsCallService;
const terminalJob = lastValueFrom(service.pollBiasImpactJob('job-1'));
await vi.advanceTimersByTimeAsync(3_000);
await expect(terminalJob).resolves.toEqual(expect.objectContaining({ status: 'COMPLETED', terminal: true }));
expect(getBiasImpactJob).toHaveBeenCalledTimes(3);
vi.useRealTimers();
});
it('retries a transient polling failure without abandoning the job', async () => {
vi.useFakeTimers();
const getBiasImpactJob = vi.fn()
.mockReturnValueOnce(throwError(() => ({ status: 0 })))
.mockReturnValueOnce(of(job('RUNNING', false)))
.mockReturnValueOnce(of(job('COMPLETED', true)));
service.taskExecutionsCallService = { getBiasImpactJob } as unknown as typeof service.taskExecutionsCallService;
const terminalJob = lastValueFrom(service.pollBiasImpactJob('job-1'));
await vi.advanceTimersByTimeAsync(5_000);
await expect(terminalJob).resolves.toEqual(expect.objectContaining({ status: 'COMPLETED' }));
expect(getBiasImpactJob).toHaveBeenCalledTimes(3);
vi.useRealTimers();
});
it('replaces the local execution immediately with the interaction response', async () => {
const baseline: TaskExecution = {
id: 'execution-1',
name: 'Human flow',
creationTime: 1,
context: {
inputs: {},
result: {},
errors: {},
warnings: [],
steps: {},
status: 'WAITING',
waitingSteps: ['decision-1']
}
};
const updated: TaskExecution = {
...baseline,
context: {
...baseline.context,
status: 'SUCCESS',
waitingSteps: []
}
};
(service as any)._taskExecutions.set([baseline]);
vi.spyOn(service, 'refresh').mockImplementation(() => undefined);
service.taskExecutionsCallService = {
submitInteractionText: vi.fn().mockReturnValue(of(updated))
} as unknown as typeof service.taskExecutionsCallService;
await lastValueFrom(service.submitInteractionText(
'execution-1',
'decision-1',
'choice',
'approve'
));
expect(service.taskExecutions()[0].context.status).toBe('SUCCESS');
});
});