mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-24 06:32:12 -03:00
feat(filter): add tag logic toggle (OR/AND) for include tags filtering
Add a segmented toggle in the Filter Panel to switch between 'Any' (OR) and 'All' (AND) logic when filtering by multiple include tags. Changes: - Backend: Add tag_logic field to FilterCriteria and ModelFilterSet - Backend: Parse tag_logic parameter in model handlers - Frontend: Add segmented toggle UI in filter panel header - Frontend: Add interaction logic and state management for tag logic - Add translations for all supported languages - Add comprehensive tests for the new feature Closes #802
This commit is contained in:
290
tests/frontend/managers/FilterManager.tagLogic.test.js
Normal file
290
tests/frontend/managers/FilterManager.tagLogic.test.js
Normal file
@@ -0,0 +1,290 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../../static/js/state/index.js', () => ({
|
||||
getCurrentPageState: vi.fn(() => ({
|
||||
filters: {},
|
||||
})),
|
||||
state: {
|
||||
currentPageType: 'loras',
|
||||
loadingManager: {
|
||||
showSimpleLoading: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||
showToast: vi.fn(),
|
||||
updatePanelPositions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||
getModelApiClient: vi.fn(() => ({
|
||||
loadMoreWithVirtualScroll: vi.fn().mockResolvedValue(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
||||
getStorageItem: vi.fn(),
|
||||
setStorageItem: vi.fn(),
|
||||
removeStorageItem: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||
translate: vi.fn((key, _params, fallback) => fallback || key),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/managers/FilterPresetManager.js', () => ({
|
||||
FilterPresetManager: vi.fn().mockImplementation(() => ({
|
||||
renderPresets: vi.fn(),
|
||||
saveActivePreset: vi.fn(),
|
||||
restoreActivePreset: vi.fn(),
|
||||
updateAddButtonState: vi.fn(),
|
||||
hasEmptyWildcardResult: vi.fn(() => false),
|
||||
})),
|
||||
EMPTY_WILDCARD_MARKER: '__EMPTY_WILDCARD_RESULT__',
|
||||
}));
|
||||
|
||||
import { FilterManager } from '../../../static/js/managers/FilterManager.js';
|
||||
import { getStorageItem, setStorageItem } from '../../../static/js/utils/storageHelpers.js';
|
||||
|
||||
describe('FilterManager - Tag Logic', () => {
|
||||
let manager;
|
||||
let mockFilterPanel;
|
||||
let mockTagLogicToggle;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Setup DOM mocks
|
||||
mockFilterPanel = document.createElement('div');
|
||||
mockFilterPanel.id = 'filterPanel';
|
||||
mockFilterPanel.classList.add('hidden');
|
||||
|
||||
mockTagLogicToggle = document.createElement('div');
|
||||
mockTagLogicToggle.id = 'tagLogicToggle';
|
||||
|
||||
// Create tag logic options
|
||||
const anyOption = document.createElement('button');
|
||||
anyOption.className = 'tag-logic-option';
|
||||
anyOption.dataset.value = 'any';
|
||||
mockTagLogicToggle.appendChild(anyOption);
|
||||
|
||||
const allOption = document.createElement('button');
|
||||
allOption.className = 'tag-logic-option';
|
||||
allOption.dataset.value = 'all';
|
||||
mockTagLogicToggle.appendChild(allOption);
|
||||
|
||||
document.body.appendChild(mockFilterPanel);
|
||||
document.body.appendChild(mockTagLogicToggle);
|
||||
|
||||
// Mock getElementById
|
||||
const originalGetElementById = document.getElementById;
|
||||
document.getElementById = vi.fn((id) => {
|
||||
if (id === 'filterPanel') return mockFilterPanel;
|
||||
if (id === 'tagLogicToggle') return mockTagLogicToggle;
|
||||
if (id === 'filterButton') return document.createElement('button');
|
||||
if (id === 'activeFiltersCount') return document.createElement('span');
|
||||
if (id === 'baseModelTags') return document.createElement('div');
|
||||
if (id === 'modelTypeTags') return document.createElement('div');
|
||||
return originalGetElementById.call(document, id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initializeFilters', () => {
|
||||
it('should default tagLogic to "any" when not provided', () => {
|
||||
manager = new FilterManager({ page: 'loras' });
|
||||
|
||||
expect(manager.filters.tagLogic).toBe('any');
|
||||
});
|
||||
|
||||
it('should use provided tagLogic value', () => {
|
||||
getStorageItem.mockReturnValue({
|
||||
tagLogic: 'all',
|
||||
tags: {},
|
||||
baseModel: [],
|
||||
});
|
||||
|
||||
manager = new FilterManager({ page: 'loras' });
|
||||
|
||||
expect(manager.filters.tagLogic).toBe('all');
|
||||
});
|
||||
});
|
||||
|
||||
describe('initializeTagLogicToggle', () => {
|
||||
it('should set "any" option as active by default', () => {
|
||||
manager = new FilterManager({ page: 'loras' });
|
||||
|
||||
// Ensure filters.tagLogic is set to default
|
||||
manager.filters.tagLogic = 'any';
|
||||
|
||||
const anyOption = mockTagLogicToggle.querySelector('[data-value="any"]');
|
||||
const allOption = mockTagLogicToggle.querySelector('[data-value="all"]');
|
||||
|
||||
// Manually update UI to ensure correct state
|
||||
manager.updateTagLogicToggleUI();
|
||||
|
||||
expect(manager.filters.tagLogic).toBe('any');
|
||||
expect(anyOption.classList.contains('active')).toBe(true);
|
||||
expect(allOption.classList.contains('active')).toBe(false);
|
||||
});
|
||||
|
||||
it('should set "all" option as active when tagLogic is "all"', () => {
|
||||
getStorageItem.mockReturnValue({
|
||||
tagLogic: 'all',
|
||||
tags: {},
|
||||
baseModel: [],
|
||||
});
|
||||
|
||||
manager = new FilterManager({ page: 'loras' });
|
||||
|
||||
// Ensure filters.tagLogic is set correctly
|
||||
manager.filters.tagLogic = 'all';
|
||||
|
||||
const anyOption = mockTagLogicToggle.querySelector('[data-value="any"]');
|
||||
const allOption = mockTagLogicToggle.querySelector('[data-value="all"]');
|
||||
|
||||
// Manually update UI to ensure correct state
|
||||
manager.updateTagLogicToggleUI();
|
||||
|
||||
expect(manager.filters.tagLogic).toBe('all');
|
||||
expect(anyOption.classList.contains('active')).toBe(false);
|
||||
expect(allOption.classList.contains('active')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTagLogicToggleUI', () => {
|
||||
it('should update UI when tagLogic changes', () => {
|
||||
// Clear any existing active classes first
|
||||
mockTagLogicToggle.querySelectorAll('.tag-logic-option').forEach(el => {
|
||||
el.classList.remove('active');
|
||||
});
|
||||
|
||||
manager = new FilterManager({ page: 'loras' });
|
||||
|
||||
let anyOption = mockTagLogicToggle.querySelector('[data-value="any"]');
|
||||
let allOption = mockTagLogicToggle.querySelector('[data-value="all"]');
|
||||
|
||||
// Ensure initial state
|
||||
manager.filters.tagLogic = 'any';
|
||||
manager.updateTagLogicToggleUI();
|
||||
expect(anyOption.classList.contains('active')).toBe(true);
|
||||
expect(allOption.classList.contains('active')).toBe(false);
|
||||
|
||||
// Change to "all"
|
||||
manager.filters.tagLogic = 'all';
|
||||
manager.updateTagLogicToggleUI();
|
||||
|
||||
expect(anyOption.classList.contains('active')).toBe(false);
|
||||
expect(allOption.classList.contains('active')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cloneFilters', () => {
|
||||
it('should include tagLogic in cloned filters', () => {
|
||||
manager = new FilterManager({ page: 'loras' });
|
||||
manager.filters.tagLogic = 'all';
|
||||
|
||||
const cloned = manager.cloneFilters();
|
||||
|
||||
expect(cloned.tagLogic).toBe('all');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearFilters', () => {
|
||||
it('should reset tagLogic to "any"', () => {
|
||||
getStorageItem.mockReturnValue({
|
||||
tagLogic: 'all',
|
||||
tags: { anime: 'include' },
|
||||
baseModel: ['SDXL'],
|
||||
});
|
||||
|
||||
manager = new FilterManager({ page: 'loras' });
|
||||
expect(manager.filters.tagLogic).toBe('all');
|
||||
|
||||
manager.clearFilters();
|
||||
|
||||
expect(manager.filters.tagLogic).toBe('any');
|
||||
});
|
||||
|
||||
it('should update UI after clearing', () => {
|
||||
getStorageItem.mockReturnValue({
|
||||
tagLogic: 'all',
|
||||
tags: {},
|
||||
baseModel: [],
|
||||
});
|
||||
|
||||
manager = new FilterManager({ page: 'loras' });
|
||||
|
||||
const anyOption = mockTagLogicToggle.querySelector('[data-value="any"]');
|
||||
const allOption = mockTagLogicToggle.querySelector('[data-value="all"]');
|
||||
|
||||
// Initially "all" is active
|
||||
expect(allOption.classList.contains('active')).toBe(true);
|
||||
|
||||
manager.clearFilters();
|
||||
|
||||
// After clear, "any" should be active
|
||||
expect(anyOption.classList.contains('active')).toBe(true);
|
||||
expect(allOption.classList.contains('active')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadFiltersFromStorage', () => {
|
||||
it('should restore tagLogic from storage', () => {
|
||||
getStorageItem.mockReturnValue({
|
||||
tagLogic: 'all',
|
||||
tags: { anime: 'include' },
|
||||
baseModel: [],
|
||||
});
|
||||
|
||||
manager = new FilterManager({ page: 'loras' });
|
||||
|
||||
expect(manager.filters.tagLogic).toBe('all');
|
||||
expect(manager.filters.tags).toEqual({ anime: 'include' });
|
||||
});
|
||||
|
||||
it('should default to "any" when no tagLogic in storage', () => {
|
||||
getStorageItem.mockReturnValue({
|
||||
tags: {},
|
||||
baseModel: [],
|
||||
});
|
||||
|
||||
manager = new FilterManager({ page: 'loras' });
|
||||
|
||||
expect(manager.filters.tagLogic).toBe('any');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tag logic toggle interaction', () => {
|
||||
it('should update tagLogic when clicking "all" option', async () => {
|
||||
manager = new FilterManager({ page: 'loras' });
|
||||
|
||||
const allOption = mockTagLogicToggle.querySelector('[data-value="all"]');
|
||||
|
||||
// Simulate click
|
||||
allOption.click();
|
||||
|
||||
// Wait for async operation
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(manager.filters.tagLogic).toBe('all');
|
||||
});
|
||||
|
||||
it('should not change tagLogic when clicking already active option', async () => {
|
||||
manager = new FilterManager({ page: 'loras' });
|
||||
|
||||
const anyOption = mockTagLogicToggle.querySelector('[data-value="any"]');
|
||||
const applyFiltersSpy = vi.spyOn(manager, 'applyFilters');
|
||||
|
||||
// Click already active option
|
||||
anyOption.click();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
// applyFilters should not be called since value didn't change
|
||||
expect(applyFiltersSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
166
tests/routes/test_tag_logic_param_parsing.py
Normal file
166
tests/routes/test_tag_logic_param_parsing.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""Tests for tag_logic parameter parsing in model handlers."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
import sys
|
||||
import types
|
||||
|
||||
folder_paths_stub = types.SimpleNamespace(get_folder_paths=lambda *_: [])
|
||||
sys.modules.setdefault("folder_paths", folder_paths_stub)
|
||||
|
||||
from py.routes.handlers.model_handlers import ModelListingHandler
|
||||
|
||||
|
||||
class MockService:
|
||||
"""Mock service for testing."""
|
||||
|
||||
def __init__(self):
|
||||
self.model_type = "test-model"
|
||||
|
||||
async def get_paginated_data(self, **kwargs):
|
||||
# Store the kwargs for verification
|
||||
self.last_call_kwargs = kwargs
|
||||
return {
|
||||
"items": [],
|
||||
"total": 0,
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
"total_pages": 0,
|
||||
}
|
||||
|
||||
async def format_response(self, item):
|
||||
return item
|
||||
|
||||
|
||||
def parse_specific_params(request):
|
||||
"""No specific params for testing."""
|
||||
return {}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def handler():
|
||||
service = MockService()
|
||||
logger = Mock()
|
||||
return ModelListingHandler(
|
||||
service=service,
|
||||
parse_specific_params=parse_specific_params,
|
||||
logger=logger,
|
||||
), service
|
||||
|
||||
|
||||
async def make_request(handler, query_string=""):
|
||||
"""Helper to create a request and call get_models."""
|
||||
app = web.Application()
|
||||
|
||||
async def test_handler(request):
|
||||
return await handler.get_models(request)
|
||||
|
||||
app.router.add_get("/test", test_handler)
|
||||
server = TestServer(app)
|
||||
client = TestClient(server)
|
||||
await client.start_server()
|
||||
|
||||
try:
|
||||
response = await client.get(f"/test?{query_string}")
|
||||
return response
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tag_logic_param_default_is_any(handler):
|
||||
"""Test that tag_logic defaults to 'any' when not provided."""
|
||||
h, service = handler
|
||||
|
||||
response = await make_request(h, "tag_include=anime&tag_include=realistic")
|
||||
assert response.status == 200
|
||||
|
||||
# Verify tag_logic was set to 'any' by default
|
||||
assert service.last_call_kwargs["tag_logic"] == "any"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tag_logic_param_explicit_any(handler):
|
||||
"""Test that tag_logic='any' is correctly parsed."""
|
||||
h, service = handler
|
||||
|
||||
response = await make_request(h, "tag_include=anime&tag_logic=any")
|
||||
assert response.status == 200
|
||||
|
||||
assert service.last_call_kwargs["tag_logic"] == "any"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tag_logic_param_explicit_all(handler):
|
||||
"""Test that tag_logic='all' is correctly parsed."""
|
||||
h, service = handler
|
||||
|
||||
response = await make_request(h, "tag_include=anime&tag_include=realistic&tag_logic=all")
|
||||
assert response.status == 200
|
||||
|
||||
assert service.last_call_kwargs["tag_logic"] == "all"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tag_logic_param_case_insensitive(handler):
|
||||
"""Test that tag_logic values are case insensitive."""
|
||||
h, service = handler
|
||||
|
||||
# Test uppercase
|
||||
response = await make_request(h, "tag_logic=ALL")
|
||||
assert response.status == 200
|
||||
assert service.last_call_kwargs["tag_logic"] == "all"
|
||||
|
||||
# Test mixed case
|
||||
response = await make_request(h, "tag_logic=Any")
|
||||
assert response.status == 200
|
||||
assert service.last_call_kwargs["tag_logic"] == "any"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tag_logic_param_invalid_value_defaults_to_any(handler):
|
||||
"""Test that invalid tag_logic values default to 'any'."""
|
||||
h, service = handler
|
||||
|
||||
response = await make_request(h, "tag_logic=invalid")
|
||||
assert response.status == 200
|
||||
|
||||
# Should default to 'any' for invalid values
|
||||
assert service.last_call_kwargs["tag_logic"] == "any"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tag_logic_param_with_other_filters(handler):
|
||||
"""Test that tag_logic works correctly with other filter parameters."""
|
||||
h, service = handler
|
||||
|
||||
query = (
|
||||
"tag_include=anime&"
|
||||
"tag_include=character&"
|
||||
"tag_exclude=nsfw&"
|
||||
"base_model=SDXL&"
|
||||
"tag_logic=all"
|
||||
)
|
||||
response = await make_request(h, query)
|
||||
assert response.status == 200
|
||||
|
||||
assert service.last_call_kwargs["tag_logic"] == "all"
|
||||
assert service.last_call_kwargs["base_models"] == ["SDXL"]
|
||||
assert "anime" in service.last_call_kwargs["tags"]
|
||||
assert "character" in service.last_call_kwargs["tags"]
|
||||
assert "nsfw" in service.last_call_kwargs["tags"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tag_logic_without_include_tags(handler):
|
||||
"""Test that tag_logic is still passed even without include tags."""
|
||||
h, service = handler
|
||||
|
||||
response = await make_request(h, "tag_logic=all&base_model=SDXL")
|
||||
assert response.status == 200
|
||||
|
||||
# tag_logic should still be set even without tag filters
|
||||
assert service.last_call_kwargs["tag_logic"] == "all"
|
||||
276
tests/services/test_tag_logic_filter.py
Normal file
276
tests/services/test_tag_logic_filter.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""Tests for tag logic (OR/AND) filtering functionality."""
|
||||
|
||||
import pytest
|
||||
from py.services.model_query import ModelFilterSet, FilterCriteria
|
||||
|
||||
|
||||
class StubSettings:
|
||||
def get(self, key, default=None):
|
||||
return default
|
||||
|
||||
|
||||
class TestTagLogicFilter:
|
||||
"""Test cases for tag_logic parameter in FilterCriteria."""
|
||||
|
||||
def test_tag_logic_any_returns_items_with_any_tag(self):
|
||||
"""Test that tag_logic='any' (OR) returns items matching any include tag."""
|
||||
filter_set = ModelFilterSet(StubSettings())
|
||||
data = [
|
||||
{"name": "m1", "tags": ["anime"]},
|
||||
{"name": "m2", "tags": ["realistic"]},
|
||||
{"name": "m3", "tags": ["anime", "realistic"]},
|
||||
{"name": "m4", "tags": ["style"]},
|
||||
{"name": "m5", "tags": []},
|
||||
]
|
||||
|
||||
# Include anime OR realistic (should match m1, m2, m3)
|
||||
criteria = FilterCriteria(
|
||||
tags={"anime": "include", "realistic": "include"},
|
||||
tag_logic="any"
|
||||
)
|
||||
result = filter_set.apply(data, criteria)
|
||||
assert len(result) == 3
|
||||
assert {item["name"] for item in result} == {"m1", "m2", "m3"}
|
||||
|
||||
def test_tag_logic_all_returns_items_with_all_tags(self):
|
||||
"""Test that tag_logic='all' (AND) returns only items matching all include tags."""
|
||||
filter_set = ModelFilterSet(StubSettings())
|
||||
data = [
|
||||
{"name": "m1", "tags": ["anime"]},
|
||||
{"name": "m2", "tags": ["realistic"]},
|
||||
{"name": "m3", "tags": ["anime", "realistic"]},
|
||||
{"name": "m4", "tags": ["style"]},
|
||||
{"name": "m5", "tags": []},
|
||||
]
|
||||
|
||||
# Include anime AND realistic (should match only m3)
|
||||
criteria = FilterCriteria(
|
||||
tags={"anime": "include", "realistic": "include"},
|
||||
tag_logic="all"
|
||||
)
|
||||
result = filter_set.apply(data, criteria)
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "m3"
|
||||
|
||||
def test_tag_logic_all_with_single_tag(self):
|
||||
"""Test that tag_logic='all' with single tag works same as 'any'."""
|
||||
filter_set = ModelFilterSet(StubSettings())
|
||||
data = [
|
||||
{"name": "m1", "tags": ["anime"]},
|
||||
{"name": "m2", "tags": ["realistic"]},
|
||||
{"name": "m3", "tags": ["anime", "realistic"]},
|
||||
]
|
||||
|
||||
# Include only anime with 'all' logic
|
||||
criteria = FilterCriteria(
|
||||
tags={"anime": "include"},
|
||||
tag_logic="all"
|
||||
)
|
||||
result = filter_set.apply(data, criteria)
|
||||
assert len(result) == 2
|
||||
assert {item["name"] for item in result} == {"m1", "m3"}
|
||||
|
||||
def test_tag_logic_any_with_exclude_tags(self):
|
||||
"""Test that tag_logic='any' works correctly with exclude tags."""
|
||||
filter_set = ModelFilterSet(StubSettings())
|
||||
data = [
|
||||
{"name": "m1", "tags": ["anime"]},
|
||||
{"name": "m2", "tags": ["realistic"]},
|
||||
{"name": "m3", "tags": ["anime", "realistic"]},
|
||||
{"name": "m4", "tags": ["nsfw"]},
|
||||
{"name": "m5", "tags": ["anime", "nsfw"]},
|
||||
]
|
||||
|
||||
# Include anime OR realistic, exclude nsfw
|
||||
criteria = FilterCriteria(
|
||||
tags={
|
||||
"anime": "include",
|
||||
"realistic": "include",
|
||||
"nsfw": "exclude"
|
||||
},
|
||||
tag_logic="any"
|
||||
)
|
||||
result = filter_set.apply(data, criteria)
|
||||
# Should match m1 (anime), m2 (realistic), m3 (both)
|
||||
# m4 excluded by nsfw, m5 excluded by nsfw
|
||||
assert len(result) == 3
|
||||
assert {item["name"] for item in result} == {"m1", "m2", "m3"}
|
||||
|
||||
def test_tag_logic_all_with_exclude_tags(self):
|
||||
"""Test that tag_logic='all' works correctly with exclude tags."""
|
||||
filter_set = ModelFilterSet(StubSettings())
|
||||
data = [
|
||||
{"name": "m1", "tags": ["anime", "character"]},
|
||||
{"name": "m2", "tags": ["realistic", "character"]},
|
||||
{"name": "m3", "tags": ["anime", "realistic", "character"]},
|
||||
{"name": "m4", "tags": ["anime", "character", "nsfw"]},
|
||||
]
|
||||
|
||||
# Include anime AND character, exclude nsfw
|
||||
criteria = FilterCriteria(
|
||||
tags={
|
||||
"anime": "include",
|
||||
"character": "include",
|
||||
"nsfw": "exclude"
|
||||
},
|
||||
tag_logic="all"
|
||||
)
|
||||
result = filter_set.apply(data, criteria)
|
||||
# m1: has anime+character, no nsfw ✓
|
||||
# m2: missing anime ✗
|
||||
# m3: has anime+character, no nsfw ✓
|
||||
# m4: has anime+character but also nsfw ✗
|
||||
assert len(result) == 2
|
||||
assert {item["name"] for item in result} == {"m1", "m3"}
|
||||
|
||||
def test_tag_logic_all_with_no_tags_special_case(self):
|
||||
"""Test tag_logic='all' with __no_tags__ special tag.
|
||||
|
||||
When __no_tags__ is used with 'all' logic along with regular tags,
|
||||
the behavior is: items with no tags are returned (since they satisfy
|
||||
__no_tags__), OR items that have all the regular tags.
|
||||
This is because __no_tags__ is a special condition that can't be ANDed
|
||||
with regular tags in a meaningful way.
|
||||
"""
|
||||
filter_set = ModelFilterSet(StubSettings())
|
||||
data = [
|
||||
{"name": "m1", "tags": ["anime"]},
|
||||
{"name": "m2", "tags": []},
|
||||
{"name": "m3", "tags": None},
|
||||
{"name": "m4", "tags": ["anime", "character"]},
|
||||
]
|
||||
|
||||
# Include anime AND __no_tags__ with 'all' logic
|
||||
# Implementation treats this as: no tags OR (all regular tags)
|
||||
criteria = FilterCriteria(
|
||||
tags={"anime": "include", "__no_tags__": "include"},
|
||||
tag_logic="all"
|
||||
)
|
||||
result = filter_set.apply(data, criteria)
|
||||
# Items with no tags: m2, m3
|
||||
# Items with all regular tags (anime): m1, m4
|
||||
# Combined: m1, m2, m3, m4 (all items)
|
||||
assert len(result) == 4
|
||||
|
||||
def test_tag_logic_any_with_no_tags_special_case(self):
|
||||
"""Test tag_logic='any' with __no_tags__ special tag."""
|
||||
filter_set = ModelFilterSet(StubSettings())
|
||||
data = [
|
||||
{"name": "m1", "tags": ["anime"]},
|
||||
{"name": "m2", "tags": []},
|
||||
{"name": "m3", "tags": None},
|
||||
{"name": "m4", "tags": ["realistic"]},
|
||||
]
|
||||
|
||||
# Include anime OR __no_tags__
|
||||
criteria = FilterCriteria(
|
||||
tags={"anime": "include", "__no_tags__": "include"},
|
||||
tag_logic="any"
|
||||
)
|
||||
result = filter_set.apply(data, criteria)
|
||||
# Should match m1 (anime), m2 (no tags), m3 (no tags)
|
||||
assert len(result) == 3
|
||||
assert {item["name"] for item in result} == {"m1", "m2", "m3"}
|
||||
|
||||
def test_tag_logic_default_is_any(self):
|
||||
"""Test that default tag_logic is 'any' when not specified."""
|
||||
filter_set = ModelFilterSet(StubSettings())
|
||||
data = [
|
||||
{"name": "m1", "tags": ["anime"]},
|
||||
{"name": "m2", "tags": ["realistic"]},
|
||||
{"name": "m3", "tags": ["anime", "realistic"]},
|
||||
]
|
||||
|
||||
# Not specifying tag_logic should default to 'any'
|
||||
criteria = FilterCriteria(
|
||||
tags={"anime": "include", "realistic": "include"}
|
||||
)
|
||||
result = filter_set.apply(data, criteria)
|
||||
# Should match m1, m2, m3 (OR behavior)
|
||||
assert len(result) == 3
|
||||
assert {item["name"] for item in result} == {"m1", "m2", "m3"}
|
||||
|
||||
def test_tag_logic_case_insensitive(self):
|
||||
"""Test that tag_logic values are case insensitive."""
|
||||
filter_set = ModelFilterSet(StubSettings())
|
||||
data = [
|
||||
{"name": "m1", "tags": ["anime"]},
|
||||
{"name": "m2", "tags": ["realistic"]},
|
||||
{"name": "m3", "tags": ["anime", "realistic"]},
|
||||
]
|
||||
|
||||
# Test uppercase 'ALL'
|
||||
criteria = FilterCriteria(
|
||||
tags={"anime": "include", "realistic": "include"},
|
||||
tag_logic="ALL"
|
||||
)
|
||||
result = filter_set.apply(data, criteria)
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "m3"
|
||||
|
||||
# Test mixed case 'Any'
|
||||
criteria = FilterCriteria(
|
||||
tags={"anime": "include", "realistic": "include"},
|
||||
tag_logic="Any"
|
||||
)
|
||||
result = filter_set.apply(data, criteria)
|
||||
assert len(result) == 3
|
||||
|
||||
def test_tag_logic_all_with_three_tags(self):
|
||||
"""Test tag_logic='all' with three include tags."""
|
||||
filter_set = ModelFilterSet(StubSettings())
|
||||
data = [
|
||||
{"name": "m1", "tags": ["anime"]},
|
||||
{"name": "m2", "tags": ["anime", "character"]},
|
||||
{"name": "m3", "tags": ["anime", "character", "style"]},
|
||||
{"name": "m4", "tags": ["character", "style"]},
|
||||
]
|
||||
|
||||
# Include anime AND character AND style
|
||||
criteria = FilterCriteria(
|
||||
tags={
|
||||
"anime": "include",
|
||||
"character": "include",
|
||||
"style": "include"
|
||||
},
|
||||
tag_logic="all"
|
||||
)
|
||||
result = filter_set.apply(data, criteria)
|
||||
# Only m3 has all three tags
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "m3"
|
||||
|
||||
def test_tag_logic_empty_include_tags(self):
|
||||
"""Test that empty include tags with any logic returns all items."""
|
||||
filter_set = ModelFilterSet(StubSettings())
|
||||
data = [
|
||||
{"name": "m1", "tags": ["anime"]},
|
||||
{"name": "m2", "tags": ["realistic"]},
|
||||
]
|
||||
|
||||
# Only exclude tags, no include tags
|
||||
criteria = FilterCriteria(
|
||||
tags={"nsfw": "exclude"},
|
||||
tag_logic="all"
|
||||
)
|
||||
result = filter_set.apply(data, criteria)
|
||||
# Both should match since no include filters
|
||||
assert len(result) == 2
|
||||
|
||||
def test_tag_logic_with_none_tags_field(self):
|
||||
"""Test tag_logic handles items with None tags field."""
|
||||
filter_set = ModelFilterSet(StubSettings())
|
||||
data = [
|
||||
{"name": "m1", "tags": ["anime", "realistic"]},
|
||||
{"name": "m2", "tags": None},
|
||||
{"name": "m3", "tags": ["anime"]},
|
||||
]
|
||||
|
||||
criteria = FilterCriteria(
|
||||
tags={"anime": "include", "realistic": "include"},
|
||||
tag_logic="all"
|
||||
)
|
||||
result = filter_set.apply(data, criteria)
|
||||
# Only m1 has both anime and realistic
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "m1"
|
||||
Reference in New Issue
Block a user