mirror of
https://github.com/proffesor-for-testing/agentic-qe.git
synced 2026-09-19 08:45:47 +08:00
3bc09cda6d
A post-Phase-4 devil's-advocate audit identified 10 issues. All addressed
with code fixes + tests. Some were real bugs (silent billing regression,
2 unwired MCP tools, 2 HybridRouter routing bugs); others were robustness
improvements (split singletons, silent errors, decorative type fields).
Findings → fixes (severity tagged in audit report):
1. CRITICAL — silent billing regression on upgrade
Users with ANTHROPIC_API_KEY in env would silently start paying for
Claude API calls on every Fleet operation. The CHANGELOG's "opt out
via code changes" wasn't sufficient — needed an env-only escape.
Fix: AQE_LLM_ROUTER_DISABLED=1 env kill-switch honored in both
QEKernelImpl._initializeLLMRouter and src/mcp/tools/base.ts
getSharedLLMRouter. Truthy values disable; 'false'/'0'/'no'/'off'/''
pass. Unified parse rule between kernel and MCP base.
2. HIGH — 2 unwired MCP standalone tools (audit said 3; QualityCriteria
isn't LLM-aware so excluded)
src/mcp/tools/test-generation/generate.ts:82 was calling
createTestGeneratorService(memory) without router. Same for
visual-accessibility/index.ts:177. Both now use the
createXxxServiceWithDependencies factories with { memory, llmRouter }.
3. CRITICAL — E2E only tested manual routing mode, not production
Production default is rule-based. Adding two new E2E tests for
rule-based mode SURFACED two real bugs in HybridRouter that I fixed:
3a. executeWithFallback hardcoded ['claude','openai','ollama'] as the
only fallback providers; gemini/openrouter/azure-openai/bedrock
users had no fallback even though ProviderManager can construct
them. Plus the user's defaultProvider wasn't auto-included in the
chain. Both fixed at src/shared/llm/router/hybrid-router.ts:612.
3b. A non-retryable error from one provider (e.g. Claude "invalid
x-api-key") aborted the entire fallback loop via `throw error`,
defeating the whole point of a fallback chain. Fixed: skip the
inter-attempt delay for non-retryable but continue to the next
fallback entry.
4. HIGH — MCPToolContext.llmRouter field was decorative
Added the field but no code populated it. Every getLLMRouter(context)
call fell through to getSharedLLMRouter().
Fix: added llmRouter and memory to BaseMCPTool.invoke() options;
qe-tool-bridge.ts and registry.ts now explicitly forward the shared
singleton via { llmRouter } at invoke time.
5. HIGH — 14 of 15 wired services had only STRUCTURAL verification, not
behavioral (only TestExecutor had a chat() round-trip test)
Fix: new tests/integration/llm-router-behavioral.test.ts asserts
isLLMAnalysisAvailable()===true (the exact gate the audit named) for
all 14 services + 2 full behavioral round-trips. 16 tests total.
6. HIGH — MCP and kernel built SEPARATE HybridRouter instances
Different cost trackers, caches, circuit breakers per side.
routing_economics/routing_metrics would only see half the traffic.
Fix: kernel.ts._initializeLLMRouter calls setSharedLLMRouter(router)
on init so both paths share one instance. kernel.dispose() calls
resetSharedLLMRouter() so the next kernel doesn't inherit the
previous instance.
7. HIGH — Silent error swallowing in _initializeLLMRouter
console.warn'd then continued without LLM, with no signal for
monitoring. A misconfigured provider would silently degrade with no
alert.
Fix: now publishes kernel.llm-router.init-failed (caught error) and
kernel.llm-router.init-no-provider (enabled=true but nothing
available) events on the event bus. Test confirms event delivery.
8. MEDIUM — Service count mismatch in docs (ADR/CHANGELOG said "14
services / 12 domains"; test enumerates 15 services / 11
LLM-enhanced domains)
Fix: ADR-043 addendum + CHANGELOG corrected to 15/11. Added
explicit service list and noted the 2 non-LLM domains
(enterprise-integration, coordination).
9. MEDIUM — aqe llm config --set apiKey=... silently dropped the key
stripApiKeys() returned a successfully-saved file without the key,
user thought it was saved.
Fix: CLI updateConfig() now throws a clear error pointing to env
vars BEFORE calling save. stripApiKeys also now logs a console.warn
when it strips anything, so programmatic callers see the strip.
10. LOW — Env vs disk precedence was counterintuitive and undocumented
Force-enable-on-env overrides explicit `enabled: false` on disk.
Fix: doc comment in config-store.applyEnvProviderDetection +
ADR-043 "Precedence & env-var conventions" section explains.
Verification
- npx tsc --noEmit: clean
- 191 unit + integration tests pass (16 new behavioral tests for #5,
17 wiring tests, gemini provider, config-store, llm-router-service,
kernel-llm-router with 17 tests including all kill-switch cases,
CLI persistence with 28 tests)
- 5 real-provider E2E tests pass (Gemini 2.5 Flash, OpenAI gpt-4o-mini,
OpenRouter, plus 2 rule-based-mode tests that previously failed and
now pass with the HybridRouter fallback fixes)
Refs ADR-043 audit findings (2026-05-21)
Builds on 94033e3 (Phase 1), be1e60d (Phase 2), c206c81 (Phase 3+4)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
459 lines
16 KiB
TypeScript
459 lines
16 KiB
TypeScript
/**
|
|
* LLM Router CLI Commands Tests
|
|
* ADR-043: Vendor-Independent LLM Support - Milestone 10
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { createLLMRouterCommand } from '../../../../src/cli/commands/llm-router.js';
|
|
import type { Command } from 'commander';
|
|
|
|
// Mock console.log and console.error for output testing
|
|
let mockConsoleLog: ReturnType<typeof vi.spyOn>;
|
|
let mockConsoleError: ReturnType<typeof vi.spyOn>;
|
|
let mockExit: ReturnType<typeof vi.spyOn>;
|
|
|
|
// Helper to parse command and execute it
|
|
async function executeCommand(
|
|
cmd: Command,
|
|
args: string[],
|
|
): Promise<void> {
|
|
// Configure commander to not exit on error
|
|
cmd.exitOverride();
|
|
cmd.configureOutput({
|
|
writeOut: () => {},
|
|
writeErr: () => {},
|
|
});
|
|
|
|
// Parse will trigger the action - use 'node' as default (first two items are node and script)
|
|
await cmd.parseAsync(args, { from: 'user' });
|
|
}
|
|
|
|
describe('LLM Router CLI Commands', () => {
|
|
let command: ReturnType<typeof createLLMRouterCommand>;
|
|
let tmpConfigRoot: string;
|
|
let savedConfigRootEnv: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
// Redirect the CLI's config persistence to a per-test temp dir so
|
|
// `aqe llm config --set` doesn't mutate the real .agentic-qe/.
|
|
tmpConfigRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'aqe-cli-llm-'));
|
|
savedConfigRootEnv = process.env.AQE_CONFIG_ROOT;
|
|
process.env.AQE_CONFIG_ROOT = tmpConfigRoot;
|
|
|
|
command = createLLMRouterCommand();
|
|
mockConsoleLog = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
mockConsoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
mockExit = vi.spyOn(process, 'exit').mockImplementation((() => {}) as never);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
if (savedConfigRootEnv === undefined) {
|
|
delete process.env.AQE_CONFIG_ROOT;
|
|
} else {
|
|
process.env.AQE_CONFIG_ROOT = savedConfigRootEnv;
|
|
}
|
|
if (fs.existsSync(tmpConfigRoot)) {
|
|
fs.rmSync(tmpConfigRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe('createLLMRouterCommand', () => {
|
|
it('should create a command with name "llm"', () => {
|
|
expect(command.name()).toBe('llm');
|
|
});
|
|
|
|
it('should have a description', () => {
|
|
expect(command.description()).toBeTruthy();
|
|
expect(command.description()).toContain('LLM Router');
|
|
});
|
|
|
|
it('should have subcommands', () => {
|
|
const subcommandNames = command.commands.map(c => c.name());
|
|
expect(subcommandNames).toContain('providers');
|
|
expect(subcommandNames).toContain('models');
|
|
expect(subcommandNames).toContain('route');
|
|
expect(subcommandNames).toContain('config');
|
|
expect(subcommandNames).toContain('health');
|
|
expect(subcommandNames).toContain('cost');
|
|
});
|
|
});
|
|
|
|
describe('providers subcommand', () => {
|
|
it('should exist and have correct options', () => {
|
|
const providersCmd = command.commands.find(c => c.name() === 'providers');
|
|
expect(providersCmd).toBeDefined();
|
|
|
|
const options = providersCmd!.options.map(o => o.long);
|
|
expect(options).toContain('--json');
|
|
expect(options).toContain('--verbose');
|
|
});
|
|
|
|
it('should list all provider types via parse', async () => {
|
|
await executeCommand(command, ['providers', '--json']);
|
|
|
|
expect(mockConsoleLog).toHaveBeenCalled();
|
|
|
|
// Get the JSON output
|
|
const jsonOutput = mockConsoleLog.mock.calls[0][0];
|
|
const providers = JSON.parse(jsonOutput);
|
|
|
|
expect(Array.isArray(providers)).toBe(true);
|
|
expect(providers.length).toBeGreaterThan(0);
|
|
|
|
// Check for expected providers
|
|
const providerNames = providers.map((p: { provider: string }) => p.provider);
|
|
expect(providerNames).toContain('claude');
|
|
expect(providerNames).toContain('openai');
|
|
expect(providerNames).toContain('ollama');
|
|
});
|
|
});
|
|
|
|
describe('models subcommand', () => {
|
|
it('should exist and have correct options', () => {
|
|
const modelsCmd = command.commands.find(c => c.name() === 'models');
|
|
expect(modelsCmd).toBeDefined();
|
|
|
|
const options = modelsCmd!.options.map(o => o.long);
|
|
expect(options).toContain('--provider');
|
|
expect(options).toContain('--tier');
|
|
expect(options).toContain('--json');
|
|
});
|
|
|
|
it('should list models as JSON', async () => {
|
|
await executeCommand(command, ['models', '--json']);
|
|
|
|
expect(mockConsoleLog).toHaveBeenCalled();
|
|
|
|
const jsonOutput = mockConsoleLog.mock.calls[0][0];
|
|
const models = JSON.parse(jsonOutput);
|
|
|
|
expect(Array.isArray(models)).toBe(true);
|
|
expect(models.length).toBeGreaterThan(0);
|
|
|
|
// Check model structure
|
|
const firstModel = models[0];
|
|
expect(firstModel).toHaveProperty('canonicalId');
|
|
expect(firstModel).toHaveProperty('tier');
|
|
expect(firstModel).toHaveProperty('family');
|
|
});
|
|
|
|
it('should filter models by provider', async () => {
|
|
await executeCommand(command, ['models', '--json', '--provider', 'claude']);
|
|
|
|
const jsonOutput = mockConsoleLog.mock.calls[0][0];
|
|
const models = JSON.parse(jsonOutput);
|
|
|
|
expect(models.length).toBeGreaterThan(0);
|
|
// All returned models should have Claude provider ID
|
|
for (const model of models) {
|
|
expect(model.providerIds).toHaveProperty('claude');
|
|
}
|
|
});
|
|
|
|
it('should filter models by tier', async () => {
|
|
await executeCommand(command, ['models', '--json', '--tier', 'flagship']);
|
|
|
|
const jsonOutput = mockConsoleLog.mock.calls[0][0];
|
|
const models = JSON.parse(jsonOutput);
|
|
|
|
expect(models.length).toBeGreaterThan(0);
|
|
// All returned models should be flagship tier
|
|
for (const model of models) {
|
|
expect(model.tier).toBe('flagship');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('route subcommand', () => {
|
|
it('should exist and have correct arguments and options', () => {
|
|
const routeCmd = command.commands.find(c => c.name() === 'route');
|
|
expect(routeCmd).toBeDefined();
|
|
|
|
// Check for task argument
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
expect((routeCmd as any).registeredArguments.length).toBe(1);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
expect((routeCmd as any).registeredArguments[0].name()).toBe('task');
|
|
|
|
const options = routeCmd!.options.map(o => o.long);
|
|
expect(options).toContain('--agent');
|
|
expect(options).toContain('--complexity');
|
|
expect(options).toContain('--mode');
|
|
expect(options).toContain('--json');
|
|
});
|
|
|
|
it('should route a security task to Claude', async () => {
|
|
await executeCommand(command, ['route', 'security vulnerability scan', '--json']);
|
|
|
|
const jsonOutput = mockConsoleLog.mock.calls[0][0];
|
|
const decision = JSON.parse(jsonOutput);
|
|
|
|
expect(decision).toHaveProperty('provider');
|
|
expect(decision).toHaveProperty('model');
|
|
expect(decision).toHaveProperty('reason');
|
|
expect(decision).toHaveProperty('confidence');
|
|
expect(decision.provider).toBe('claude');
|
|
});
|
|
|
|
it('should route with agent type consideration', async () => {
|
|
await executeCommand(command, ['route', 'analyze code', '--json', '--agent', 'security-auditor']);
|
|
|
|
const jsonOutput = mockConsoleLog.mock.calls[0][0];
|
|
const decision = JSON.parse(jsonOutput);
|
|
|
|
expect(decision.provider).toBe('claude');
|
|
expect(decision.reason).toContain('Security');
|
|
});
|
|
|
|
it('should route simple tasks to efficient models', async () => {
|
|
await executeCommand(command, [
|
|
'route',
|
|
'simple formatting task',
|
|
'--json',
|
|
'--complexity',
|
|
'trivial',
|
|
'--mode',
|
|
'cost-optimized',
|
|
]);
|
|
|
|
const jsonOutput = mockConsoleLog.mock.calls[0][0];
|
|
const decision = JSON.parse(jsonOutput);
|
|
|
|
// Should select cost-effective model
|
|
expect(decision.model).toContain('mini');
|
|
});
|
|
});
|
|
|
|
describe('config subcommand', () => {
|
|
it('should exist and have correct options', () => {
|
|
const configCmd = command.commands.find(c => c.name() === 'config');
|
|
expect(configCmd).toBeDefined();
|
|
|
|
const options = configCmd!.options.map(o => o.long);
|
|
expect(options).toContain('--set');
|
|
expect(options).toContain('--json');
|
|
});
|
|
|
|
it('should output configuration as JSON', async () => {
|
|
await executeCommand(command, ['config', '--json']);
|
|
|
|
const jsonOutput = mockConsoleLog.mock.calls[0][0];
|
|
const config = JSON.parse(jsonOutput);
|
|
|
|
expect(config).toHaveProperty('mode');
|
|
expect(config).toHaveProperty('defaultProvider');
|
|
expect(config).toHaveProperty('defaultModel');
|
|
expect(config).toHaveProperty('enableMetrics');
|
|
});
|
|
|
|
it('should set and persist configuration value', async () => {
|
|
// Set mode to cost-optimized
|
|
await executeCommand(command, ['config', '--set', 'mode=cost-optimized']);
|
|
|
|
expect(mockConsoleLog).toHaveBeenCalled();
|
|
|
|
// Verify the setting was applied
|
|
mockConsoleLog.mockClear();
|
|
// Create new command instance to verify persistence (in-memory for tests)
|
|
const newCommand = createLLMRouterCommand();
|
|
await executeCommand(newCommand, ['config', '--json']);
|
|
|
|
const jsonOutput = mockConsoleLog.mock.calls[0][0];
|
|
const config = JSON.parse(jsonOutput);
|
|
expect(config.mode).toBe('cost-optimized');
|
|
});
|
|
|
|
it('writes the setting through to disk so a fresh process picks it up', async () => {
|
|
// Simulate the cross-process case: --set writes the file.
|
|
await executeCommand(command, ['config', '--set', 'defaultProvider=gemini']);
|
|
|
|
const configPath = path.join(tmpConfigRoot, '.agentic-qe', 'llm-config.json');
|
|
expect(fs.existsSync(configPath)).toBe(true);
|
|
const onDisk = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
expect(onDisk.defaultProvider).toBe('gemini');
|
|
});
|
|
|
|
it('strips secrets defensively on persist (apiKey never reaches disk)', async () => {
|
|
// The config-store strips any apiKey fields it sees in the
|
|
// serialized payload — this protects against a future bug that
|
|
// ever copies an env API key into the in-memory config.
|
|
await executeCommand(command, ['config', '--set', 'defaultProvider=openai']);
|
|
|
|
const configPath = path.join(tmpConfigRoot, '.agentic-qe', 'llm-config.json');
|
|
const raw = fs.readFileSync(configPath, 'utf8');
|
|
expect(raw).not.toMatch(/sk-/);
|
|
expect(raw).not.toMatch(/apiKey/);
|
|
});
|
|
|
|
it('refuses to save apiKey with a clear error pointing to env vars', async () => {
|
|
// Audit Fix #9: previously `--set apiKey=...` returned "Unknown
|
|
// config key" which was unhelpful. Now it throws a clear error
|
|
// that explains api keys go in env vars.
|
|
let thrown: Error | undefined;
|
|
try {
|
|
await executeCommand(command, ['config', '--set', 'apiKey=sk-fake-123']);
|
|
} catch (e) {
|
|
thrown = e as Error;
|
|
}
|
|
expect(thrown).toBeDefined();
|
|
expect(thrown!.message).toMatch(/environment variable|ANTHROPIC_API_KEY|GEMINI_API_KEY/i);
|
|
|
|
// And nothing was written to disk
|
|
const configPath = path.join(tmpConfigRoot, '.agentic-qe', 'llm-config.json');
|
|
if (fs.existsSync(configPath)) {
|
|
const raw = fs.readFileSync(configPath, 'utf8');
|
|
expect(raw).not.toMatch(/sk-fake-123/);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('health subcommand', () => {
|
|
it('should exist and have correct options', () => {
|
|
const healthCmd = command.commands.find(c => c.name() === 'health');
|
|
expect(healthCmd).toBeDefined();
|
|
|
|
const options = healthCmd!.options.map(o => o.long);
|
|
expect(options).toContain('--json');
|
|
expect(options).toContain('--timeout');
|
|
});
|
|
|
|
it('should return health status for all providers', async () => {
|
|
await executeCommand(command, ['health', '--json']);
|
|
|
|
const jsonOutput = mockConsoleLog.mock.calls[0][0];
|
|
const healthResults = JSON.parse(jsonOutput);
|
|
|
|
expect(Array.isArray(healthResults)).toBe(true);
|
|
expect(healthResults.length).toBeGreaterThan(0);
|
|
|
|
// Check structure of health result
|
|
const firstResult = healthResults[0];
|
|
expect(firstResult).toHaveProperty('provider');
|
|
expect(firstResult).toHaveProperty('healthy');
|
|
});
|
|
});
|
|
|
|
describe('cost subcommand', () => {
|
|
it('should exist and have correct arguments and options', () => {
|
|
const costCmd = command.commands.find(c => c.name() === 'cost');
|
|
expect(costCmd).toBeDefined();
|
|
|
|
// Check for model argument
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
expect((costCmd as any).registeredArguments.length).toBe(1);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
expect((costCmd as any).registeredArguments[0].name()).toBe('model');
|
|
|
|
const options = costCmd!.options.map(o => o.long);
|
|
expect(options).toContain('--tokens');
|
|
expect(options).toContain('--json');
|
|
});
|
|
|
|
it('should estimate cost for a known model', async () => {
|
|
await executeCommand(command, ['cost', 'claude-sonnet-4-6', '--json', '--tokens', '10000']);
|
|
|
|
const jsonOutput = mockConsoleLog.mock.calls[0][0];
|
|
const costEstimate = JSON.parse(jsonOutput);
|
|
|
|
expect(costEstimate).toHaveProperty('model');
|
|
expect(costEstimate).toHaveProperty('tokens');
|
|
expect(costEstimate).toHaveProperty('inputCostPer1M');
|
|
expect(costEstimate).toHaveProperty('outputCostPer1M');
|
|
expect(costEstimate).toHaveProperty('estimatedTotalCost');
|
|
expect(costEstimate.tokens).toBe(10000);
|
|
});
|
|
|
|
it('should error for unknown model', async () => {
|
|
// Unknown model triggers console.error and process.exit
|
|
await executeCommand(command, ['cost', 'unknown-model-xyz', '--json']);
|
|
|
|
// Expected - console.error was called
|
|
expect(mockConsoleError).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('command help', () => {
|
|
it('should have help text with subcommands listed', () => {
|
|
const helpText = command.helpInformation();
|
|
|
|
expect(helpText).toContain('providers');
|
|
expect(helpText).toContain('models');
|
|
expect(helpText).toContain('route');
|
|
expect(helpText).toContain('config');
|
|
expect(helpText).toContain('health');
|
|
expect(helpText).toContain('cost');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('LLM Router Integration', () => {
|
|
beforeEach(() => {
|
|
mockConsoleLog = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
mockConsoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
mockExit = vi.spyOn(process, 'exit').mockImplementation((() => {}) as never);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('should support all 8 provider types', async () => {
|
|
const expectedProviders = [
|
|
'claude',
|
|
'openai',
|
|
'ollama',
|
|
'openrouter',
|
|
'gemini',
|
|
'azure-openai',
|
|
'bedrock',
|
|
'onnx',
|
|
];
|
|
|
|
const command = createLLMRouterCommand();
|
|
await executeCommand(command, ['providers', '--json']);
|
|
|
|
const jsonOutput = mockConsoleLog.mock.calls[0][0];
|
|
const providers = JSON.parse(jsonOutput);
|
|
|
|
const providerNames = providers.map((p: { provider: string }) => p.provider);
|
|
for (const expected of expectedProviders) {
|
|
expect(providerNames).toContain(expected);
|
|
}
|
|
});
|
|
|
|
it('should support 4 routing modes', async () => {
|
|
const modes = ['manual', 'rule-based', 'cost-optimized', 'performance-optimized'];
|
|
|
|
for (const mode of modes) {
|
|
mockConsoleLog.mockClear();
|
|
const command = createLLMRouterCommand();
|
|
await executeCommand(command, ['route', 'test task', '--json', '--mode', mode]);
|
|
|
|
expect(mockConsoleLog).toHaveBeenCalled();
|
|
const jsonOutput = mockConsoleLog.mock.calls[0][0];
|
|
const decision = JSON.parse(jsonOutput);
|
|
|
|
expect(decision).toHaveProperty('provider');
|
|
expect(decision).toHaveProperty('model');
|
|
}
|
|
});
|
|
|
|
it('should provide accurate cost estimates', async () => {
|
|
const command = createLLMRouterCommand();
|
|
await executeCommand(command, ['cost', 'gpt-4o-mini', '--json', '--tokens', '1000000']);
|
|
|
|
const jsonOutput = mockConsoleLog.mock.calls[0][0];
|
|
const costEstimate = JSON.parse(jsonOutput);
|
|
|
|
// GPT-4o-mini pricing: $0.15 input, $0.6 output per 1M tokens
|
|
expect(costEstimate.inputCostPer1M).toBe(0.15);
|
|
expect(costEstimate.outputCostPer1M).toBe(0.6);
|
|
// At 1M tokens, cost should be: 0.15 + 0.6 = 0.75
|
|
expect(costEstimate.estimatedTotalCost).toBeCloseTo(0.75, 2);
|
|
});
|
|
});
|