124 lines
4.9 KiB
TypeScript
124 lines
4.9 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { LoopGuard } from '../src/loop-guard.js';
|
|
|
|
describe('LoopGuard Rolling Window Detection (11B-7)', () => {
|
|
it('detects A/B oscillation pattern after 8 calls (4 of each)', () => {
|
|
// windowSize=10, windowThreshold=4 (defaults)
|
|
const guard = new LoopGuard({ maxRepeats: 10 }); // high maxRepeats so consecutive check doesn't trigger
|
|
|
|
const argA = { command: 'echo A' };
|
|
const argB = { command: 'echo B' };
|
|
|
|
// A, B, A, B, A, B — 3 of each, still allowed
|
|
expect(guard.check('bash', argA)).toBe(true); // window: [A]
|
|
expect(guard.check('bash', argB)).toBe(true); // window: [A, B]
|
|
expect(guard.check('bash', argA)).toBe(true); // window: [A, B, A]
|
|
expect(guard.check('bash', argB)).toBe(true); // window: [A, B, A, B]
|
|
expect(guard.check('bash', argA)).toBe(true); // window: [A, B, A, B, A] — A=3
|
|
expect(guard.check('bash', argB)).toBe(true); // window: [A, B, A, B, A, B] — B=3
|
|
|
|
// 7th call: A again — A count = 4 in window => blocked
|
|
expect(guard.check('bash', argA)).toBe(false);
|
|
});
|
|
|
|
it('alternating A, B, A, B, A, B, A, B — detects loop after 8th call', () => {
|
|
const guard = new LoopGuard({ maxRepeats: 10, windowSize: 10, windowThreshold: 4 });
|
|
|
|
const results: boolean[] = [];
|
|
for (let i = 0; i < 8; i++) {
|
|
const args = i % 2 === 0 ? { cmd: 'A' } : { cmd: 'B' };
|
|
results.push(guard.check('tool', args));
|
|
}
|
|
|
|
// First 6 should be true (3 of each), 7th triggers A=4
|
|
// Call 1: A(1) -> true
|
|
// Call 2: B(1) -> true
|
|
// Call 3: A(2) -> true
|
|
// Call 4: B(2) -> true
|
|
// Call 5: A(3) -> true
|
|
// Call 6: B(3) -> true
|
|
// Call 7: A(4) -> false (4 A's in window)
|
|
// Call 8: B(4) -> would also be false but we already stopped at 7
|
|
|
|
// The 7th call (index 6) should be false
|
|
expect(results.slice(0, 6).every(r => r)).toBe(true);
|
|
expect(results[6]).toBe(false);
|
|
});
|
|
|
|
it('does not false-positive with diverse calls in the window', () => {
|
|
const guard = new LoopGuard({ maxRepeats: 10, windowSize: 10, windowThreshold: 4 });
|
|
|
|
// 10 different calls
|
|
for (let i = 0; i < 10; i++) {
|
|
expect(guard.check('tool', { idx: i })).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('window slides — old entries drop out', () => {
|
|
const guard = new LoopGuard({ maxRepeats: 10, windowSize: 5, windowThreshold: 3 });
|
|
|
|
// Fill window: A, A, B, C, D — A count is 2
|
|
expect(guard.check('t', { v: 'A' })).toBe(true);
|
|
expect(guard.check('t', { v: 'A' })).toBe(true);
|
|
expect(guard.check('t', { v: 'B' })).toBe(true);
|
|
expect(guard.check('t', { v: 'C' })).toBe(true);
|
|
expect(guard.check('t', { v: 'D' })).toBe(true);
|
|
|
|
// Window is now [A, A, B, C, D] — if we add A, window becomes [A, B, C, D, A]
|
|
// A count = 2 (within threshold of 3)
|
|
expect(guard.check('t', { v: 'A' })).toBe(true);
|
|
|
|
// Now add E to push out old A: window [B, C, D, A, E]
|
|
expect(guard.check('t', { v: 'E' })).toBe(true);
|
|
|
|
// Add A again: window [C, D, A, E, A] — A count = 2
|
|
expect(guard.check('t', { v: 'A' })).toBe(true);
|
|
});
|
|
|
|
it('respects custom windowSize and windowThreshold', () => {
|
|
const guard = new LoopGuard({ maxRepeats: 10, windowSize: 4, windowThreshold: 2 });
|
|
|
|
// With threshold=2 in window of 4:
|
|
expect(guard.check('t', { v: 'X' })).toBe(true); // window: [X], count=1
|
|
expect(guard.check('t', { v: 'Y' })).toBe(true); // window: [X, Y]
|
|
expect(guard.check('t', { v: 'X' })).toBe(false); // window: [X, Y, X], X count=2 => blocked
|
|
});
|
|
|
|
it('reset clears window state', () => {
|
|
const guard = new LoopGuard({ maxRepeats: 10, windowSize: 5, windowThreshold: 3 });
|
|
|
|
// Build up some history
|
|
guard.check('t', { v: 'A' });
|
|
guard.check('t', { v: 'A' });
|
|
|
|
guard.reset();
|
|
|
|
// After reset, A count should be fresh
|
|
expect(guard.check('t', { v: 'A' })).toBe(true); // A=1 in fresh window
|
|
expect(guard.check('t', { v: 'A' })).toBe(true); // A=2
|
|
});
|
|
|
|
it('consecutive detection still works alongside window detection', () => {
|
|
const guard = new LoopGuard({ maxRepeats: 3 }); // consecutive limit
|
|
|
|
// 3 consecutive identical calls — the 4th is blocked by consecutive check
|
|
expect(guard.check('bash', { cmd: 'x' })).toBe(true);
|
|
expect(guard.check('bash', { cmd: 'x' })).toBe(true);
|
|
expect(guard.check('bash', { cmd: 'x' })).toBe(true);
|
|
expect(guard.check('bash', { cmd: 'x' })).toBe(false); // consecutive > 3
|
|
});
|
|
|
|
it('three-way oscillation A, B, C detected when threshold met', () => {
|
|
const guard = new LoopGuard({ maxRepeats: 20, windowSize: 12, windowThreshold: 4 });
|
|
|
|
// A, B, C, A, B, C, A, B, C — 3 of each, still fine
|
|
for (let cycle = 0; cycle < 3; cycle++) {
|
|
expect(guard.check('t', { v: 'A' })).toBe(true);
|
|
expect(guard.check('t', { v: 'B' })).toBe(true);
|
|
expect(guard.check('t', { v: 'C' })).toBe(true);
|
|
}
|
|
// 10th call: A — A count becomes 4 in window => blocked
|
|
expect(guard.check('t', { v: 'A' })).toBe(false);
|
|
});
|
|
});
|