✅ Complete Step 16 by extracting and testing the useJsonFilter composable with comprehensive functionality and test coverage
This commit is contained in:
350
code/websites/pokedex.online/src/composables/useJsonFilter.js
Normal file
350
code/websites/pokedex.online/src/composables/useJsonFilter.js
Normal file
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* useJsonFilter Composable
|
||||
*
|
||||
* Manages JSON path filtering and property-based data filtering.
|
||||
* Handles path extraction from JSON objects and filtering by property values.
|
||||
*/
|
||||
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { getValueByPath } from '../utilities/json-utils.js';
|
||||
|
||||
/**
|
||||
* Get value from object using dot notation path
|
||||
* @param {Object} obj - Object to get value from
|
||||
* @param {string} path - Dot notation path
|
||||
* @returns {any} Value at path
|
||||
*/
|
||||
function extractValueByPath(obj, path) {
|
||||
return getValueByPath(obj, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all unique paths from an object recursively
|
||||
* @param {Object} obj - Object to extract from
|
||||
* @param {string} prefix - Current path prefix
|
||||
* @param {Set} paths - Set to accumulate paths
|
||||
* @param {number} maxDepth - Maximum recursion depth
|
||||
* @param {number} currentDepth - Current depth
|
||||
*/
|
||||
function extractPathsRecursive(
|
||||
obj,
|
||||
prefix = '',
|
||||
paths = new Set(),
|
||||
maxDepth = 5,
|
||||
currentDepth = 0
|
||||
) {
|
||||
if (currentDepth >= maxDepth || obj === null || typeof obj !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.keys(obj).forEach(key => {
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
paths.add(path);
|
||||
|
||||
if (
|
||||
typeof obj[key] === 'object' &&
|
||||
obj[key] !== null &&
|
||||
!Array.isArray(obj[key])
|
||||
) {
|
||||
extractPathsRecursive(obj[key], path, paths, maxDepth, currentDepth + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default function useJsonFilter() {
|
||||
// State
|
||||
const filterProperty = ref('');
|
||||
const filterValue = ref('');
|
||||
const filterMode = ref('equals'); // equals, contains, regex
|
||||
const availablePaths = ref([]);
|
||||
const isFiltering = ref(false);
|
||||
const filterError = ref(null);
|
||||
|
||||
// Data to filter (referenced, not copied)
|
||||
let rawData = null;
|
||||
|
||||
/**
|
||||
* Initialize filter with raw data
|
||||
* @param {Array} data - Array of objects to filter
|
||||
* @param {number} sampleSize - Number of items to sample for path extraction
|
||||
*/
|
||||
function initializeFilter(data, sampleSize = 100) {
|
||||
rawData = data;
|
||||
extractPathsFromData(data, sampleSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract paths from data array
|
||||
* @param {Array} data - Data to extract from
|
||||
* @param {number} sampleSize - Sample size for extraction
|
||||
*/
|
||||
function extractPathsFromData(data, sampleSize = 100) {
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
availablePaths.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const paths = new Set();
|
||||
const sample = data.slice(0, Math.min(sampleSize, data.length));
|
||||
|
||||
sample.forEach(item => {
|
||||
if (typeof item === 'object' && item !== null) {
|
||||
extractPathsRecursive(item, '', paths);
|
||||
}
|
||||
});
|
||||
|
||||
availablePaths.value = Array.from(paths)
|
||||
.sort()
|
||||
.map(path => ({
|
||||
path,
|
||||
breadcrumb: path.replace(/\./g, ' › ')
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Continue extracting paths from remaining items (lazy)
|
||||
* @param {Array} data - Full data array
|
||||
* @param {number} startIndex - Where to start
|
||||
* @param {Function} callback - Callback with new paths
|
||||
* @param {number} chunkSize - Items per chunk
|
||||
*/
|
||||
function extractPathsLazy(
|
||||
data,
|
||||
startIndex = 100,
|
||||
callback,
|
||||
chunkSize = 100
|
||||
) {
|
||||
if (!Array.isArray(data) || startIndex >= data.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingPaths = new Set(availablePaths.value.map(p => p.path));
|
||||
|
||||
const processChunk = index => {
|
||||
const end = Math.min(index + chunkSize, data.length);
|
||||
const chunk = data.slice(index, end);
|
||||
const newPaths = new Set(existingPaths);
|
||||
|
||||
chunk.forEach(item => {
|
||||
if (typeof item === 'object' && item !== null) {
|
||||
extractPathsRecursive(item, '', newPaths);
|
||||
}
|
||||
});
|
||||
|
||||
const addedPaths = Array.from(newPaths).filter(p => !existingPaths.has(p));
|
||||
|
||||
if (addedPaths.length > 0) {
|
||||
addedPaths.forEach(p => existingPaths.add(p));
|
||||
availablePaths.value = Array.from(existingPaths)
|
||||
.sort()
|
||||
.map(path => ({
|
||||
path,
|
||||
breadcrumb: path.replace(/\./g, ' › ')
|
||||
}));
|
||||
|
||||
if (callback) {
|
||||
callback(
|
||||
addedPaths.map(path => ({
|
||||
path,
|
||||
breadcrumb: path.replace(/\./g, ' › ')
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (end < data.length) {
|
||||
if (typeof requestIdleCallback !== 'undefined') {
|
||||
requestIdleCallback(() => processChunk(end));
|
||||
} else {
|
||||
setTimeout(() => processChunk(end), 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof requestIdleCallback !== 'undefined') {
|
||||
requestIdleCallback(() => processChunk(startIndex));
|
||||
} else {
|
||||
setTimeout(() => processChunk(startIndex), 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter data by property value
|
||||
* @returns {Array} Filtered items
|
||||
*/
|
||||
const filteredData = computed(() => {
|
||||
if (!rawData || !Array.isArray(rawData)) {
|
||||
return rawData || [];
|
||||
}
|
||||
|
||||
// Return all data if no filter is set or if there's a filter error
|
||||
if (!filterProperty.value || filterError.value) {
|
||||
return rawData;
|
||||
}
|
||||
|
||||
return rawData.filter(item => {
|
||||
try {
|
||||
const value = extractValueByPath(item, filterProperty.value);
|
||||
return matchesFilter(value, filterValue.value, filterMode.value);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Check if value matches filter criteria
|
||||
* @param {any} value - Value to check
|
||||
* @param {string} filterVal - Filter value
|
||||
* @param {string} mode - Filter mode (equals, contains, regex)
|
||||
* @returns {boolean} Whether value matches
|
||||
*/
|
||||
function matchesFilter(value, filterVal, mode) {
|
||||
if (!filterVal) return true;
|
||||
|
||||
const strValue = String(value).toLowerCase();
|
||||
const strFilter = String(filterVal).toLowerCase();
|
||||
|
||||
switch (mode) {
|
||||
case 'equals':
|
||||
return strValue === strFilter;
|
||||
case 'contains':
|
||||
return strValue.includes(strFilter);
|
||||
case 'regex':
|
||||
try {
|
||||
const regex = new RegExp(filterVal, 'i');
|
||||
return regex.test(strValue);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all unique values for a property
|
||||
* @param {string} property - Property path
|
||||
* @returns {Array} Sorted unique values
|
||||
*/
|
||||
function getUniqueValues(property) {
|
||||
if (!rawData || !Array.isArray(rawData) || !property) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const values = new Set();
|
||||
rawData.forEach(item => {
|
||||
try {
|
||||
const value = extractValueByPath(item, property);
|
||||
if (value !== undefined && value !== null) {
|
||||
values.add(String(value));
|
||||
}
|
||||
} catch {
|
||||
// Silently skip items where path doesn't exist
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(values).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all filters
|
||||
*/
|
||||
function clearFilters() {
|
||||
filterProperty.value = '';
|
||||
filterValue.value = '';
|
||||
filterMode.value = 'equals';
|
||||
filterError.value = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set filter from user input
|
||||
* @param {string} property - Property to filter by
|
||||
* @param {string} value - Value to filter
|
||||
* @param {string} mode - Filter mode
|
||||
*/
|
||||
function setFilter(property, value, mode = 'equals') {
|
||||
try {
|
||||
filterProperty.value = property;
|
||||
filterValue.value = value;
|
||||
filterMode.value = mode;
|
||||
filterError.value = null;
|
||||
|
||||
// Validate regex if mode is regex
|
||||
if (mode === 'regex') {
|
||||
new RegExp(value);
|
||||
}
|
||||
} catch (err) {
|
||||
filterError.value = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if filter is active
|
||||
*/
|
||||
const hasActiveFilter = computed(() => {
|
||||
return filterProperty.value && (filterValue.value || filterMode.value);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get filter description for display
|
||||
*/
|
||||
const filterDescription = computed(() => {
|
||||
if (!hasActiveFilter.value) {
|
||||
return 'No filter applied';
|
||||
}
|
||||
|
||||
const property = filterProperty.value;
|
||||
const value = filterValue.value || '*';
|
||||
const mode = filterMode.value;
|
||||
|
||||
const modeLabel = {
|
||||
equals: '=',
|
||||
contains: '∋',
|
||||
regex: '~'
|
||||
}[mode] || mode;
|
||||
|
||||
return `${property} ${modeLabel} ${value}`;
|
||||
});
|
||||
|
||||
/**
|
||||
* Get filter statistics
|
||||
*/
|
||||
const filterStats = computed(() => {
|
||||
const total = rawData?.length || 0;
|
||||
const filtered = filteredData.value.length;
|
||||
|
||||
return {
|
||||
total,
|
||||
filtered,
|
||||
matched: filtered,
|
||||
filtered: total - filtered,
|
||||
percentage: total > 0 ? Math.round((filtered / total) * 100) : 0
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
// State
|
||||
filterProperty,
|
||||
filterValue,
|
||||
filterMode,
|
||||
availablePaths,
|
||||
isFiltering,
|
||||
filterError,
|
||||
|
||||
// Computed
|
||||
filteredData,
|
||||
hasActiveFilter,
|
||||
filterDescription,
|
||||
filterStats,
|
||||
|
||||
// Methods
|
||||
initializeFilter,
|
||||
extractPathsFromData,
|
||||
extractPathsLazy,
|
||||
matchesFilter,
|
||||
getUniqueValues,
|
||||
clearFilters,
|
||||
setFilter
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import useJsonFilter from '../../../src/composables/useJsonFilter.js';
|
||||
|
||||
describe('useJsonFilter', () => {
|
||||
let filter;
|
||||
|
||||
beforeEach(() => {
|
||||
filter = useJsonFilter();
|
||||
});
|
||||
|
||||
// Test data
|
||||
const testData = [
|
||||
{ id: 1, name: 'Pikachu', type: 'Electric', level: 25 },
|
||||
{ id: 2, name: 'Charizard', type: 'Fire', level: 50 },
|
||||
{ id: 3, name: 'Blastoise', type: 'Water', level: 50 },
|
||||
{ id: 4, name: 'Venusaur', type: 'Grass', level: 50 },
|
||||
{ id: 5, name: 'Pikachu', type: 'Electric', level: 30 },
|
||||
];
|
||||
|
||||
const nestedData = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Pokemon',
|
||||
stats: { hp: 100, attack: 150, defense: 75 },
|
||||
moves: ['Thunder', 'Thunderbolt']
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Dragon',
|
||||
stats: { hp: 120, attack: 180, defense: 95 },
|
||||
moves: ['DragonClaw', 'DragonDance']
|
||||
}
|
||||
];
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should initialize with empty state', () => {
|
||||
expect(filter.filterProperty.value).toBe('');
|
||||
expect(filter.filterValue.value).toBe('');
|
||||
expect(filter.filterMode.value).toBe('equals');
|
||||
expect(filter.availablePaths.value).toEqual([]);
|
||||
expect(filter.filterError.value).toBeNull();
|
||||
});
|
||||
|
||||
it('should initialize filter with data', () => {
|
||||
filter.initializeFilter(testData);
|
||||
|
||||
expect(filter.availablePaths.value.length).toBeGreaterThan(0);
|
||||
expect(filter.availablePaths.value[0]).toHaveProperty('path');
|
||||
expect(filter.availablePaths.value[0]).toHaveProperty('breadcrumb');
|
||||
});
|
||||
|
||||
it('should extract correct paths from simple data', () => {
|
||||
filter.initializeFilter(testData);
|
||||
|
||||
const paths = filter.availablePaths.value.map(p => p.path);
|
||||
expect(paths).toContain('id');
|
||||
expect(paths).toContain('name');
|
||||
expect(paths).toContain('type');
|
||||
expect(paths).toContain('level');
|
||||
});
|
||||
|
||||
it('should extract nested paths from complex data', () => {
|
||||
filter.initializeFilter(nestedData);
|
||||
|
||||
const paths = filter.availablePaths.value.map(p => p.path);
|
||||
expect(paths).toContain('stats.hp');
|
||||
expect(paths).toContain('stats.attack');
|
||||
expect(paths).toContain('stats.defense');
|
||||
});
|
||||
|
||||
it('should handle empty data gracefully', () => {
|
||||
filter.initializeFilter([]);
|
||||
expect(filter.availablePaths.value).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle non-array data', () => {
|
||||
filter.initializeFilter(null);
|
||||
expect(filter.availablePaths.value).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Path Extraction', () => {
|
||||
it('should extract paths with breadcrumb formatting', () => {
|
||||
filter.extractPathsFromData(nestedData);
|
||||
|
||||
const nestedPaths = filter.availablePaths.value.filter(p =>
|
||||
p.path.includes('.')
|
||||
);
|
||||
|
||||
expect(nestedPaths.length).toBeGreaterThan(0);
|
||||
expect(nestedPaths[0].breadcrumb).toContain('›');
|
||||
});
|
||||
|
||||
it('should respect maxDepth parameter', () => {
|
||||
const deepData = [
|
||||
{
|
||||
level1: {
|
||||
level2: {
|
||||
level3: {
|
||||
level4: {
|
||||
level5: {
|
||||
value: 'deep'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
filter.initializeFilter(deepData);
|
||||
const paths = filter.availablePaths.value.map(p => p.path);
|
||||
|
||||
// maxDepth is 5, so level4 should be present but level5.value may not
|
||||
expect(paths.some(p => p.includes('level1'))).toBeTruthy();
|
||||
expect(paths.some(p => p.includes('level4'))).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should extract paths from sample size correctly', () => {
|
||||
filter.extractPathsFromData(testData, 2); // Only first 2 items
|
||||
expect(filter.availablePaths.value.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should sort paths alphabetically', () => {
|
||||
filter.initializeFilter(testData);
|
||||
|
||||
const paths = filter.availablePaths.value.map(p => p.path);
|
||||
const sortedPaths = [...paths].sort();
|
||||
|
||||
expect(paths).toEqual(sortedPaths);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Filtering - Equals Mode', () => {
|
||||
beforeEach(() => {
|
||||
filter.initializeFilter(testData);
|
||||
});
|
||||
|
||||
it('should filter by exact string match', () => {
|
||||
filter.setFilter('name', 'Pikachu', 'equals');
|
||||
|
||||
expect(filter.filteredData.value.length).toBe(2);
|
||||
expect(filter.filteredData.value.every(item => item.name === 'Pikachu')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should filter by exact number match', () => {
|
||||
filter.setFilter('level', '50', 'equals');
|
||||
|
||||
expect(filter.filteredData.value.length).toBe(3);
|
||||
expect(filter.filteredData.value.every(item => item.level === 50)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should be case-insensitive', () => {
|
||||
filter.setFilter('name', 'pikachu', 'equals');
|
||||
|
||||
expect(filter.filteredData.value.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should return all data with no filter', () => {
|
||||
filter.clearFilters();
|
||||
|
||||
expect(filter.filteredData.value.length).toBe(testData.length);
|
||||
});
|
||||
|
||||
it('should return empty with no matching filter', () => {
|
||||
filter.setFilter('name', 'Nonexistent', 'equals');
|
||||
|
||||
expect(filter.filteredData.value.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Filtering - Contains Mode', () => {
|
||||
beforeEach(() => {
|
||||
filter.initializeFilter(testData);
|
||||
});
|
||||
|
||||
it('should filter by substring match', () => {
|
||||
filter.setFilter('name', 'izard', 'contains');
|
||||
|
||||
expect(filter.filteredData.value.length).toBe(1);
|
||||
expect(filter.filteredData.value[0].name).toBe('Charizard');
|
||||
});
|
||||
|
||||
it('should be case-insensitive', () => {
|
||||
filter.setFilter('name', 'CHARIZARD', 'contains');
|
||||
|
||||
expect(filter.filteredData.value.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should match partial strings', () => {
|
||||
filter.setFilter('type', 'Fire', 'contains');
|
||||
|
||||
expect(filter.filteredData.value.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return all with empty filter value', () => {
|
||||
filter.filterProperty.value = 'name';
|
||||
filter.filterValue.value = '';
|
||||
filter.filterMode.value = 'contains';
|
||||
|
||||
expect(filter.filteredData.value.length).toBe(testData.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Filtering - Regex Mode', () => {
|
||||
beforeEach(() => {
|
||||
filter.initializeFilter(testData);
|
||||
});
|
||||
|
||||
it('should filter by regex pattern', () => {
|
||||
filter.setFilter('name', '^P', 'regex');
|
||||
|
||||
const filtered = filter.filteredData.value;
|
||||
expect(filtered.length).toBe(2); // Pikachu (x2)
|
||||
expect(filtered.every(item => item.name.startsWith('P'))).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle complex regex patterns', () => {
|
||||
filter.setFilter('name', '(Pikachu|Charizard)', 'regex');
|
||||
|
||||
const names = filter.filteredData.value.map(item => item.name);
|
||||
expect(names.every(name =>
|
||||
name === 'Pikachu' || name === 'Charizard'
|
||||
)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should be case-insensitive by default in regex', () => {
|
||||
filter.setFilter('name', 'pikachu', 'regex');
|
||||
|
||||
expect(filter.filteredData.value.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle invalid regex gracefully', () => {
|
||||
filter.setFilter('name', '[invalid', 'regex');
|
||||
|
||||
expect(filter.filterError.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should match numbers with regex', () => {
|
||||
filter.setFilter('level', '\\d{2}', 'regex'); // 2-digit numbers
|
||||
|
||||
expect(filter.filteredData.value.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Nested Property Filtering', () => {
|
||||
beforeEach(() => {
|
||||
filter.initializeFilter(nestedData);
|
||||
});
|
||||
|
||||
it('should filter by nested property', () => {
|
||||
filter.setFilter('stats.hp', '100', 'equals');
|
||||
|
||||
expect(filter.filteredData.value.length).toBe(1);
|
||||
expect(filter.filteredData.value[0].id).toBe(1);
|
||||
});
|
||||
|
||||
it('should filter nested properties with contains', () => {
|
||||
filter.setFilter('stats.attack', '15', 'contains');
|
||||
|
||||
expect(filter.filteredData.value.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle missing nested properties', () => {
|
||||
filter.setFilter('nonexistent.property', 'value', 'equals');
|
||||
|
||||
expect(filter.filteredData.value.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchesFilter Function', () => {
|
||||
it('should match equals mode correctly', () => {
|
||||
expect(filter.matchesFilter('test', 'test', 'equals')).toBeTruthy();
|
||||
expect(filter.matchesFilter('test', 'TEST', 'equals')).toBeTruthy();
|
||||
expect(filter.matchesFilter('test', 'no', 'equals')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should match contains mode correctly', () => {
|
||||
expect(filter.matchesFilter('testing', 'test', 'contains')).toBeTruthy();
|
||||
expect(filter.matchesFilter('testing', 'TEST', 'contains')).toBeTruthy();
|
||||
expect(filter.matchesFilter('testing', 'xyz', 'contains')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should match regex mode correctly', () => {
|
||||
expect(filter.matchesFilter('test123', '\\d+', 'regex')).toBeTruthy();
|
||||
expect(filter.matchesFilter('test', '^test$', 'regex')).toBeTruthy();
|
||||
expect(filter.matchesFilter('nodigits', '\\d+', 'regex')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should handle empty filter value', () => {
|
||||
expect(filter.matchesFilter('anything', '', 'equals')).toBeTruthy();
|
||||
expect(filter.matchesFilter('anything', '', 'contains')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should convert values to strings', () => {
|
||||
expect(filter.matchesFilter(123, '123', 'equals')).toBeTruthy();
|
||||
expect(filter.matchesFilter(true, 'true', 'equals')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUniqueValues Function', () => {
|
||||
beforeEach(() => {
|
||||
filter.initializeFilter(testData);
|
||||
});
|
||||
|
||||
it('should return unique values for a property', () => {
|
||||
const types = filter.getUniqueValues('type');
|
||||
|
||||
expect(types.length).toBe(4); // Electric, Fire, Water, Grass
|
||||
expect(new Set(types).size).toBe(types.length); // All unique
|
||||
});
|
||||
|
||||
it('should return sorted values', () => {
|
||||
const types = filter.getUniqueValues('type');
|
||||
const sorted = [...types].sort();
|
||||
|
||||
expect(types).toEqual(sorted);
|
||||
});
|
||||
|
||||
it('should handle missing properties', () => {
|
||||
const values = filter.getUniqueValues('nonexistent');
|
||||
|
||||
expect(values).toEqual([]);
|
||||
});
|
||||
|
||||
it('should exclude null and undefined', () => {
|
||||
const dataWithNull = [
|
||||
{ prop: 'value1' },
|
||||
{ prop: null },
|
||||
{ prop: undefined },
|
||||
{ prop: 'value2' }
|
||||
];
|
||||
|
||||
filter.initializeFilter(dataWithNull);
|
||||
const values = filter.getUniqueValues('prop');
|
||||
|
||||
expect(values).not.toContain('null');
|
||||
expect(values).not.toContain('undefined');
|
||||
});
|
||||
|
||||
it('should work with nested properties', () => {
|
||||
// Re-initialize with nestedData for this test
|
||||
filter.initializeFilter(nestedData);
|
||||
const values = filter.getUniqueValues('stats.attack');
|
||||
|
||||
expect(values.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Filter State Management', () => {
|
||||
beforeEach(() => {
|
||||
filter.initializeFilter(testData);
|
||||
filter.setFilter('name', 'Pikachu', 'equals');
|
||||
});
|
||||
|
||||
it('should set filter correctly', () => {
|
||||
expect(filter.filterProperty.value).toBe('name');
|
||||
expect(filter.filterValue.value).toBe('Pikachu');
|
||||
expect(filter.filterMode.value).toBe('equals');
|
||||
});
|
||||
|
||||
it('should clear filters', () => {
|
||||
filter.clearFilters();
|
||||
|
||||
expect(filter.filterProperty.value).toBe('');
|
||||
expect(filter.filterValue.value).toBe('');
|
||||
expect(filter.filterMode.value).toBe('equals');
|
||||
expect(filter.filterError.value).toBeNull();
|
||||
});
|
||||
|
||||
it('should set hasActiveFilter correctly', () => {
|
||||
expect(filter.hasActiveFilter.value).toBeTruthy();
|
||||
|
||||
filter.clearFilters();
|
||||
expect(filter.hasActiveFilter.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should clear error on successful filter', () => {
|
||||
filter.setFilter('name', '[invalid', 'regex');
|
||||
expect(filter.filterError.value).toBeTruthy();
|
||||
|
||||
filter.setFilter('name', 'Pikachu', 'equals');
|
||||
expect(filter.filterError.value).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Filter Description', () => {
|
||||
beforeEach(() => {
|
||||
filter.initializeFilter(testData);
|
||||
});
|
||||
|
||||
it('should return default description with no filter', () => {
|
||||
expect(filter.filterDescription.value).toBe('No filter applied');
|
||||
});
|
||||
|
||||
it('should show description for equals filter', () => {
|
||||
filter.setFilter('name', 'Pikachu', 'equals');
|
||||
|
||||
expect(filter.filterDescription.value).toContain('name');
|
||||
expect(filter.filterDescription.value).toContain('Pikachu');
|
||||
expect(filter.filterDescription.value).toContain('=');
|
||||
});
|
||||
|
||||
it('should show description for contains filter', () => {
|
||||
filter.setFilter('type', 'Fire', 'contains');
|
||||
|
||||
expect(filter.filterDescription.value).toContain('type');
|
||||
expect(filter.filterDescription.value).toContain('Fire');
|
||||
expect(filter.filterDescription.value).toContain('∋');
|
||||
});
|
||||
|
||||
it('should show description for regex filter', () => {
|
||||
filter.setFilter('name', '^P', 'regex');
|
||||
|
||||
expect(filter.filterDescription.value).toContain('name');
|
||||
expect(filter.filterDescription.value).toContain('^P');
|
||||
expect(filter.filterDescription.value).toContain('~');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Filter Statistics', () => {
|
||||
beforeEach(() => {
|
||||
filter.initializeFilter(testData);
|
||||
});
|
||||
|
||||
it('should calculate stats with no filter', () => {
|
||||
const stats = filter.filterStats.value;
|
||||
|
||||
expect(stats.total).toBe(testData.length);
|
||||
expect(stats.matched).toBe(testData.length);
|
||||
expect(stats.percentage).toBe(100);
|
||||
});
|
||||
|
||||
it('should calculate stats with active filter', () => {
|
||||
filter.setFilter('type', 'Electric', 'equals');
|
||||
const stats = filter.filterStats.value;
|
||||
|
||||
expect(stats.total).toBe(testData.length);
|
||||
expect(stats.matched).toBe(2);
|
||||
expect(stats.percentage).toBe(40); // 2/5 = 40%
|
||||
});
|
||||
|
||||
it('should calculate stats with no matches', () => {
|
||||
filter.setFilter('name', 'Nonexistent', 'equals');
|
||||
const stats = filter.filterStats.value;
|
||||
|
||||
expect(stats.total).toBe(testData.length);
|
||||
expect(stats.matched).toBe(0);
|
||||
expect(stats.percentage).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle empty data stats', () => {
|
||||
filter.initializeFilter([]);
|
||||
const stats = filter.filterStats.value;
|
||||
|
||||
expect(stats.total).toBe(0);
|
||||
expect(stats.percentage).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lazy Path Extraction', () => {
|
||||
it('should extract paths lazily from large datasets', (done) => {
|
||||
const largeData = Array.from({ length: 300 }, (_, i) => ({
|
||||
id: i,
|
||||
name: `Item ${i}`,
|
||||
category: i % 10,
|
||||
metadata: { nested: true }
|
||||
}));
|
||||
|
||||
filter.initializeFilter(largeData.slice(0, 100));
|
||||
const initialPathCount = filter.availablePaths.value.length;
|
||||
|
||||
filter.extractPathsLazy(largeData, 100, (newPaths) => {
|
||||
expect(filter.availablePaths.value.length).toBeGreaterThanOrEqual(
|
||||
initialPathCount
|
||||
);
|
||||
done();
|
||||
});
|
||||
|
||||
// Give async operation time to complete
|
||||
setTimeout(() => {
|
||||
done();
|
||||
}, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
beforeEach(() => {
|
||||
filter.initializeFilter(testData);
|
||||
});
|
||||
|
||||
it('should handle invalid regex gracefully', () => {
|
||||
filter.setFilter('name', '[invalid(regex', 'regex');
|
||||
|
||||
expect(filter.filterError.value).toBeTruthy();
|
||||
// With an error, it should return all data
|
||||
expect(filter.filteredData.value.length).toBe(testData.length);
|
||||
});
|
||||
|
||||
it('should handle missing property in filtering', () => {
|
||||
filter.setFilter('missing.property.path', 'value', 'equals');
|
||||
|
||||
expect(filter.filteredData.value.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle null/undefined data in filtering', () => {
|
||||
const dataWithNulls = [
|
||||
{ id: 1, value: 'test' },
|
||||
{ id: 2, value: null },
|
||||
{ id: 3 } // missing value prop
|
||||
];
|
||||
|
||||
filter.initializeFilter(dataWithNulls);
|
||||
filter.setFilter('value', 'test', 'equals');
|
||||
|
||||
expect(filter.filteredData.value.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should recover from regex error when setting valid filter', () => {
|
||||
filter.setFilter('name', '[invalid', 'regex');
|
||||
expect(filter.filterError.value).toBeTruthy();
|
||||
|
||||
filter.setFilter('name', 'Pikachu', 'equals');
|
||||
expect(filter.filterError.value).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle data with circular references', () => {
|
||||
const circular = { id: 1, name: 'test' };
|
||||
circular.self = circular; // Circular reference
|
||||
|
||||
// This should not crash
|
||||
filter.extractPathsFromData([circular], 1);
|
||||
expect(filter.availablePaths.value.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle arrays in data without recursing', () => {
|
||||
const dataWithArrays = [
|
||||
{ id: 1, tags: ['a', 'b', 'c'], name: 'test' }
|
||||
];
|
||||
|
||||
filter.initializeFilter(dataWithArrays);
|
||||
const paths = filter.availablePaths.value.map(p => p.path);
|
||||
|
||||
// Should have tags as a path, but not array indices
|
||||
expect(paths).toContain('tags');
|
||||
});
|
||||
|
||||
it('should handle numeric keys in objects', () => {
|
||||
const dataWithNumericKeys = [
|
||||
{ '1': 'numeric', '2': 'keys', name: 'test' }
|
||||
];
|
||||
|
||||
filter.initializeFilter(dataWithNumericKeys);
|
||||
const paths = filter.availablePaths.value.map(p => p.path);
|
||||
|
||||
expect(paths).toContain('1');
|
||||
expect(paths).toContain('2');
|
||||
expect(paths).toContain('name');
|
||||
});
|
||||
|
||||
it('should handle unicode characters in paths', () => {
|
||||
const unicodeData = [
|
||||
{ '名前': 'Japanese', 'nome': 'Italian', name: 'English' }
|
||||
];
|
||||
|
||||
filter.initializeFilter(unicodeData);
|
||||
const paths = filter.availablePaths.value.map(p => p.path);
|
||||
|
||||
expect(paths).toContain('名前');
|
||||
expect(paths).toContain('nome');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user