Files
automaker/apps/ui/tests/unit/components/phase-model-selector.test.tsx
gsxdsm 1c0e460dd1 Add orphaned features management routes and UI integration (#819)
* test(copilot): add edge case test for error with code field

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Changes from fix/bug-fixes-1-0

* refactor(auto-mode): enhance orphaned feature detection and improve project initialization

- Updated detectOrphanedFeatures method to accept preloaded features, reducing redundant disk reads.
- Improved project initialization by creating required directories and files in parallel for better performance.
- Adjusted planning mode handling in UI components to clarify approval requirements for different modes.
- Added refresh functionality for file editor tabs to ensure content consistency with disk state.

These changes enhance performance, maintainability, and user experience across the application.

* feat(orphaned-features): add orphaned features management routes and UI integration

- Introduced new routes for managing orphaned features, including listing, resolving, and bulk resolving.
- Updated the UI to include an Orphaned Features section in project settings and navigation.
- Enhanced the execution service to support new orphaned feature functionalities.

These changes improve the application's capability to handle orphaned features effectively, enhancing user experience and project management.

* fix: Normalize line endings and resolve stale dirty states in file editor

* chore: Update .gitignore and enhance orphaned feature handling

- Added a blank line in .gitignore for better readability.
- Introduced a hash to worktree paths in orphaned feature resolution to prevent conflicts.
- Added validation for target branch existence during orphaned feature resolution.
- Improved prompt formatting in execution service for clarity.
- Enhanced error handling in project selector for project initialization failures.
- Refactored orphaned features section to improve state management and UI responsiveness.

These changes improve code maintainability and user experience when managing orphaned features.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-27 22:14:41 -08:00

412 lines
15 KiB
TypeScript

/**
* Unit tests for PhaseModelSelector component
* Tests useShallow selector reactivity with enabledDynamicModelIds array changes
*
* Bug: Opencode model selection changes from settings aren't showing up in dropdown
* Fix: Added useShallow selector to ensure proper reactivity when enabledDynamicModelIds array changes
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useAppStore } from '@/store/app-store';
// Mock the store
vi.mock('@/store/app-store');
const mockUseAppStore = useAppStore as ReturnType<typeof vi.fn>;
/**
* Type definition for the mock store state to ensure type safety across tests
*/
interface MockStoreState {
enabledDynamicModelIds: string[];
enabledCursorModels: string[];
enabledGeminiModels: string[];
enabledCopilotModels: string[];
favoriteModels: string[];
toggleFavoriteModel: ReturnType<typeof vi.fn>;
codexModels: unknown[];
codexModelsLoading: boolean;
fetchCodexModels: ReturnType<typeof vi.fn>;
disabledProviders: string[];
claudeCompatibleProviders: string[];
defaultThinkingLevel?: string;
defaultReasoningEffort?: string;
}
/**
* Creates a mock store state with default values that can be overridden
* @param overrides - Partial state object to override defaults
* @returns A complete mock store state object
*/
function createMockStoreState(overrides: Partial<MockStoreState> = {}): MockStoreState {
return {
enabledDynamicModelIds: [],
enabledCursorModels: [],
enabledGeminiModels: [],
enabledCopilotModels: [],
favoriteModels: [],
toggleFavoriteModel: vi.fn(),
codexModels: [],
codexModelsLoading: false,
fetchCodexModels: vi.fn().mockResolvedValue([]),
disabledProviders: [],
claudeCompatibleProviders: [],
defaultThinkingLevel: undefined,
defaultReasoningEffort: undefined,
...overrides,
};
}
describe('PhaseModelSelector - useShallow Selector Behavior', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useShallow selector reactivity with enabledDynamicModelIds', () => {
it('should properly track selector call counts', () => {
// Verify that when useAppStore is called with a selector (useShallow pattern),
// it properly extracts the required state values
let _capturedSelector: ((state: MockStoreState) => Partial<MockStoreState>) | null = null;
// Mock useAppStore to capture the selector function
mockUseAppStore.mockImplementation((selector?: unknown) => {
if (typeof selector === 'function') {
_capturedSelector = selector as (state: MockStoreState) => Partial<MockStoreState>;
}
const mockState = createMockStoreState();
return typeof selector === 'function' ? selector(mockState) : mockState;
});
// Call useAppStore (simulating what PhaseModelSelector does)
const { result } = renderHook(() => useAppStore());
// Verify we got a result back (meaning the selector was applied)
expect(result).toBeDefined();
expect(typeof result).toBe('object');
// Now test that a selector function would extract enabledDynamicModelIds correctly
// This simulates the useShallow selector pattern
const testState = createMockStoreState({
enabledDynamicModelIds: ['model-1', 'model-2'],
});
// Simulate the selector function that useShallow wraps
const simulatedSelector = (state: MockStoreState) => ({
enabledDynamicModelIds: state.enabledDynamicModelIds,
enabledCursorModels: state.enabledCursorModels,
enabledGeminiModels: state.enabledGeminiModels,
enabledCopilotModels: state.enabledCopilotModels,
});
const selectorResult = simulatedSelector(testState);
expect(selectorResult).toHaveProperty('enabledDynamicModelIds');
expect(selectorResult.enabledDynamicModelIds).toEqual(['model-1', 'model-2']);
});
it('should detect changes when enabledDynamicModelIds array reference changes', () => {
// Test that useShallow properly handles array reference changes
// This simulates what happens when toggleDynamicModel is called
const results: Partial<MockStoreState>[] = [];
mockUseAppStore.mockImplementation((selector?: unknown) => {
const mockState = createMockStoreState({
enabledDynamicModelIds: ['model-1'],
});
const result = typeof selector === 'function' ? selector(mockState) : mockState;
results.push(result);
return result;
});
// First call
renderHook(() => useAppStore());
const firstCallResult = results[0];
expect(firstCallResult?.enabledDynamicModelIds).toEqual(['model-1']);
// Simulate store update with new array reference
mockUseAppStore.mockImplementation((selector?: unknown) => {
const mockState = createMockStoreState({
enabledDynamicModelIds: ['model-1', 'model-2'], // New array reference
});
const result = typeof selector === 'function' ? selector(mockState) : mockState;
results.push(result);
return result;
});
// Second call with updated state
renderHook(() => useAppStore());
const secondCallResult = results[1];
expect(secondCallResult?.enabledDynamicModelIds).toEqual(['model-1', 'model-2']);
// Verify that the arrays have different references (useShallow handles this)
expect(firstCallResult?.enabledDynamicModelIds).not.toBe(
secondCallResult?.enabledDynamicModelIds
);
});
});
describe('Store state integration with enabledDynamicModelIds', () => {
it('should return all required state values from the selector', () => {
mockUseAppStore.mockImplementation((selector?: unknown) => {
const mockState = createMockStoreState({
enabledCursorModels: ['cursor-small'],
enabledGeminiModels: ['gemini-flash'],
enabledCopilotModels: ['gpt-4o'],
enabledDynamicModelIds: ['custom-model-1'],
defaultThinkingLevel: 'medium',
defaultReasoningEffort: 'medium',
});
return typeof selector === 'function' ? selector(mockState) : mockState;
});
const result = renderHook(() => useAppStore()).result.current;
// Verify all required properties are present
expect(result).toHaveProperty('enabledCursorModels');
expect(result).toHaveProperty('enabledGeminiModels');
expect(result).toHaveProperty('enabledCopilotModels');
expect(result).toHaveProperty('favoriteModels');
expect(result).toHaveProperty('toggleFavoriteModel');
expect(result).toHaveProperty('codexModels');
expect(result).toHaveProperty('codexModelsLoading');
expect(result).toHaveProperty('fetchCodexModels');
expect(result).toHaveProperty('enabledDynamicModelIds');
expect(result).toHaveProperty('disabledProviders');
expect(result).toHaveProperty('claudeCompatibleProviders');
expect(result).toHaveProperty('defaultThinkingLevel');
expect(result).toHaveProperty('defaultReasoningEffort');
// Verify values
expect(result.enabledCursorModels).toEqual(['cursor-small']);
expect(result.enabledGeminiModels).toEqual(['gemini-flash']);
expect(result.enabledCopilotModels).toEqual(['gpt-4o']);
expect(result.enabledDynamicModelIds).toEqual(['custom-model-1']);
});
it('should handle empty enabledDynamicModelIds array', () => {
mockUseAppStore.mockImplementation((selector?: unknown) => {
const mockState = createMockStoreState({
enabledDynamicModelIds: [],
});
return typeof selector === 'function' ? selector(mockState) : mockState;
});
const result = renderHook(() => useAppStore()).result.current;
expect(result.enabledDynamicModelIds).toEqual([]);
expect(Array.isArray(result.enabledDynamicModelIds)).toBe(true);
});
});
describe('Array reference changes with useShallow', () => {
it('should detect changes when array content changes', () => {
const referenceComparisons: { array: string[]; isArray: boolean; length: number }[] = [];
mockUseAppStore.mockImplementation((selector?: unknown) => {
const mockState = createMockStoreState({
enabledDynamicModelIds: ['model-1', 'model-2'],
});
const result = typeof selector === 'function' ? selector(mockState) : mockState;
referenceComparisons.push({
array: result.enabledDynamicModelIds,
isArray: Array.isArray(result.enabledDynamicModelIds),
length: result.enabledDynamicModelIds.length,
});
return result;
});
// First call
renderHook(() => useAppStore());
// Update to new array with different length
mockUseAppStore.mockImplementation((selector?: unknown) => {
const mockState = createMockStoreState({
enabledDynamicModelIds: ['model-1', 'model-2', 'model-3'], // New array with additional item
});
const result = typeof selector === 'function' ? selector(mockState) : mockState;
referenceComparisons.push({
array: result.enabledDynamicModelIds,
isArray: Array.isArray(result.enabledDynamicModelIds),
length: result.enabledDynamicModelIds.length,
});
return result;
});
// Second call
renderHook(() => useAppStore());
// Verify both calls produced arrays
expect(referenceComparisons[0].isArray).toBe(true);
expect(referenceComparisons[1].isArray).toBe(true);
// Verify the length changed (new array reference)
expect(referenceComparisons[0].length).toBe(2);
expect(referenceComparisons[1].length).toBe(3);
// Verify different array references
expect(referenceComparisons[0].array).not.toBe(referenceComparisons[1].array);
});
it('should handle array removal correctly', () => {
const snapshots: string[][] = [];
mockUseAppStore.mockImplementation((selector?: unknown) => {
const mockState = createMockStoreState({
enabledDynamicModelIds: ['model-1', 'model-2', 'model-3'],
});
const result = typeof selector === 'function' ? selector(mockState) : mockState;
snapshots.push([...result.enabledDynamicModelIds]);
return result;
});
// Initial state with 3 models
renderHook(() => useAppStore());
expect(snapshots[0]).toEqual(['model-1', 'model-2', 'model-3']);
// Remove one model (simulate user toggling off)
mockUseAppStore.mockImplementation((selector?: unknown) => {
const mockState = createMockStoreState({
enabledDynamicModelIds: ['model-1', 'model-3'], // model-2 removed
});
const result = typeof selector === 'function' ? selector(mockState) : mockState;
snapshots.push([...result.enabledDynamicModelIds]);
return result;
});
// Updated state
renderHook(() => useAppStore());
expect(snapshots[1]).toEqual(['model-1', 'model-3']);
// Verify different array references
expect(snapshots[0]).not.toBe(snapshots[1]);
});
});
describe('Code contract verification', () => {
it('should verify useShallow import is present', () => {
// Read the component file and verify useShallow is imported
const componentPath = path.resolve(
__dirname,
'../../../src/components/views/settings-view/model-defaults/phase-model-selector.tsx'
);
const componentCode = fs.readFileSync(componentPath, 'utf-8');
// Verify the fix is in place
expect(componentCode).toMatch(/import.*useShallow.*from.*zustand\/react\/shallow/);
});
it('should verify useAppStore call uses useShallow', () => {
const componentPath = path.resolve(
__dirname,
'../../../src/components/views/settings-view/model-defaults/phase-model-selector.tsx'
);
const componentCode = fs.readFileSync(componentPath, 'utf-8');
// Look for the useAppStore pattern with useShallow
// The pattern should be: useAppStore(useShallow((state) => ({ ... })))
expect(componentCode).toMatch(/useAppStore\(\s*useShallow\(/);
});
});
});
describe('PhaseModelSelector - enabledDynamicModelIds filtering logic', () => {
describe('Array filtering behavior', () => {
it('should filter dynamic models based on enabledDynamicModelIds', () => {
// This test verifies the filtering logic concept
// The actual filtering happens in the useMemo within PhaseModelSelector
const dynamicOpencodeModels = [
{
id: 'custom-model-1',
name: 'Custom Model 1',
description: 'First',
tier: 'basic',
maxTokens: 200000,
},
{
id: 'custom-model-2',
name: 'Custom Model 2',
description: 'Second',
tier: 'premium',
maxTokens: 200000,
},
{
id: 'custom-model-3',
name: 'Custom Model 3',
description: 'Third',
tier: 'basic',
maxTokens: 200000,
},
];
const enabledDynamicModelIds = ['custom-model-1', 'custom-model-3'];
// Simulate the filter logic from the component
const filteredModels = dynamicOpencodeModels.filter((model) =>
enabledDynamicModelIds.includes(model.id)
);
expect(filteredModels).toHaveLength(2);
expect(filteredModels.map((m) => m.id)).toEqual(['custom-model-1', 'custom-model-3']);
});
it('should return empty array when no dynamic models are enabled', () => {
const dynamicOpencodeModels = [
{
id: 'custom-model-1',
name: 'Custom Model 1',
description: 'First',
tier: 'basic',
maxTokens: 200000,
},
];
const enabledDynamicModelIds: string[] = [];
const filteredModels = dynamicOpencodeModels.filter((model) =>
enabledDynamicModelIds.includes(model.id)
);
expect(filteredModels).toHaveLength(0);
});
it('should return all models when all are enabled', () => {
const dynamicOpencodeModels = [
{
id: 'custom-model-1',
name: 'Custom Model 1',
description: 'First',
tier: 'basic',
maxTokens: 200000,
},
{
id: 'custom-model-2',
name: 'Custom Model 2',
description: 'Second',
tier: 'premium',
maxTokens: 200000,
},
];
const enabledDynamicModelIds = ['custom-model-1', 'custom-model-2'];
const filteredModels = dynamicOpencodeModels.filter((model) =>
enabledDynamicModelIds.includes(model.id)
);
expect(filteredModels).toHaveLength(2);
});
});
});