BMAD-METHOD/.patch/477/test/unit/ui-prompt-handler-advanced....

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');
}
});
});
});