481 lines
16 KiB
JavaScript
481 lines
16 KiB
JavaScript
/**
|
|
* Advanced Tests for UI Component - Question Handling
|
|
* Coverage: Prompt behavior, caching, conditional display, user interactions
|
|
* File: test/unit/ui-prompt-handler-advanced.test.js
|
|
*/
|
|
|
|
const fs = require('fs-extra');
|
|
const path = require('node:path');
|
|
const inquirer = require('inquirer');
|
|
|
|
describe('UI PromptHandler - Advanced Scenarios', () => {
|
|
let tempDir;
|
|
let mockUI;
|
|
|
|
beforeEach(async () => {
|
|
tempDir = path.join(__dirname, '../fixtures/temp', `ui-${Date.now()}`);
|
|
await fs.ensureDir(tempDir);
|
|
|
|
// Mock UI module
|
|
mockUI = {
|
|
prompt: jest.fn(),
|
|
askInstallType: jest.fn(),
|
|
askDocOrganization: jest.fn(),
|
|
shouldSkipQuestion: jest.fn(),
|
|
};
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (tempDir) {
|
|
await fs.remove(tempDir);
|
|
}
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
describe('Question Skipping Logic', () => {
|
|
test('should skip questions when configuration exists and not fresh install', () => {
|
|
const shouldSkip = (isUpdate, hasConfig) => {
|
|
return isUpdate && hasConfig;
|
|
};
|
|
|
|
expect(shouldSkip(true, true)).toBe(true);
|
|
expect(shouldSkip(true, false)).toBe(false);
|
|
expect(shouldSkip(false, true)).toBe(false);
|
|
expect(shouldSkip(false, false)).toBe(false);
|
|
});
|
|
|
|
test('should ask questions on fresh install regardless of config', () => {
|
|
const shouldAsk = (isFreshInstall, hasConfig) => {
|
|
return isFreshInstall || !hasConfig;
|
|
};
|
|
|
|
expect(shouldAsk(true, true)).toBe(true);
|
|
expect(shouldAsk(true, false)).toBe(true);
|
|
expect(shouldAsk(false, true)).toBe(false);
|
|
expect(shouldAsk(false, false)).toBe(true);
|
|
});
|
|
|
|
test('should determine skip decision based on multiple criteria', () => {
|
|
const determineSkip = (installMode, hasConfig, forceAsk = false) => {
|
|
if (forceAsk) return false;
|
|
return installMode === 'update' && hasConfig;
|
|
};
|
|
|
|
expect(determineSkip('update', true)).toBe(true);
|
|
expect(determineSkip('update', true, true)).toBe(false);
|
|
expect(determineSkip('fresh', true)).toBe(false);
|
|
expect(determineSkip('reinstall', true)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Cached Answer Retrieval', () => {
|
|
test('should retrieve cached answer for question', () => {
|
|
const cache = {
|
|
install_type: 'full',
|
|
doc_organization: 'hierarchical',
|
|
};
|
|
|
|
const getCachedAnswer = (key, defaultValue) => {
|
|
return cache[key] === undefined ? defaultValue : cache[key];
|
|
};
|
|
|
|
expect(getCachedAnswer('install_type')).toBe('full');
|
|
expect(getCachedAnswer('doc_organization')).toBe('hierarchical');
|
|
expect(getCachedAnswer('missing_key')).toBeUndefined();
|
|
expect(getCachedAnswer('missing_key', 'default')).toBe('default');
|
|
});
|
|
|
|
test('should handle null and undefined in cache', () => {
|
|
const cache = {
|
|
explicit_null: null,
|
|
explicit_undefined: undefined,
|
|
missing: undefined,
|
|
};
|
|
|
|
const getValue = (key, defaultValue = 'default') => {
|
|
// Return cached value only if key exists AND value is not null/undefined
|
|
if (key in cache && cache[key] !== null && cache[key] !== undefined) {
|
|
return cache[key];
|
|
}
|
|
return defaultValue;
|
|
};
|
|
|
|
expect(getValue('explicit_null')).toBe('default');
|
|
expect(getValue('explicit_undefined')).toBe('default');
|
|
expect(getValue('missing')).toBe('default');
|
|
expect(getValue('exists') === 'default').toBe(true);
|
|
});
|
|
|
|
test('should handle complex cached values', () => {
|
|
const cache = {
|
|
modules: ['bmb', 'bmm', 'cis'],
|
|
ides: ['claude-code', 'github-copilot'],
|
|
config: {
|
|
nested: {
|
|
value: 'test',
|
|
},
|
|
},
|
|
};
|
|
|
|
const getArrayValue = (key) => cache[key] || [];
|
|
const getNestedValue = (key, path, defaultValue) => {
|
|
const obj = cache[key];
|
|
if (!obj) return defaultValue;
|
|
const keys = path.split('.');
|
|
let current = obj;
|
|
for (const k of keys) {
|
|
current = current?.[k];
|
|
}
|
|
return current ?? defaultValue;
|
|
};
|
|
|
|
expect(getArrayValue('modules')).toHaveLength(3);
|
|
expect(getArrayValue('missing')).toEqual([]);
|
|
expect(getNestedValue('config', 'nested.value')).toBe('test');
|
|
expect(getNestedValue('config', 'missing.path', 'default')).toBe('default');
|
|
});
|
|
});
|
|
|
|
describe('Question Type Handling', () => {
|
|
test('should handle boolean questions correctly', () => {
|
|
const handleBooleanAnswer = (answer) => {
|
|
return answer === true || answer === 'yes' || answer === 'y';
|
|
};
|
|
|
|
expect(handleBooleanAnswer(true)).toBe(true);
|
|
expect(handleBooleanAnswer('yes')).toBe(true);
|
|
expect(handleBooleanAnswer(false)).toBe(false);
|
|
expect(handleBooleanAnswer('no')).toBe(false);
|
|
});
|
|
|
|
test('should handle multiple choice questions', () => {
|
|
const choices = new Set(['option1', 'option2', 'option3']);
|
|
const validateChoice = (answer) => {
|
|
return choices.has(answer);
|
|
};
|
|
|
|
expect(validateChoice('option1')).toBe(true);
|
|
expect(validateChoice('option4')).toBe(false);
|
|
});
|
|
|
|
test('should handle array selection questions', () => {
|
|
const availableItems = new Set(['item1', 'item2', 'item3', 'item4']);
|
|
const validateSelection = (answers) => {
|
|
return Array.isArray(answers) && answers.every((a) => availableItems.has(a));
|
|
};
|
|
|
|
expect(validateSelection(['item1', 'item3'])).toBe(true);
|
|
expect(validateSelection(['item1', 'invalid'])).toBe(false);
|
|
expect(validateSelection('not-array')).toBe(false);
|
|
});
|
|
|
|
test('should handle string input questions', () => {
|
|
const validateString = (answer, minLength = 1, maxLength = 255) => {
|
|
return typeof answer === 'string' && answer.length >= minLength && answer.length <= maxLength;
|
|
};
|
|
|
|
expect(validateString('valid')).toBe(true);
|
|
expect(validateString('')).toBe(false);
|
|
expect(validateString('a'.repeat(300))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Prompt Display Conditions', () => {
|
|
test('should determine when to show tool selection prompt', () => {
|
|
const shouldShowToolSelection = (modules, installMode) => {
|
|
if (!modules || modules.length === 0) return false;
|
|
return installMode === 'fresh' || installMode === 'update';
|
|
};
|
|
|
|
expect(shouldShowToolSelection(['bmb'], 'fresh')).toBe(true);
|
|
expect(shouldShowToolSelection(['bmb'], 'update')).toBe(true);
|
|
expect(shouldShowToolSelection([], 'fresh')).toBe(false);
|
|
expect(shouldShowToolSelection(null, 'fresh')).toBe(false);
|
|
});
|
|
|
|
test('should determine when to show configuration questions', () => {
|
|
const shouldShowConfig = (installMode, previousConfig) => {
|
|
if (installMode === 'fresh') return true; // Always ask on fresh
|
|
if (installMode === 'update' && !previousConfig) return true; // Ask if no config
|
|
return false; // Skip on update with config
|
|
};
|
|
|
|
expect(shouldShowConfig('fresh', { install_type: 'full' })).toBe(true);
|
|
expect(shouldShowConfig('update', null)).toBe(true);
|
|
expect(shouldShowConfig('update', { install_type: 'full' })).toBe(false);
|
|
expect(shouldShowConfig('reinstall', null)).toBe(false);
|
|
});
|
|
|
|
test('should handle conditional IDE prompts', () => {
|
|
const ides = ['claude-code', 'github-copilot', 'roo'];
|
|
const previousIdes = ['claude-code'];
|
|
|
|
const getNewIDEs = (selected, previous) => {
|
|
return selected.filter((ide) => !previous.includes(ide));
|
|
};
|
|
|
|
const newIDEs = getNewIDEs(ides, previousIdes);
|
|
expect(newIDEs).toContain('github-copilot');
|
|
expect(newIDEs).toContain('roo');
|
|
expect(newIDEs).not.toContain('claude-code');
|
|
});
|
|
});
|
|
|
|
describe('Default Value Handling', () => {
|
|
test('should provide sensible defaults for config questions', () => {
|
|
const defaults = {
|
|
install_type: 'full',
|
|
doc_organization: 'hierarchical',
|
|
prd_sharding: 'auto',
|
|
architecture_sharding: 'auto',
|
|
};
|
|
|
|
for (const [key, value] of Object.entries(defaults)) {
|
|
expect(value).toBeTruthy();
|
|
}
|
|
});
|
|
|
|
test('should use cached values as defaults', () => {
|
|
const cachedConfig = {
|
|
install_type: 'minimal',
|
|
doc_organization: 'flat',
|
|
};
|
|
|
|
const getDefault = (key, defaults) => {
|
|
return cachedConfig[key] || defaults[key];
|
|
};
|
|
|
|
expect(getDefault('install_type', { install_type: 'full' })).toBe('minimal');
|
|
expect(getDefault('doc_organization', { doc_organization: 'hierarchical' })).toBe('flat');
|
|
expect(getDefault('prd_sharding', { prd_sharding: 'auto' })).toBe('auto');
|
|
});
|
|
|
|
test('should handle missing defaults gracefully', () => {
|
|
const getDefault = (key, defaults, fallback = null) => {
|
|
return defaults?.[key] ?? fallback;
|
|
};
|
|
|
|
expect(getDefault('key1', { key1: 'value' })).toBe('value');
|
|
expect(getDefault('missing', { key1: 'value' })).toBeNull();
|
|
expect(getDefault('missing', { key1: 'value' }, 'fallback')).toBe('fallback');
|
|
expect(getDefault('key', null, 'fallback')).toBe('fallback');
|
|
});
|
|
});
|
|
|
|
describe('User Input Validation', () => {
|
|
test('should validate install type options', () => {
|
|
const validTypes = new Set(['full', 'minimal', 'custom']);
|
|
const validate = (type) => validTypes.has(type);
|
|
|
|
expect(validate('full')).toBe(true);
|
|
expect(validate('minimal')).toBe(true);
|
|
expect(validate('invalid')).toBe(false);
|
|
});
|
|
|
|
test('should validate doc organization options', () => {
|
|
const validOptions = new Set(['hierarchical', 'flat', 'modular']);
|
|
const validate = (option) => validOptions.has(option);
|
|
|
|
expect(validate('hierarchical')).toBe(true);
|
|
expect(validate('flat')).toBe(true);
|
|
expect(validate('invalid')).toBe(false);
|
|
});
|
|
|
|
test('should validate IDE selections', () => {
|
|
const availableIDEs = new Set(['claude-code', 'github-copilot', 'cline', 'roo', 'auggie', 'codex', 'qwen', 'gemini']);
|
|
|
|
const validate = (selections) => {
|
|
return Array.isArray(selections) && selections.every((ide) => availableIDEs.has(ide));
|
|
};
|
|
|
|
expect(validate(['claude-code', 'roo'])).toBe(true);
|
|
expect(validate(['claude-code', 'invalid-ide'])).toBe(false);
|
|
expect(validate('not-array')).toBe(false);
|
|
});
|
|
|
|
test('should validate module selections', () => {
|
|
const availableModules = new Set(['bmb', 'bmm', 'cis']);
|
|
|
|
const validate = (selections) => {
|
|
return Array.isArray(selections) && selections.every((mod) => availableModules.has(mod));
|
|
};
|
|
|
|
expect(validate(['bmb', 'bmm'])).toBe(true);
|
|
expect(validate(['bmb', 'invalid'])).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('State Consistency', () => {
|
|
test('should maintain consistent state across questions', () => {
|
|
const state = {
|
|
installMode: 'update',
|
|
modules: ['bmb', 'bmm'],
|
|
ides: ['claude-code'],
|
|
config: {
|
|
install_type: 'full',
|
|
},
|
|
};
|
|
|
|
const isValidState = (st) => {
|
|
return st.installMode && Array.isArray(st.modules) && Array.isArray(st.ides) && st.config !== null;
|
|
};
|
|
|
|
expect(isValidState(state)).toBe(true);
|
|
});
|
|
|
|
test('should validate state transitions', () => {
|
|
const transitions = {
|
|
fresh: ['update', 'reinstall'],
|
|
update: ['update', 'reinstall'],
|
|
reinstall: ['fresh', 'update', 'reinstall'],
|
|
};
|
|
|
|
const canTransition = (from, to) => {
|
|
return transitions[from]?.includes(to) ?? false;
|
|
};
|
|
|
|
expect(canTransition('fresh', 'update')).toBe(true);
|
|
expect(canTransition('fresh', 'fresh')).toBe(false);
|
|
expect(canTransition('update', 'update')).toBe(true);
|
|
});
|
|
|
|
test('should handle incomplete state', () => {
|
|
const completeState = (partialState, defaults) => {
|
|
return { ...defaults, ...partialState };
|
|
};
|
|
|
|
const defaults = {
|
|
installMode: 'fresh',
|
|
modules: [],
|
|
ides: [],
|
|
config: {},
|
|
};
|
|
|
|
const partial = { modules: ['bmb'] };
|
|
const complete = completeState(partial, defaults);
|
|
|
|
expect(complete.modules).toEqual(['bmb']);
|
|
expect(complete.installMode).toBe('fresh');
|
|
expect(complete.ides).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('Error Messages and Feedback', () => {
|
|
test('should provide helpful error messages for invalid inputs', () => {
|
|
const getErrorMessage = (errorType, context = {}) => {
|
|
const messages = {
|
|
invalid_choice: `"${context.value}" is not a valid option. Valid options: ${(context.options || []).join(', ')}`,
|
|
missing_required: `This field is required`,
|
|
invalid_format: `Invalid format provided`,
|
|
};
|
|
return messages[errorType] || 'An error occurred';
|
|
};
|
|
|
|
const error1 = getErrorMessage('invalid_choice', {
|
|
value: 'invalid',
|
|
options: ['a', 'b', 'c'],
|
|
});
|
|
expect(error1).toContain('invalid');
|
|
|
|
const error2 = getErrorMessage('missing_required');
|
|
expect(error2).toContain('required');
|
|
});
|
|
|
|
test('should provide context-aware messages', () => {
|
|
const getMessage = (installMode, context = {}) => {
|
|
if (installMode === 'update' && context.hasConfig) {
|
|
return 'Using saved configuration...';
|
|
}
|
|
if (installMode === 'fresh') {
|
|
return 'Setting up new installation...';
|
|
}
|
|
return 'Processing...';
|
|
};
|
|
|
|
expect(getMessage('update', { hasConfig: true })).toContain('saved');
|
|
expect(getMessage('fresh')).toContain('new');
|
|
expect(getMessage('reinstall')).toContain('Processing');
|
|
});
|
|
});
|
|
|
|
describe('Performance Considerations', () => {
|
|
test('should handle large option lists efficiently', () => {
|
|
const largeList = Array.from({ length: 1000 }, (_, i) => `option-${i}`);
|
|
|
|
const filterOptions = (list, searchTerm) => {
|
|
return list.filter((opt) => opt.includes(searchTerm));
|
|
};
|
|
|
|
const start = Date.now();
|
|
const result = filterOptions(largeList, 'option-500');
|
|
const time = Date.now() - start;
|
|
|
|
expect(result).toContain('option-500');
|
|
expect(time).toBeLessThan(100);
|
|
});
|
|
|
|
test('should cache expensive computations', () => {
|
|
let computeCount = 0;
|
|
|
|
const memoizeExpensiveComputation = () => {
|
|
const cache = {};
|
|
return (key) => {
|
|
if (key in cache) return cache[key];
|
|
computeCount++;
|
|
cache[key] = `result-${key}`;
|
|
return cache[key];
|
|
};
|
|
};
|
|
|
|
const compute = memoizeExpensiveComputation();
|
|
|
|
compute('key1');
|
|
compute('key1');
|
|
compute('key1');
|
|
|
|
expect(computeCount).toBe(1); // Only computed once
|
|
});
|
|
});
|
|
|
|
describe('Edge Cases in Prompt Handling', () => {
|
|
test('should handle empty arrays in selections', () => {
|
|
const processSelection = (selection) => {
|
|
return Array.isArray(selection) && selection.length > 0 ? selection : null;
|
|
};
|
|
|
|
expect(processSelection([])).toBeNull();
|
|
expect(processSelection(['item'])).toContain('item');
|
|
expect(processSelection(null)).toBeNull();
|
|
});
|
|
|
|
test('should handle whitespace in string inputs', () => {
|
|
const trimAndValidate = (input) => {
|
|
const trimmed = typeof input === 'string' ? input.trim() : input;
|
|
return trimmed && trimmed.length > 0 ? trimmed : null;
|
|
};
|
|
|
|
expect(trimAndValidate(' text ')).toBe('text');
|
|
expect(trimAndValidate(' ')).toBeNull();
|
|
expect(trimAndValidate('')).toBeNull();
|
|
});
|
|
|
|
test('should handle duplicate selections', () => {
|
|
const removeDuplicates = (array) => {
|
|
return [...new Set(array)];
|
|
};
|
|
|
|
expect(removeDuplicates(['a', 'b', 'a', 'c', 'b'])).toHaveLength(3);
|
|
expect(removeDuplicates(['a', 'b', 'c'])).toHaveLength(3);
|
|
});
|
|
|
|
test('should handle special characters in values', () => {
|
|
const values = ['item-1', 'item_2', 'item.3', 'item@4', 'item/5'];
|
|
|
|
for (const val of values) {
|
|
expect(val).toBeDefined();
|
|
expect(typeof val).toBe('string');
|
|
}
|
|
});
|
|
});
|
|
});
|