Files
simstudioai--sim/apps/sim/lib/workflows/subblocks/visibility.test.ts
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

329 lines
13 KiB
TypeScript

/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
evaluateSubBlockCondition,
reindexToolCanonicalModes,
scopeCanonicalModesForTool,
} from './visibility'
describe('evaluateSubBlockCondition', () => {
describe('simple value matching', () => {
it.concurrent('returns true when field value matches condition value', () => {
const condition = { field: 'operation', value: 'create_booking' }
const values = { operation: 'create_booking' }
expect(evaluateSubBlockCondition(condition, values)).toBe(true)
})
it.concurrent('returns false when field value does not match condition value', () => {
const condition = { field: 'operation', value: 'create_booking' }
const values = { operation: 'cancel_booking' }
expect(evaluateSubBlockCondition(condition, values)).toBe(false)
})
it.concurrent('returns false when field is missing', () => {
const condition = { field: 'operation', value: 'create_booking' }
const values = {}
expect(evaluateSubBlockCondition(condition, values)).toBe(false)
})
it.concurrent('returns false when field is undefined', () => {
const condition = { field: 'operation', value: 'create_booking' }
const values = { operation: undefined }
expect(evaluateSubBlockCondition(condition, values)).toBe(false)
})
it.concurrent('returns false when field is null', () => {
const condition = { field: 'operation', value: 'create_booking' }
const values = { operation: null }
expect(evaluateSubBlockCondition(condition, values)).toBe(false)
})
})
describe('array value matching', () => {
it.concurrent('returns true when field value is in condition array', () => {
const condition = { field: 'operation', value: ['create_booking', 'update_booking'] }
const values = { operation: 'create_booking' }
expect(evaluateSubBlockCondition(condition, values)).toBe(true)
})
it.concurrent('returns true for second array value', () => {
const condition = { field: 'operation', value: ['create_booking', 'update_booking'] }
const values = { operation: 'update_booking' }
expect(evaluateSubBlockCondition(condition, values)).toBe(true)
})
it.concurrent('returns false when field value is not in condition array', () => {
const condition = { field: 'operation', value: ['create_booking', 'update_booking'] }
const values = { operation: 'cancel_booking' }
expect(evaluateSubBlockCondition(condition, values)).toBe(false)
})
it.concurrent('returns false when field is undefined with array condition', () => {
const condition = { field: 'operation', value: ['create_booking', 'update_booking'] }
const values = { operation: undefined }
expect(evaluateSubBlockCondition(condition, values)).toBe(false)
})
it.concurrent('returns false when field is null with array condition', () => {
const condition = { field: 'operation', value: ['create_booking', 'update_booking'] }
const values = { operation: null }
expect(evaluateSubBlockCondition(condition, values)).toBe(false)
})
})
describe('negation with not flag', () => {
it.concurrent('returns false when field matches but not is true', () => {
const condition = { field: 'operation', value: 'create_booking', not: true }
const values = { operation: 'create_booking' }
expect(evaluateSubBlockCondition(condition, values)).toBe(false)
})
it.concurrent('returns true when field does not match and not is true', () => {
const condition = { field: 'operation', value: 'create_booking', not: true }
const values = { operation: 'cancel_booking' }
expect(evaluateSubBlockCondition(condition, values)).toBe(true)
})
it.concurrent('returns true when field is not in array and not is true', () => {
const condition = {
field: 'operation',
value: ['create_booking', 'update_booking'],
not: true,
}
const values = { operation: 'cancel_booking' }
expect(evaluateSubBlockCondition(condition, values)).toBe(true)
})
it.concurrent('returns false when field is in array and not is true', () => {
const condition = {
field: 'operation',
value: ['create_booking', 'update_booking'],
not: true,
}
const values = { operation: 'create_booking' }
expect(evaluateSubBlockCondition(condition, values)).toBe(false)
})
})
describe('compound conditions with and', () => {
it.concurrent('returns true when both conditions match', () => {
const condition = {
field: 'operation',
value: 'create_booking',
and: { field: 'hasEmail', value: true },
}
const values = { operation: 'create_booking', hasEmail: true }
expect(evaluateSubBlockCondition(condition, values)).toBe(true)
})
it.concurrent('returns false when first condition matches but and condition fails', () => {
const condition = {
field: 'operation',
value: 'create_booking',
and: { field: 'hasEmail', value: true },
}
const values = { operation: 'create_booking', hasEmail: false }
expect(evaluateSubBlockCondition(condition, values)).toBe(false)
})
it.concurrent('returns false when first condition fails but and condition matches', () => {
const condition = {
field: 'operation',
value: 'create_booking',
and: { field: 'hasEmail', value: true },
}
const values = { operation: 'cancel_booking', hasEmail: true }
expect(evaluateSubBlockCondition(condition, values)).toBe(false)
})
it.concurrent('returns false when both conditions fail', () => {
const condition = {
field: 'operation',
value: 'create_booking',
and: { field: 'hasEmail', value: true },
}
const values = { operation: 'cancel_booking', hasEmail: false }
expect(evaluateSubBlockCondition(condition, values)).toBe(false)
})
})
describe('edge cases', () => {
it.concurrent('returns true when condition is undefined', () => {
expect(evaluateSubBlockCondition(undefined, { operation: 'anything' })).toBe(true)
})
it.concurrent('handles function conditions', () => {
const condition = () => ({ field: 'operation', value: 'create_booking' })
const values = { operation: 'create_booking' }
expect(evaluateSubBlockCondition(condition, values)).toBe(true)
})
it.concurrent('passes current values into function conditions', () => {
const condition = (values?: Record<string, unknown>) => ({
field: 'model',
value: typeof values?.model === 'string' ? values.model : '__no_model_selected__',
})
const values = { model: 'ollama/gemma3:4b' }
expect(evaluateSubBlockCondition(condition, values)).toBe(true)
})
it.concurrent('handles boolean values', () => {
const condition = { field: 'enabled', value: true }
const values = { enabled: true }
expect(evaluateSubBlockCondition(condition, values)).toBe(true)
})
it.concurrent('handles numeric values', () => {
const condition = { field: 'count', value: 5 }
const values = { count: 5 }
expect(evaluateSubBlockCondition(condition, values)).toBe(true)
})
})
})
describe('scopeCanonicalModesForTool', () => {
it.concurrent('returns undefined when there are no overrides', () => {
expect(scopeCanonicalModesForTool(undefined, 0)).toBeUndefined()
})
it.concurrent('returns undefined when toolIndex is undefined', () => {
expect(scopeCanonicalModesForTool({ '0:tableId': 'advanced' }, undefined)).toBeUndefined()
})
it.concurrent('strips the toolIndex prefix for the matching tool instance', () => {
const overrides = { '0:tableId': 'advanced', '1:tableId': 'basic' }
expect(scopeCanonicalModesForTool(overrides, 0)).toEqual({ tableId: 'advanced' })
expect(scopeCanonicalModesForTool(overrides, 1)).toEqual({ tableId: 'basic' })
})
it.concurrent(
'keeps two same-type tool instances independent (regression: two Table tools on one Agent block used to share a mode)',
() => {
const overrides = { '0:tableId': 'advanced', '1:tableId': 'basic' }
// Both tools have type "table" and canonicalId "tableId" - only toolIndex disambiguates them.
expect(scopeCanonicalModesForTool(overrides, 0)).toEqual({ tableId: 'advanced' })
expect(scopeCanonicalModesForTool(overrides, 1)).toEqual({ tableId: 'basic' })
}
)
it.concurrent('returns undefined when no keys match the given toolIndex prefix', () => {
expect(scopeCanonicalModesForTool({ '1:tableId': 'advanced' }, 0)).toBeUndefined()
})
it.concurrent('ignores falsy override values', () => {
expect(
scopeCanonicalModesForTool({ '0:tableId': undefined as unknown as 'advanced' }, 0)
).toBeUndefined()
})
it.concurrent(
'falls back to the legacy toolType-scoped prefix when no index-scoped key matches',
() => {
// Saved before per-instance scoping shipped - must not be silently dropped.
const legacyOverrides = { 'table:tableId': 'advanced' as const }
expect(scopeCanonicalModesForTool(legacyOverrides, 0, 'table')).toEqual({
tableId: 'advanced',
})
expect(scopeCanonicalModesForTool(legacyOverrides, 3, 'table')).toEqual({
tableId: 'advanced',
})
}
)
it.concurrent('prefers an index-scoped key over the legacy type-scoped fallback', () => {
const overrides = { 'table:tableId': 'advanced' as const, '0:tableId': 'basic' as const }
expect(scopeCanonicalModesForTool(overrides, 0, 'table')).toEqual({ tableId: 'basic' })
})
it.concurrent('does not fall back when no legacyToolType is given', () => {
expect(scopeCanonicalModesForTool({ 'table:tableId': 'advanced' }, 0)).toBeUndefined()
})
})
describe('reindexToolCanonicalModes', () => {
// Generic over T - only object identity matters, so a plain marker object stands in for a
// real StoredTool/fork-parsed-tool.
const tool = (label: string) => ({ label })
it.concurrent('returns undefined when there are no overrides', () => {
expect(reindexToolCanonicalModes([tool('a')], [tool('a')], undefined)).toBeUndefined()
})
it.concurrent('returns undefined when every tool keeps its index', () => {
const a = tool('a')
const b = tool('b')
expect(reindexToolCanonicalModes([a, b], [a, b], { '0:tableId': 'advanced' })).toBeUndefined()
})
it.concurrent('re-keys a surviving tool overrides to its new index after a removal', () => {
const a = tool('a')
const b = tool('b')
const c = tool('c')
// Remove `a` (index 0): b shifts 1->0, c shifts 2->1.
const result = reindexToolCanonicalModes([a, b, c], [b, c], {
'1:tableId': 'advanced',
'2:tableId': 'basic',
})
expect(result).toEqual({ '0:tableId': 'advanced', '1:tableId': 'basic' })
})
it.concurrent('re-keys overrides after a drag reorder (swap)', () => {
const a = tool('a')
const b = tool('b')
// Swap a and b: a moves 0->1, b moves 1->0. A naive sequential re-key would have one
// write clobber the other since both use the same canonicalId; this must resolve both
// from the ORIGINAL snapshot into one atomic result.
const result = reindexToolCanonicalModes([a, b], [b, a], {
'0:tableId': 'advanced',
'1:tableId': 'basic',
})
expect(result).toEqual({ '1:tableId': 'advanced', '0:tableId': 'basic' })
})
it.concurrent(
'regression: drops a removed tool old key so a later tool cannot inherit it',
() => {
const a = tool('a')
const b = tool('b')
// Remove `b` (index 1): nothing survives at index 1 in the result, so a future tool
// appended back into that slot won't silently inherit `b`'s old advanced mode.
const result = reindexToolCanonicalModes([a, b], [a], { '1:tableId': 'advanced' })
expect(result).toEqual({})
}
)
it.concurrent('drops a stale index key with no corresponding old-array position', () => {
// Simulates leftover pollution from before this fix (or an earlier missed clear):
// index 5 doesn't correspond to any tool in `oldTools` at all.
const a = tool('a')
const result = reindexToolCanonicalModes([a], [a], {
'5:tableId': 'advanced',
'0:tableId': 'basic',
})
expect(result).toEqual({ '0:tableId': 'basic' })
})
it.concurrent('carries a legacy (non-index-scoped) key through unchanged', () => {
const a = tool('a')
const b = tool('b')
// `table:tableId` isn't tied to any array position - removing/reordering tools must not
// touch it.
const result = reindexToolCanonicalModes([a, b], [b], {
'0:tableId': 'advanced',
'table:tableId': 'basic',
})
expect(result).toEqual({ 'table:tableId': 'basic' })
})
it.concurrent('ignores falsy override values', () => {
const a = tool('a')
const b = tool('b')
const result = reindexToolCanonicalModes([a, b], [b, a], {
'0:tableId': undefined as unknown as 'advanced',
})
expect(result).toBeUndefined()
})
})