Skip to content

Commit e585887

Browse files
authored
Merge pull request #4539 from Agenta-AI/fe-test/add-unit-tests-shared-annotation-packages
test(frontend): add unit tests for @agenta/shared and @agenta/annotation packages
2 parents e18b038 + 1b80a4e commit e585887

14 files changed

Lines changed: 2178 additions & 2 deletions
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Generated by Vitest — do not commit
2+
test-results/
3+
coverage/
Lines changed: 378 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,378 @@
1+
/**
2+
* Unit tests for pure helper functions exported from annotationFormController.ts:
3+
* - isEmptyValue
4+
* - getOutputsSchema
5+
* - getMetricFieldsFromEvaluator
6+
* - getMetricsFromAnnotation
7+
*
8+
* The module has many heavy imports (Jotai atoms, entity API calls, session
9+
* controller). We mock the external packages so no network or Jotai store
10+
* is touched during tests.
11+
*/
12+
13+
import {beforeEach, describe, expect, it, vi} from "vitest"
14+
15+
// ---------------------------------------------------------------------------
16+
// Module-level mocks — vi.mock is hoisted before imports by Vitest
17+
// ---------------------------------------------------------------------------
18+
19+
const mockResolveOutputSchema = vi.fn()
20+
21+
vi.mock("@agenta/entities/workflow", () => ({
22+
resolveOutputSchema: (data: unknown) => mockResolveOutputSchema(data),
23+
workflowQueryAtomFamily: () => ({isPending: false, data: null}),
24+
workflowLatestRevisionQueryAtomFamily: () => ({isPending: false, data: null}),
25+
}))
26+
27+
vi.mock("@agenta/entities/annotation", () => ({
28+
createAnnotation: vi.fn(),
29+
updateAnnotation: vi.fn(),
30+
invalidateAnnotationCacheByLink: vi.fn(),
31+
}))
32+
33+
vi.mock("@agenta/entities/evaluationRun", () => ({
34+
evaluationRunMolecule: {selectors: {annotationSteps: vi.fn(), scenarioSteps: vi.fn()}},
35+
queryEvaluationResults: vi.fn(),
36+
}))
37+
38+
vi.mock("@agenta/entities/simpleQueue", () => ({
39+
invalidateScenarioProgressCache: vi.fn(),
40+
invalidateSimpleQueueCache: vi.fn(),
41+
invalidateSimpleQueuesListCache: vi.fn(),
42+
simpleQueuePaginatedStore: {refreshAtom: {}},
43+
}))
44+
45+
vi.mock("@agenta/entities/trace", () => ({
46+
fetchPreviewTrace: vi.fn(),
47+
}))
48+
49+
vi.mock("@agenta/shared/api", () => ({
50+
axios: {patch: vi.fn(), post: vi.fn()},
51+
getAgentaApiUrl: () => "http://localhost",
52+
queryClient: {invalidateQueries: vi.fn()},
53+
}))
54+
55+
vi.mock("@agenta/shared/state", () => ({
56+
projectIdAtom: {},
57+
}))
58+
59+
vi.mock("../../src/state/controllers/annotationSessionController", () => ({
60+
annotationSessionController: {
61+
selectors: {
62+
evaluatorStepRefs: () => ({}),
63+
scenarioAnnotations: () => ({}),
64+
scenarioStatuses: () => ({}),
65+
activeRunId: () => ({}),
66+
focusAutoNext: () => ({}),
67+
},
68+
set: {markCompleted: vi.fn(), navigateNext: vi.fn()},
69+
cache: {invalidateScenarioAnnotations: vi.fn()},
70+
},
71+
}))
72+
73+
// Import the functions AFTER all vi.mock() declarations
74+
import {
75+
getMetricFieldsFromEvaluator,
76+
getMetricsFromAnnotation,
77+
getOutputsSchema,
78+
isEmptyValue,
79+
} from "../../src/state/controllers/annotationFormController"
80+
import type {Annotation} from "@agenta/entities/annotation"
81+
import type {Workflow} from "@agenta/entities/workflow"
82+
83+
// ---------------------------------------------------------------------------
84+
// Helpers
85+
// ---------------------------------------------------------------------------
86+
87+
function makeWorkflow(schemaProperties: Record<string, unknown> = {}): Workflow {
88+
// resolveOutputSchema is mocked to return its input,
89+
// so we set data to the schema shape directly.
90+
return {
91+
data: {properties: schemaProperties},
92+
slug: "test-evaluator",
93+
id: "wf-1",
94+
} as unknown as Workflow
95+
}
96+
97+
function makeAnnotation(
98+
outputs: Record<string, unknown>,
99+
references?: {evaluator?: {slug?: string}},
100+
): Annotation {
101+
return {
102+
trace_id: "trace-1",
103+
span_id: "span-1",
104+
data: {outputs},
105+
references,
106+
meta: {},
107+
} as unknown as Annotation
108+
}
109+
110+
beforeEach(() => {
111+
// Default: resolveOutputSchema returns the data as-is (pass-through)
112+
mockResolveOutputSchema.mockImplementation((data: unknown) => data)
113+
})
114+
115+
// ---------------------------------------------------------------------------
116+
// isEmptyValue
117+
// ---------------------------------------------------------------------------
118+
119+
describe("isEmptyValue", () => {
120+
it.each([
121+
[null, true],
122+
[undefined, true],
123+
["", true],
124+
[[], true],
125+
])("returns true for %s", (value, expected) => {
126+
expect(isEmptyValue(value)).toBe(expected)
127+
})
128+
129+
it.each([
130+
[0, false],
131+
[false, false],
132+
["0", false],
133+
[[null], false],
134+
[{}, false],
135+
[" ", false],
136+
])("returns false for %s", (value, expected) => {
137+
expect(isEmptyValue(value)).toBe(expected)
138+
})
139+
})
140+
141+
// ---------------------------------------------------------------------------
142+
// getOutputsSchema
143+
// ---------------------------------------------------------------------------
144+
145+
describe("getOutputsSchema", () => {
146+
it("returns the schema from resolveOutputSchema", () => {
147+
const schema = {properties: {score: {type: "number"}}}
148+
const workflow = makeWorkflow(schema.properties)
149+
const result = getOutputsSchema(workflow)
150+
expect(result).toMatchObject({properties: {score: {type: "number"}}})
151+
})
152+
153+
it("returns empty object when resolveOutputSchema returns null", () => {
154+
mockResolveOutputSchema.mockReturnValueOnce(null)
155+
const result = getOutputsSchema(makeWorkflow())
156+
expect(result).toEqual({})
157+
})
158+
})
159+
160+
// ---------------------------------------------------------------------------
161+
// getMetricFieldsFromEvaluator — scalar types
162+
// ---------------------------------------------------------------------------
163+
164+
describe("getMetricFieldsFromEvaluator — scalar types", () => {
165+
it("produces a number field with null default", () => {
166+
const wf = makeWorkflow({score: {type: "number", minimum: 0, maximum: 10}})
167+
const fields = getMetricFieldsFromEvaluator(wf)
168+
expect(fields.score).toMatchObject({value: null, type: "number", minimum: 0, maximum: 10})
169+
})
170+
171+
it("produces an integer field with null default", () => {
172+
const wf = makeWorkflow({count: {type: "integer"}})
173+
expect(getMetricFieldsFromEvaluator(wf).count).toMatchObject({value: null, type: "integer"})
174+
})
175+
176+
it("produces a boolean field with null default", () => {
177+
const wf = makeWorkflow({approved: {type: "boolean"}})
178+
expect(getMetricFieldsFromEvaluator(wf).approved).toMatchObject({
179+
value: null,
180+
type: "boolean",
181+
})
182+
})
183+
184+
it("produces a string field with empty-string default", () => {
185+
const wf = makeWorkflow({notes: {type: "string"}})
186+
expect(getMetricFieldsFromEvaluator(wf).notes).toMatchObject({value: "", type: "string"})
187+
})
188+
})
189+
190+
describe("getMetricFieldsFromEvaluator — array type", () => {
191+
it("produces an array field with item schema", () => {
192+
const wf = makeWorkflow({
193+
labels: {
194+
type: "array",
195+
items: {type: "string", enum: ["good", "bad"]},
196+
},
197+
})
198+
const fields = getMetricFieldsFromEvaluator(wf)
199+
expect(fields.labels).toMatchObject({
200+
value: [],
201+
type: "array",
202+
items: {type: "string", enum: ["good", "bad"]},
203+
})
204+
})
205+
206+
it("defaults item type to string when items is missing", () => {
207+
const wf = makeWorkflow({tags: {type: "array"}})
208+
expect(getMetricFieldsFromEvaluator(wf).tags.items).toMatchObject({
209+
type: "string",
210+
enum: [],
211+
})
212+
})
213+
})
214+
215+
describe("getMetricFieldsFromEvaluator — anyOf schema", () => {
216+
it("unwraps the first anyOf entry to get the real type", () => {
217+
const wf = makeWorkflow({
218+
score: {anyOf: [{type: "number", minimum: 0}, {type: "null"}]},
219+
})
220+
expect(getMetricFieldsFromEvaluator(wf).score).toMatchObject({value: null, type: "number"})
221+
})
222+
})
223+
224+
describe("getMetricFieldsFromEvaluator — array-of-types", () => {
225+
it("filters 'null' from the type array and uses the remaining types", () => {
226+
const wf = makeWorkflow({status: {type: ["string", "null"]}})
227+
const field = getMetricFieldsFromEvaluator(wf).status
228+
expect(field.type).toEqual(["string"])
229+
expect(field.value).toBe("")
230+
})
231+
232+
it("skips the property when only 'null' type remains after filtering", () => {
233+
const wf = makeWorkflow({x: {type: ["null"]}})
234+
expect(getMetricFieldsFromEvaluator(wf)).not.toHaveProperty("x")
235+
})
236+
237+
it("includes non-null enum values and strips null/empty entries", () => {
238+
const wf = makeWorkflow({
239+
choice: {type: ["string", "null"], enum: ["a", null, "", "b"]},
240+
})
241+
const field = getMetricFieldsFromEvaluator(wf).choice
242+
expect(field.enum).toEqual(["a", "b"])
243+
})
244+
})
245+
246+
describe("getMetricFieldsFromEvaluator — edge cases", () => {
247+
it("returns empty object for an empty schema", () => {
248+
mockResolveOutputSchema.mockReturnValueOnce(null)
249+
expect(getMetricFieldsFromEvaluator(makeWorkflow())).toEqual({})
250+
})
251+
252+
it("skips unsupported types (e.g. 'object')", () => {
253+
const wf = makeWorkflow({meta: {type: "object"}})
254+
expect(getMetricFieldsFromEvaluator(wf)).not.toHaveProperty("meta")
255+
})
256+
257+
it("skips properties with no type field", () => {
258+
const wf = makeWorkflow({weird: {description: "no type here"}})
259+
expect(getMetricFieldsFromEvaluator(wf)).not.toHaveProperty("weird")
260+
})
261+
})
262+
263+
// ---------------------------------------------------------------------------
264+
// getMetricsFromAnnotation — flat outputs
265+
// ---------------------------------------------------------------------------
266+
267+
describe("getMetricsFromAnnotation — flat outputs matching schema", () => {
268+
it("fills a number field from flat outputs", () => {
269+
const wf = makeWorkflow({score: {type: "number"}})
270+
const ann = makeAnnotation({score: 8.5})
271+
const fields = getMetricsFromAnnotation(ann, wf)
272+
expect(fields.score).toMatchObject({value: 8.5, type: "number"})
273+
})
274+
275+
it("fills a string field from flat outputs", () => {
276+
// "notes" is a reserved flattening key — use a plain field name
277+
const wf = makeWorkflow({label: {type: "string"}})
278+
const ann = makeAnnotation({label: "looks good"})
279+
expect(getMetricsFromAnnotation(ann, wf).label).toMatchObject({
280+
value: "looks good",
281+
type: "string",
282+
})
283+
})
284+
285+
it("uses schema default when key is absent in outputs", () => {
286+
const wf = makeWorkflow({score: {type: "number"}})
287+
const ann = makeAnnotation({})
288+
expect(getMetricsFromAnnotation(ann, wf).score).toMatchObject({value: null, type: "number"})
289+
})
290+
291+
it("uses '' as default for a missing string field", () => {
292+
const wf = makeWorkflow({label: {type: "string"}})
293+
const ann = makeAnnotation({})
294+
expect(getMetricsFromAnnotation(ann, wf).label.value).toBe("")
295+
})
296+
})
297+
298+
// ---------------------------------------------------------------------------
299+
// getMetricsFromAnnotation — nested output structures
300+
// ---------------------------------------------------------------------------
301+
302+
describe("getMetricsFromAnnotation — nested outputs", () => {
303+
it("flattens metrics nested under 'metrics' key", () => {
304+
const wf = makeWorkflow({score: {type: "number"}})
305+
const ann = makeAnnotation({metrics: {score: 9}})
306+
expect(getMetricsFromAnnotation(ann, wf).score.value).toBe(9)
307+
})
308+
309+
it("flattens fields nested under 'notes' key", () => {
310+
const wf = makeWorkflow({comment: {type: "string"}})
311+
const ann = makeAnnotation({notes: {comment: "great"}})
312+
expect(getMetricsFromAnnotation(ann, wf).comment.value).toBe("great")
313+
})
314+
315+
it("flattens fields nested under 'extra' key", () => {
316+
const wf = makeWorkflow({custom: {type: "string"}})
317+
const ann = makeAnnotation({extra: {custom: "value"}})
318+
expect(getMetricsFromAnnotation(ann, wf).custom.value).toBe("value")
319+
})
320+
321+
it("flat keys outside of metrics/notes/extra are preserved directly", () => {
322+
const wf = makeWorkflow({direct: {type: "number"}})
323+
const ann = makeAnnotation({direct: 42})
324+
expect(getMetricsFromAnnotation(ann, wf).direct.value).toBe(42)
325+
})
326+
})
327+
328+
// ---------------------------------------------------------------------------
329+
// getMetricsFromAnnotation — schema-free (infer from outputs)
330+
// ---------------------------------------------------------------------------
331+
332+
describe("getMetricsFromAnnotation — schema-free inference", () => {
333+
beforeEach(() => {
334+
// Empty schema → falls back to inferFieldsFromOutputs
335+
mockResolveOutputSchema.mockReturnValue(null)
336+
})
337+
338+
it("infers a number field from a numeric output value", () => {
339+
const wf = makeWorkflow()
340+
const ann = makeAnnotation({score: 7})
341+
const fields = getMetricsFromAnnotation(ann, wf)
342+
expect(fields.score.type).toBe("integer")
343+
expect(fields.score.value).toBe(7)
344+
})
345+
346+
it("infers a boolean field from a boolean output value", () => {
347+
const wf = makeWorkflow()
348+
const ann = makeAnnotation({approved: true})
349+
expect(getMetricsFromAnnotation(ann, wf).approved).toMatchObject({
350+
value: true,
351+
type: "boolean",
352+
})
353+
})
354+
355+
it("infers a string field from a string output value", () => {
356+
// "notes" is a reserved key — use a plain field name
357+
const wf = makeWorkflow()
358+
const ann = makeAnnotation({comment: "hello"})
359+
expect(getMetricsFromAnnotation(ann, wf).comment).toMatchObject({
360+
value: "hello",
361+
type: "string",
362+
})
363+
})
364+
365+
it("serialises an object output to a JSON string field", () => {
366+
const wf = makeWorkflow()
367+
const ann = makeAnnotation({meta: {key: "val"}})
368+
const field = getMetricsFromAnnotation(ann, wf).meta
369+
expect(field.type).toBe("string")
370+
expect(field.value).toBe(JSON.stringify({key: "val"}))
371+
})
372+
373+
it("returns empty object when annotation outputs are empty", () => {
374+
const wf = makeWorkflow()
375+
const ann = makeAnnotation({})
376+
expect(getMetricsFromAnnotation(ann, wf)).toEqual({})
377+
})
378+
})

0 commit comments

Comments
 (0)