Remove Token Services.

The token services weren't providing much value and have become more maintenance than they are worth given the addition of binary conversation data.
This commit is contained in:
Andrew Beal 2025-12-17 10:54:04 +00:00
parent d96d4b4b29
commit 385271cb75
13 changed files with 1 additions and 691 deletions

View file

@ -1,41 +0,0 @@
import type { ITokenService } from "AIClasses/ITokenService";
import Anthropic from '@anthropic-ai/sdk'
import { Resolve } from "Services/DependencyService";
import { Services } from "Services/Services";
import { Role } from "Enums/Role";
import { AIProvider } from "Enums/ApiProvider";
import type { SettingsService } from "Services/SettingsService";
import type { AbortService } from "Services/AbortService";
export class ClaudeTokenService implements ITokenService {
private ai: Anthropic;
private model: string;
private readonly abortService: AbortService;
public constructor() {
const settingsService = Resolve<SettingsService>(Services.SettingsService);
this.ai = new Anthropic({
apiKey: settingsService.getApiKeyForProvider(AIProvider.Claude),
dangerouslyAllowBrowser: true
});
this.model = settingsService.settings.model;
this.abortService = Resolve<AbortService>(Services.AbortService);
}
public async countTokens(input: string): Promise<number> {
if (input.trim() === "") {
return 0;
}
// to maintain the convenience of the interface we just submit the entire input as one message
const result = await this.ai.messages.countTokens({
model: this.model,
messages: [
{ role: Role.User, content: input }
]
})
return result.input_tokens;
}
}

View file

@ -1,36 +0,0 @@
import type { ITokenService } from "AIClasses/ITokenService";
import { CountTokensResponse, GoogleGenAI } from '@google/genai'
import { Resolve } from "Services/DependencyService";
import { Services } from "Services/Services";
import { AIProvider } from "Enums/ApiProvider";
import type { SettingsService } from "Services/SettingsService";
import type { AbortService } from "Services/AbortService";
export class GeminiTokenService implements ITokenService {
private readonly ai: GoogleGenAI;
private model: string;
private readonly abortService: AbortService;
public constructor() {
const settingsService = Resolve<SettingsService>(Services.SettingsService);
this.ai = new GoogleGenAI({
apiKey: settingsService.getApiKeyForProvider(AIProvider.Gemini)
});
this.model = settingsService.settings.model;
this.abortService = Resolve<AbortService>(Services.AbortService);
}
public async countTokens(input: string): Promise<number> {
if (input.trim() === "") {
return 0;
}
const result: CountTokensResponse = await this.ai.models.countTokens({
model: this.model,
contents: input
});
return result.totalTokens ?? -1;
}
}

View file

@ -1,3 +0,0 @@
export interface ITokenService {
countTokens(input: string): Promise<number>;
}

View file

@ -1,14 +0,0 @@
import type { ITokenService } from "AIClasses/ITokenService";
import { countTokens } from 'gpt-tokenizer'
export class OpenAITokenService implements ITokenService {
public countTokens(input: string): Promise<number> {
if (input.trim() === "") {
return Promise.resolve(0);
}
return Promise.resolve(countTokens(input));
}
}

View file

@ -49,7 +49,6 @@
if (chatContainer) {
plugin.registerDomEvent(chatContainer, 'click', handleLinkClick);
}
chatService.setStatusBarTokens(0, 0);
});
async function handleLinkClick(evt: MouseEvent) {
@ -121,7 +120,6 @@
isSubmitting = false;
abortService.reset();
chatArea.updateChatAreaLayout("smooth", true);
await chatService.updateTokenDisplay(conversation);
},
onCancel: () => {
cancelling = true;
@ -131,7 +129,6 @@
$: if ($conversationStore.shouldReset) {
conversation = new Conversation();
chatService.setStatusBarTokens(0, 0);
isSubmitting = false;
currentStreamingMessageId = null;
@ -155,7 +152,6 @@
conversation = loadedConversation;
conversationService.setCurrentConversationPath(filePath);
chatService.onNameChanged?.(loadedConversation.title);
chatService.updateTokenDisplay(loadedConversation);
conversationStore.clearLoadFlag();
chatArea.updateChatAreaLayout("instant", true);
}

View file

@ -5,9 +5,6 @@ import type { IAIClass } from "AIClasses/IAIClass";
import type { ConversationFileSystemService } from "./ConversationFileSystemService";
import type { AIFunctionService } from "./AIFunctionService";
import type { ConversationNamingService } from "./ConversationNamingService";
import type { ITokenService } from "AIClasses/ITokenService";
import type { IPrompt } from "AIClasses/IPrompt";
import type { StatusBarService } from "./StatusBarService";
import { Conversation } from "Conversations/Conversation";
import { ConversationContent } from "Conversations/ConversationContent";
import { Role } from "Enums/Role";
@ -32,9 +29,6 @@ export class ChatService {
private conversationService: ConversationFileSystemService;
private aiFunctionService: AIFunctionService;
private namingService: ConversationNamingService;
private tokenService: ITokenService | undefined;
private prompt: IPrompt;
private statusBarService: StatusBarService;
private eventService: EventService;
private abortService: AbortService;
@ -45,8 +39,6 @@ export class ChatService {
this.conversationService = Resolve<ConversationFileSystemService>(Services.ConversationFileSystemService);
this.aiFunctionService = Resolve<AIFunctionService>(Services.AIFunctionService);
this.namingService = Resolve<ConversationNamingService>(Services.ConversationNamingService);
this.prompt = Resolve<IPrompt>(Services.IPrompt);
this.statusBarService = Resolve<StatusBarService>(Services.StatusBarService);
this.eventService = Resolve<EventService>(Services.EventService);
this.abortService = Resolve<AbortService>(Services.AbortService);
this.semaphore = new Semaphore(1, false);
@ -56,7 +48,6 @@ export class ChatService {
public resolveAIProvider() {
this.ai = Resolve<IAIClass>(Services.IAIClass);
this.tokenService = Resolve<ITokenService>(Services.ITokenService);
}
public async submit(conversation: Conversation, allowDestructiveActions: boolean, userRequest: string, formattedRequest: string, callbacks: IChatServiceCallbacks) {
@ -131,35 +122,6 @@ export class ChatService {
this.eventService.trigger(Event.DiffClosed);
}
public async updateTokenDisplay(conversation: Conversation) {
if (this.tokenService === undefined) {
return;
}
const systemInstruction = this.prompt.systemInstruction();
const userInstruction = await this.prompt.userInstruction();
const inputMessages = conversation.contents
.filter(message => message.role === Role.User && !message.isFunctionCallResponse)
.map(message => message.promptContent)
.join("\n");
const outputMessages = conversation.contents
.filter(message => message.role === Role.Assistant && !message.isFunctionCall)
.map(message => message.content)
.join("\n");
const inputText = systemInstruction + "\n" + userInstruction + "\n" + inputMessages;
const inputTokens = await this.tokenService.countTokens(inputText);
const outputTokens = await this.tokenService.countTokens(outputMessages);
this.setStatusBarTokens(inputTokens, outputTokens);
}
public setStatusBarTokens(inputTokens: number, outputTokens: number) {
this.statusBarService.animateTokens(inputTokens, outputTokens);
}
private async saveConversation(conversation: Conversation) {
const result = await this.conversationService.saveConversation(conversation);
if (result instanceof Error) {

View file

@ -18,11 +18,6 @@ import { WorkSpaceService } from "./WorkSpaceService";
import { ChatService } from "./ChatService";
import { ConversationNamingService } from "./ConversationNamingService";
import { VaultService } from "./VaultService";
import type { ITokenService } from "AIClasses/ITokenService";
import { GeminiTokenService } from "AIClasses/Gemini/GeminiTokenService";
import { StatusBarService } from "./StatusBarService";
import { ClaudeTokenService } from "AIClasses/Claude/ClaudeTokenService";
import { OpenAITokenService } from "AIClasses/OpenAI/OpenAITokenService";
import { ClaudeConversationNamingService } from "AIClasses/Claude/ClaudeConversationNamingService";
import { Claude } from "AIClasses/Claude/Claude";
import { OpenAIConversationNamingService } from "AIClasses/OpenAI/OpenAIConversationNamingService";
@ -47,7 +42,6 @@ export async function RegisterPlugin(plugin: VaultkeeperAIPlugin) {
export function RegisterDependencies() {
RegisterSingleton<EventService>(Services.EventService, new EventService());
RegisterSingleton<AbortService>(Services.AbortService, new AbortService());
RegisterSingleton<StatusBarService>(Services.StatusBarService, new StatusBarService());
RegisterSingleton<HTMLService>(Services.HTMLService, new HTMLService());
RegisterSingleton<SanitiserService>(Services.SanitiserService, new SanitiserService());
RegisterSingleton<DiffService>(Services.DiffService, new DiffService());
@ -79,17 +73,14 @@ export function RegisterAiProvider() {
if (provider == AIProvider.Claude) {
RegisterSingleton<IAIClass>(Services.IAIClass, new Claude());
RegisterSingleton<ITokenService>(Services.ITokenService, new ClaudeTokenService());
RegisterSingleton<IConversationNamingService>(Services.IConversationNamingService, new ClaudeConversationNamingService());
}
else if (provider == AIProvider.Gemini) {
RegisterSingleton<IAIClass>(Services.IAIClass, new Gemini());
RegisterSingleton<ITokenService>(Services.ITokenService, new GeminiTokenService());
RegisterSingleton<IConversationNamingService>(Services.IConversationNamingService, new GeminiConversationNamingService());
}
else if (provider == AIProvider.OpenAI) {
RegisterSingleton<IAIClass>(Services.IAIClass, new OpenAI());
RegisterSingleton<ITokenService>(Services.ITokenService, new OpenAITokenService());
RegisterSingleton<IConversationNamingService>(Services.IConversationNamingService, new OpenAIConversationNamingService());
}

View file

@ -3,7 +3,6 @@ export class Services {
static SettingsService = Symbol("SettingsService");
static EventService = Symbol("EventService");
static AbortService = Symbol("AbortService");
static StatusBarService = Symbol("StatusBarService");
static HTMLService = Symbol("HTMLService");
static VaultService = Symbol("VaultService");
static VaultCacheService = Symbol("VaultCacheService");
@ -28,7 +27,6 @@ export class Services {
// interfaces
static IAIClass = Symbol("IAIClass");
static IPrompt = Symbol("IPrompt");
static ITokenService = Symbol("ITokenService");
static IConversationNamingService = Symbol("IConversationNamingService");
// modals

View file

@ -1,87 +0,0 @@
import type VaultkeeperAIPlugin from "main";
import { Resolve } from "./DependencyService";
import { Services } from "./Services";
import { Component } from "obsidian";
export class StatusBarService extends Component {
private readonly plugin: VaultkeeperAIPlugin;
private statusBarItem: HTMLElement | null;
private currentInputTokens: number = 0;
private currentOutputTokens: number = 0;
private animationFrame: number | null = null;
public constructor() {
super();
this.plugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
}
override onunload(): void {
this.removeStatusBarMessage();
}
public setStatusBarMessage(message: string) {
if (this.statusBarItem == null) {
this.createStatusBarMessage();
}
this.statusBarItem?.empty();
this.statusBarItem?.createEl("span", { text: message });
}
public animateTokens(targetInputTokens: number, targetOutputTokens: number) {
// Cancel any ongoing animation
if (this.animationFrame !== null) {
cancelAnimationFrame(this.animationFrame);
}
const startInputTokens = this.currentInputTokens;
const startOutputTokens = this.currentOutputTokens;
const startTime = performance.now();
const duration = 1000;
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
// Ease-out cubic for smooth deceleration
const eased = 1 - Math.pow(1 - progress, 3);
// Calculate current values
this.currentInputTokens = Math.round(
startInputTokens + (targetInputTokens - startInputTokens) * eased
);
this.currentOutputTokens = Math.round(
startOutputTokens + (targetOutputTokens - startOutputTokens) * eased
);
this.setStatusBarMessage(
`Input Tokens: ${this.currentInputTokens} / Output Tokens: ${this.currentOutputTokens}`
);
// Continue animation if not complete
if (progress < 1) {
this.animationFrame = requestAnimationFrame(animate);
} else {
this.animationFrame = null;
}
};
this.animationFrame = requestAnimationFrame(animate);
}
public removeStatusBarMessage() {
if (this.animationFrame !== null) {
cancelAnimationFrame(this.animationFrame);
this.animationFrame = null;
}
this.statusBarItem?.remove();
this.statusBarItem = null;
}
private createStatusBarMessage() {
this.statusBarItem?.remove();
this.statusBarItem = this.plugin.addStatusBarItem();
this.statusBarItem.empty();
}
}

View file

@ -2,9 +2,6 @@ import { ItemView, WorkspaceLeaf } from 'obsidian';
import { mount, unmount } from 'svelte';
import ChatWindow from 'Components/ChatWindow.svelte';
import TopBar from 'Components/TopBar.svelte';
import type { StatusBarService } from 'Services/StatusBarService';
import { Resolve } from 'Services/DependencyService';
import { Services } from 'Services/Services';
export const VIEW_TYPE_MAIN = 'vaultkeeper-ai-main-view';
@ -15,8 +12,6 @@ interface ChatWindowComponent {
export class MainView extends ItemView {
private statusBarService: StatusBarService = Resolve<StatusBarService>(Services.StatusBarService);
constructor(leaf: WorkspaceLeaf) {
super(leaf);
}
@ -40,7 +35,6 @@ export class MainView extends ItemView {
const container = this.contentEl;
container.empty();
// Mount TopBar with reference to ChatWindow's focus function
this.topBar = mount(TopBar, {
target: container,
props: {
@ -52,7 +46,6 @@ export class MainView extends ItemView {
}
});
// Mount ChatWindow first
this.input = mount(ChatWindow, {
target: container,
props: {}
@ -68,6 +61,5 @@ export class MainView extends ItemView {
if (this.input) {
await unmount(this.input);
}
this.statusBarService.removeStatusBarMessage();
}
}

View file

@ -26,7 +26,6 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => {
let mockNamingService: any;
let mockPrompt: any;
let mockStatusBarService: any;
let mockTokenService: any;
let mockEventService: any;
let abortService: AbortService;
@ -53,10 +52,6 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => {
animateTokens: vi.fn()
};
mockTokenService = {
countTokens: vi.fn().mockResolvedValue(100)
};
// Mock EventService since it extends Obsidian's Events class
mockEventService = {
trigger: vi.fn(),
@ -72,7 +67,6 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => {
RegisterSingleton(Services.AIFunctionService, mockAIFunctionService);
RegisterSingleton(Services.ConversationNamingService, mockNamingService);
RegisterSingleton(Services.IPrompt, mockPrompt);
RegisterSingleton(Services.StatusBarService, mockStatusBarService);
RegisterSingleton(Services.EventService, mockEventService);
RegisterSingleton(Services.AbortService, abortService);
@ -102,7 +96,6 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => {
it('should resolve AI provider services', () => {
const mockAI = { streamRequest: vi.fn() };
RegisterSingleton(Services.IAIClass, mockAI as any);
RegisterSingleton(Services.ITokenService, mockTokenService);
service.resolveAIProvider();
@ -125,90 +118,6 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => {
});
});
describe('setStatusBarTokens', () => {
beforeEach(() => {
// Need to resolve AI provider to initialize token service
RegisterSingleton(Services.IAIClass, { streamRequest: vi.fn() } as any);
RegisterSingleton(Services.ITokenService, mockTokenService);
service.resolveAIProvider();
});
it('should call status bar service with token counts', () => {
service.setStatusBarTokens(100, 50);
expect(mockStatusBarService.animateTokens).toHaveBeenCalledWith(100, 50);
});
it('should handle zero tokens', () => {
service.setStatusBarTokens(0, 0);
expect(mockStatusBarService.animateTokens).toHaveBeenCalledWith(0, 0);
});
it('should handle large token counts', () => {
service.setStatusBarTokens(100000, 50000);
expect(mockStatusBarService.animateTokens).toHaveBeenCalledWith(100000, 50000);
});
});
describe('updateTokenDisplay', () => {
beforeEach(() => {
// Need to resolve AI provider to initialize token service
RegisterSingleton(Services.IAIClass, { streamRequest: vi.fn() } as any);
RegisterSingleton(Services.ITokenService, mockTokenService);
service.resolveAIProvider();
});
it('should count tokens for conversation contents', async () => {
const conversation = new Conversation();
conversation.contents.push(new ConversationContent(Role.User, 'User message'));
conversation.contents.push(new ConversationContent(Role.Assistant, 'Assistant response'));
await service.updateTokenDisplay(conversation);
expect(mockTokenService.countTokens).toHaveBeenCalled();
expect(mockStatusBarService.animateTokens).toHaveBeenCalled();
});
it('should include system and user instructions in token count', async () => {
const conversation = new Conversation();
conversation.contents.push(new ConversationContent(Role.User, 'Test'));
await service.updateTokenDisplay(conversation);
expect(mockPrompt.systemInstruction).toHaveBeenCalled();
expect(mockPrompt.userInstruction).toHaveBeenCalled();
});
it('should filter out function call responses from user messages', async () => {
const conversation = new Conversation();
conversation.contents.push(new ConversationContent(Role.User, 'Regular message'));
conversation.contents.push(
new ConversationContent(Role.User, 'Function result', '', '', new Date(), false, true, 'tool-id')
);
await service.updateTokenDisplay(conversation);
expect(mockTokenService.countTokens).toHaveBeenCalled();
});
it('should handle empty conversation', async () => {
const conversation = new Conversation();
await service.updateTokenDisplay(conversation);
expect(mockStatusBarService.animateTokens).toHaveBeenCalled();
});
it('should not throw if token service not initialized', async () => {
const newService = new ChatService();
const conversation = new Conversation();
await expect(newService.updateTokenDisplay(conversation)).resolves.not.toThrow();
});
});
describe('onNameChanged callback', () => {
it('should allow setting callback function', () => {
const callback = vi.fn();
@ -327,7 +236,7 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => {
conversation.contents.push(new ConversationContent(Role.User, 'Request'));
conversation.contents.push(new ConversationContent(Role.Assistant, 'Making a function call', '', '{"name":"test"}', new Date(), true));
// Function response (already User role with isFunctionCallResponse)
conversation.contents.push(new ConversationContent(Role.User, 'Function result', 'Function result', '', new Date(), false, true, 'tool-1'));
conversation.contents.push(new ConversationContent(Role.User, 'Function result', 'Function result', '', new Date(), false, true, false, 'tool-1'));
// Assistant processes the function response
conversation.contents.push(new ConversationContent(Role.Assistant, 'Final response'));

View file

@ -1,356 +0,0 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { StatusBarService } from '../../Services/StatusBarService';
import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService';
import { Services } from '../../Services/Services';
describe('StatusBarService', () => {
let service: StatusBarService;
let mockPlugin: any;
let mockStatusBarItem: any;
let rafCallbacks: Array<(time: number) => void>;
let currentTime: number;
beforeEach(() => {
// Track RAF callbacks
rafCallbacks = [];
currentTime = 0;
// Mock requestAnimationFrame
global.requestAnimationFrame = vi.fn((callback: (time: number) => void) => {
rafCallbacks.push(callback);
return rafCallbacks.length;
});
// Mock cancelAnimationFrame
global.cancelAnimationFrame = vi.fn((handle: number) => {
if (handle > 0 && handle <= rafCallbacks.length) {
rafCallbacks[handle - 1] = () => {}; // Clear callback
}
});
// Mock performance.now
global.performance.now = vi.fn(() => currentTime);
// Mock status bar item
mockStatusBarItem = {
empty: vi.fn(),
createEl: vi.fn(),
remove: vi.fn()
};
// Mock plugin
mockPlugin = {
addStatusBarItem: vi.fn().mockReturnValue(mockStatusBarItem)
};
RegisterSingleton(Services.VaultkeeperAIPlugin, mockPlugin);
service = new StatusBarService();
});
afterEach(() => {
// Clean up service resources (cancel animations, remove DOM elements)
service.removeStatusBarMessage();
// Clear singleton registry to prevent memory leaks
DeregisterAllServices();
// Clear RAF callback references
rafCallbacks = [];
// Restore all mocks
vi.restoreAllMocks();
});
describe('Constructor and Initialization', () => {
it('should initialize with dependencies', () => {
expect(service).toBeDefined();
});
it('should not create status bar item until first use', () => {
expect(mockPlugin.addStatusBarItem).not.toHaveBeenCalled();
});
});
describe('setStatusBarMessage', () => {
it('should create status bar item on first call', () => {
service.setStatusBarMessage('Test message');
expect(mockPlugin.addStatusBarItem).toHaveBeenCalled();
expect(mockStatusBarItem.empty).toHaveBeenCalled();
expect(mockStatusBarItem.createEl).toHaveBeenCalledWith('span', { text: 'Test message' });
});
it('should reuse existing status bar item on subsequent calls', () => {
service.setStatusBarMessage('First message');
service.setStatusBarMessage('Second message');
// Should only create once
expect(mockPlugin.addStatusBarItem).toHaveBeenCalledTimes(1);
// empty() is called: createStatusBarMessage(1) + setStatusBarMessage first call(2) + second call(3) = 3 times
expect(mockStatusBarItem.empty).toHaveBeenCalledTimes(3);
expect(mockStatusBarItem.createEl).toHaveBeenCalledTimes(2);
});
it('should update message content', () => {
service.setStatusBarMessage('Message 1');
service.setStatusBarMessage('Message 2');
expect(mockStatusBarItem.createEl).toHaveBeenLastCalledWith('span', { text: 'Message 2' });
});
});
describe('removeStatusBarMessage', () => {
it('should remove status bar item', () => {
service.setStatusBarMessage('Test');
service.removeStatusBarMessage();
expect(mockStatusBarItem.remove).toHaveBeenCalled();
});
it('should cancel ongoing animation', () => {
service.animateTokens(100, 200);
expect(rafCallbacks.length).toBeGreaterThan(0);
service.removeStatusBarMessage();
expect(global.cancelAnimationFrame).toHaveBeenCalled();
});
it('should handle being called when no status bar exists', () => {
// Should not throw
expect(() => service.removeStatusBarMessage()).not.toThrow();
});
it('should clear status bar reference', () => {
service.setStatusBarMessage('Test');
service.removeStatusBarMessage();
// Next call should recreate
service.setStatusBarMessage('New message');
expect(mockPlugin.addStatusBarItem).toHaveBeenCalledTimes(2);
});
});
describe('animateTokens', () => {
it('should start animation with requestAnimationFrame', () => {
service.animateTokens(100, 200);
expect(global.requestAnimationFrame).toHaveBeenCalled();
expect(rafCallbacks.length).toBe(1);
});
it('should animate from current values to target values', () => {
// Start from 0, animate to 100 input and 200 output
service.animateTokens(100, 200);
// Run first frame at t=0
currentTime = 0;
rafCallbacks[0](currentTime);
// Should show start values
expect(mockStatusBarItem.createEl).toHaveBeenCalledWith('span', {
text: 'Input Tokens: 0 / Output Tokens: 0'
});
// Run frame at t=500 (halfway through 1000ms animation)
currentTime = 500;
rafCallbacks[1](currentTime);
// Should be roughly halfway (with easing)
const lastCall = mockStatusBarItem.createEl.mock.calls[mockStatusBarItem.createEl.mock.calls.length - 1];
const text = lastCall[1].text;
expect(text).toContain('Input Tokens:');
expect(text).toContain('Output Tokens:');
});
it('should complete animation after 1000ms', () => {
service.animateTokens(100, 200);
// Simulate animation frames until completion
currentTime = 0;
let iterations = 0;
const maxIterations = 100; // Safety limit
while (currentTime < 1000 && iterations < maxIterations) {
const callbackIndex = rafCallbacks.length - 1; // Get most recent callback
if (callbackIndex >= 0) {
rafCallbacks[callbackIndex](currentTime);
}
currentTime += 16; // ~60fps
iterations++;
}
// Run final frame at exactly 1000ms to ensure completion
currentTime = 1000;
const finalCallbackIndex = rafCallbacks.length - 1;
if (finalCallbackIndex >= 0) {
rafCallbacks[finalCallbackIndex](currentTime);
}
// Should reach target values
const lastCall = mockStatusBarItem.createEl.mock.calls[mockStatusBarItem.createEl.mock.calls.length - 1];
expect(lastCall[1].text).toBe('Input Tokens: 100 / Output Tokens: 200');
});
it('should cancel previous animation when starting new one', () => {
service.animateTokens(50, 100);
const firstAnimationFrame = rafCallbacks.length;
service.animateTokens(150, 250);
expect(global.cancelAnimationFrame).toHaveBeenCalled();
});
it('should handle zero target values', () => {
// Start with some values
(service as any).currentInputTokens = 100;
(service as any).currentOutputTokens = 200;
service.animateTokens(0, 0);
// Run to completion
currentTime = 0;
rafCallbacks[0](currentTime);
currentTime = 1000;
if (rafCallbacks.length > 1) {
rafCallbacks[rafCallbacks.length - 1](currentTime);
}
// Should animate down to zero
const lastCall = mockStatusBarItem.createEl.mock.calls[mockStatusBarItem.createEl.mock.calls.length - 1];
expect(lastCall[1].text).toContain('0');
});
it('should handle large token jumps', () => {
service.animateTokens(10000, 20000);
currentTime = 0;
rafCallbacks[0](currentTime);
// Should not throw and should format correctly
expect(mockStatusBarItem.createEl).toHaveBeenCalled();
});
it('should round token values during animation', () => {
service.animateTokens(99, 199);
currentTime = 0;
rafCallbacks[0](currentTime);
// All displayed values should be integers
mockStatusBarItem.createEl.mock.calls.forEach((call: any) => {
const text = call[1].text;
const matches = text.match(/\d+/g);
if (matches) {
matches.forEach((num: string) => {
expect(num).toBe(Math.round(parseInt(num)).toString());
});
}
});
});
it('should use ease-out cubic easing', () => {
service.animateTokens(100, 200);
// Collect values at different times
const values: number[] = [];
for (let t = 0; t <= 1000; t += 100) {
currentTime = t;
const currentIndex = rafCallbacks.length - 1;
if (currentIndex >= 0) {
rafCallbacks[currentIndex](currentTime);
}
// Extract input token value
const lastCall = mockStatusBarItem.createEl.mock.calls[mockStatusBarItem.createEl.mock.calls.length - 1];
const match = lastCall[1].text.match(/Input Tokens: (\d+)/);
if (match) {
values.push(parseInt(match[1]));
}
}
// With ease-out, changes should be larger at start than at end
if (values.length >= 3) {
const earlyChange = values[1] - values[0];
const lateChange = values[values.length - 1] - values[values.length - 2];
expect(earlyChange).toBeGreaterThan(lateChange);
}
});
});
describe('Multiple Animation Cycles', () => {
it('should handle rapid consecutive calls', () => {
service.animateTokens(50, 100);
service.animateTokens(100, 150);
service.animateTokens(150, 200);
// Should cancel previous animations
expect(global.cancelAnimationFrame).toHaveBeenCalled();
// Run the latest animation to completion
currentTime = 1000;
const latestCallback = rafCallbacks[rafCallbacks.length - 1];
if (latestCallback) {
latestCallback(currentTime);
}
const lastCall = mockStatusBarItem.createEl.mock.calls[mockStatusBarItem.createEl.mock.calls.length - 1];
expect(lastCall[1].text).toContain('150');
expect(lastCall[1].text).toContain('200');
});
it('should maintain state between animations', () => {
// First animation - run to completion
service.animateTokens(100, 200);
// Simulate animation to completion
currentTime = 0;
const startTime = currentTime;
let iterations = 0;
while (currentTime - startTime < 1000 && iterations < 100) {
const callbackIndex = rafCallbacks.length - 1;
if (callbackIndex >= 0) {
rafCallbacks[callbackIndex](currentTime);
}
currentTime += 16;
iterations++;
}
// Final frame at exactly 1000ms
currentTime = startTime + 1000;
const finalIndex = rafCallbacks.length - 1;
if (finalIndex >= 0) {
rafCallbacks[finalIndex](currentTime);
}
// Second animation should start from previous end values
rafCallbacks = []; // Reset RAF tracking
service.animateTokens(200, 400);
// Simulate second animation to completion
currentTime = performance.now();
const secondStartTime = currentTime;
iterations = 0;
while (currentTime - secondStartTime < 1000 && iterations < 100) {
const callbackIndex = rafCallbacks.length - 1;
if (callbackIndex >= 0) {
rafCallbacks[callbackIndex](currentTime);
}
currentTime += 16;
iterations++;
}
// Final frame at exactly 1000ms
currentTime = secondStartTime + 1000;
const secondFinalIndex = rafCallbacks.length - 1;
if (secondFinalIndex >= 0) {
rafCallbacks[secondFinalIndex](currentTime);
}
const lastCall = mockStatusBarItem.createEl.mock.calls[mockStatusBarItem.createEl.mock.calls.length - 1];
expect(lastCall[1].text).toBe('Input Tokens: 200 / Output Tokens: 400');
});
});
});

View file

@ -52,7 +52,6 @@
"diff2html": "^3.4.52",
"express": "^5.2.1",
"fuzzysort": "^3.1.0",
"gpt-tokenizer": "^3.4.0",
"highlight.js": "^11.11.1",
"katex": "^0.16.27",
"lowlight": "^3.3.0",