# -*- coding: utf-8; -*-

import datetime
import decimal
from unittest import TestCase
from unittest.mock import patch, MagicMock

import sqlalchemy as sa
from sqlalchemy import orm
from paginate import Page
from paginate_sqlalchemy import SqlalchemyOrmPage
from pyramid import testing

from wuttjamaican.conf import WuttaConfig
from wuttaweb.grids import base as mod
from wuttaweb.grids.filters import GridFilter, StringAlchemyFilter, default_sqlalchemy_filters
from wuttaweb.util import FieldList
from wuttaweb.forms import Form
from wuttaweb.testing import WebTestCase


class TestGrid(WebTestCase):

    def make_grid(self, request=None, **kwargs):
        return mod.Grid(request or self.request, **kwargs)

    def test_constructor(self):

        # empty
        grid = self.make_grid()
        self.assertIsNone(grid.key)
        self.assertEqual(grid.columns, [])
        self.assertIsNone(grid.data)

        # now with columns
        grid = self.make_grid(columns=['foo', 'bar'])
        self.assertIsInstance(grid.columns, FieldList)
        self.assertEqual(grid.columns, ['foo', 'bar'])

    def test_constructor_sorting(self):
        model = self.app.model

        # defaults, not sortable
        grid = self.make_grid()
        self.assertFalse(grid.sortable)
        self.assertTrue(grid.sort_on_backend)
        self.assertEqual(grid.sorters, {})
        self.assertEqual(grid.sort_defaults, [])

        # defaults, sortable
        grid = self.make_grid(sortable=True)
        self.assertTrue(grid.sortable)
        self.assertTrue(grid.sort_on_backend)
        self.assertEqual(grid.sorters, {})
        self.assertEqual(grid.sort_defaults, [])

        # sorters may be pre-populated
        grid = self.make_grid(model_class=model.Setting, sortable=True)
        self.assertEqual(len(grid.sorters), 2)
        self.assertIn('name', grid.sorters)
        self.assertIn('value', grid.sorters)
        self.assertEqual(grid.sort_defaults, [])

        # sort defaults as str
        grid = self.make_grid(model_class=model.Setting, sortable=True,
                              sort_defaults='name')
        self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'asc')])

        # sort defaults as tuple
        grid = self.make_grid(model_class=model.Setting, sortable=True,
                              sort_defaults=('name', 'desc'))
        self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')])

        # sort defaults as list w/ single tuple
        grid = self.make_grid(model_class=model.Setting, sortable=True,
                              sort_defaults=[('name', 'desc')])
        self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')])

        # multi-column defaults
        grid = self.make_grid(model_class=model.Setting, sortable=True,
                              sort_multiple=True,
                              sort_defaults=[('name', 'desc'), ('value', 'asc')])
        self.assertTrue(grid.sort_multiple)
        self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc'),
                                              mod.SortInfo('value', 'asc')])

        # multi-column sort disabled for oruga
        self.request.use_oruga = True
        grid = self.make_grid(model_class=model.Setting, sortable=True,
                              sort_multiple=True)
        self.assertFalse(grid.sort_multiple)

    def test_constructor_filtering(self):
        model = self.app.model

        # defaults, not filterable
        grid = self.make_grid()
        self.assertFalse(grid.filterable)
        self.assertEqual(grid.filters, {})

        # defaults, filterable
        grid = self.make_grid(filterable=True)
        self.assertTrue(grid.filterable)
        self.assertEqual(grid.filters, {})

        # filters may be pre-populated
        with patch.object(mod.Grid, 'make_filter', return_value=42):
            grid = self.make_grid(model_class=model.Setting, filterable=True)
            self.assertEqual(len(grid.filters), 2)
            self.assertIn('name', grid.filters)
            self.assertIn('value', grid.filters)

        # can specify filters
        grid = self.make_grid(model_class=model.Setting, filterable=True,
                              filters={'name': 42})
        self.assertTrue(grid.filterable)
        self.assertEqual(grid.filters, {'name': 42})

    def test_vue_tagname(self):
        grid = self.make_grid()
        self.assertEqual(grid.vue_tagname, 'wutta-grid')

    def test_vue_component(self):
        grid = self.make_grid()
        self.assertEqual(grid.vue_component, 'WuttaGrid')

    def test_get_columns(self):
        model = self.app.model

        # empty
        grid = self.make_grid()
        self.assertEqual(grid.columns, [])
        self.assertEqual(grid.get_columns(), [])

        # explicit
        grid = self.make_grid(columns=['foo', 'bar'])
        self.assertEqual(grid.columns, ['foo', 'bar'])
        self.assertEqual(grid.get_columns(), ['foo', 'bar'])

        # derived from model
        grid = self.make_grid(model_class=model.Setting)
        self.assertEqual(grid.columns, ['name', 'value'])
        self.assertEqual(grid.get_columns(), ['name', 'value'])

    def test_append(self):
        grid = self.make_grid(columns=['one', 'two'])
        self.assertEqual(grid.columns, ['one', 'two'])
        grid.append('one', 'two', 'three')
        self.assertEqual(grid.columns, ['one', 'two', 'three'])

    def test_remove(self):
        grid = self.make_grid(columns=['one', 'two', 'three', 'four'])
        self.assertEqual(grid.columns, ['one', 'two', 'three', 'four'])
        grid.remove('two', 'three')
        self.assertEqual(grid.columns, ['one', 'four'])

    def test_set_label(self):
        model = self.app.model
        with patch.object(mod.Grid, 'make_filter'):
            # nb. filters are MagicMock instances
            grid = self.make_grid(model_class=model.Setting, filterable=True)
        self.assertEqual(grid.labels, {})

        # basic
        grid.set_label('name', "NAME COL")
        self.assertEqual(grid.labels['name'], "NAME COL")

        # can replace label
        grid.set_label('name', "Different")
        self.assertEqual(grid.labels['name'], "Different")
        self.assertEqual(grid.get_label('name'), "Different")

        # can update only column, not filter
        self.assertEqual(grid.labels, {'name': "Different"})
        self.assertIn('name', grid.filters)
        self.assertEqual(grid.filters['name'].label, "Different")
        grid.set_label('name', "COLUMN ONLY", column_only=True)
        self.assertEqual(grid.get_label('name'), "COLUMN ONLY")
        self.assertEqual(grid.filters['name'].label, "Different")

    def test_get_label(self):
        grid = self.make_grid(columns=['foo', 'bar'])
        self.assertEqual(grid.labels, {})

        # default derived from key
        self.assertEqual(grid.get_label('foo'), "Foo")

        # can override
        grid.set_label('foo', "Different")
        self.assertEqual(grid.get_label('foo'), "Different")

    def test_set_renderer(self):
        grid = self.make_grid(columns=['foo', 'bar'])
        self.assertEqual(grid.renderers, {})

        def render1(record, key, value):
            pass

        # basic
        grid.set_renderer('foo', render1)
        self.assertIs(grid.renderers['foo'], render1)

        def render2(record, key, value, extra=None):
            return extra

        # can pass kwargs to get a partial
        grid.set_renderer('foo', render2, extra=42)
        self.assertIsNot(grid.renderers['foo'], render2)
        self.assertEqual(grid.renderers['foo'](None, None, None), 42)

        # can use built-in string shortcut
        grid.set_renderer('foo', 'quantity')
        obj = MagicMock(foo=42.00)
        self.assertEqual(grid.renderers['foo'](obj, 'foo', 42.00), '42')

    def test_set_default_renderers(self):
        model = self.app.model

        # no defaults for "plain" schema
        grid = self.make_grid(columns=['foo', 'bar'])
        self.assertEqual(grid.renderers, {})

        # no defaults for "plain" mapped class
        grid = self.make_grid(model_class=model.Setting)
        self.assertEqual(grid.renderers, {})

        def myrender(obj, key, value):
            return value

        # renderer set for datetime mapped field
        grid = self.make_grid(model_class=model.Upgrade)
        self.assertIn('created', grid.renderers)
        self.assertIsNot(grid.renderers['created'], myrender)

        # renderer *not* set for datetime, if override present
        grid = self.make_grid(model_class=model.Upgrade,
                              renderers={'created': myrender})
        self.assertIn('created', grid.renderers)
        self.assertIs(grid.renderers['created'], myrender)

        # renderer set for boolean mapped field
        grid = self.make_grid(model_class=model.Upgrade)
        self.assertIn('executing', grid.renderers)
        self.assertIsNot(grid.renderers['executing'], myrender)

        # renderer *not* set for boolean, if override present
        grid = self.make_grid(model_class=model.Upgrade,
                              renderers={'executing': myrender})
        self.assertIn('executing', grid.renderers)
        self.assertIs(grid.renderers['executing'], myrender)

        # nb. as of writing we have no Date columns in default schema,
        # so must invent one to test that type
        class SomeFoolery(model.Base):
            __tablename__ = 'somefoolery'
            id = sa.Column(sa.Integer(), primary_key=True)
            created = sa.Column(sa.Date())

        # renderer set for date mapped field
        grid = self.make_grid(model_class=SomeFoolery)
        self.assertIn('created', grid.renderers)
        self.assertIsNot(grid.renderers['created'], myrender)

    def test_linked_columns(self):
        grid = self.make_grid(columns=['foo', 'bar'])
        self.assertEqual(grid.linked_columns, [])
        self.assertFalse(grid.is_linked('foo'))

        grid.set_link('foo')
        self.assertEqual(grid.linked_columns, ['foo'])
        self.assertTrue(grid.is_linked('foo'))
        self.assertFalse(grid.is_linked('bar'))

        grid.set_link('bar')
        self.assertEqual(grid.linked_columns, ['foo', 'bar'])
        self.assertTrue(grid.is_linked('foo'))
        self.assertTrue(grid.is_linked('bar'))

        grid.set_link('foo', False)
        self.assertEqual(grid.linked_columns, ['bar'])
        self.assertFalse(grid.is_linked('foo'))
        self.assertTrue(grid.is_linked('bar'))

    def test_hidden_columns(self):
        grid = self.make_grid(columns=['foo', 'bar'])
        self.assertEqual(grid.hidden_columns, [])
        self.assertFalse(grid.is_hidden('foo'))

        grid.set_hidden('foo')
        self.assertEqual(grid.hidden_columns, ['foo'])
        self.assertTrue(grid.is_hidden('foo'))
        self.assertFalse(grid.is_hidden('bar'))

        grid.set_hidden('bar')
        self.assertEqual(grid.hidden_columns, ['foo', 'bar'])
        self.assertTrue(grid.is_hidden('foo'))
        self.assertTrue(grid.is_hidden('bar'))

        grid.set_hidden('foo', False)
        self.assertEqual(grid.hidden_columns, ['bar'])
        self.assertFalse(grid.is_hidden('foo'))
        self.assertTrue(grid.is_hidden('bar'))

    def test_searchable_columns(self):
        grid = self.make_grid(columns=['foo', 'bar'])
        self.assertEqual(grid.searchable_columns, set())
        self.assertFalse(grid.is_searchable('foo'))

        grid.set_searchable('foo')
        self.assertEqual(grid.searchable_columns, {'foo'})
        self.assertTrue(grid.is_searchable('foo'))
        self.assertFalse(grid.is_searchable('bar'))

        grid.set_searchable('bar')
        self.assertEqual(grid.searchable_columns, {'foo', 'bar'})
        self.assertTrue(grid.is_searchable('foo'))
        self.assertTrue(grid.is_searchable('bar'))

        grid.set_searchable('foo', False)
        self.assertEqual(grid.searchable_columns, {'bar'})
        self.assertFalse(grid.is_searchable('foo'))
        self.assertTrue(grid.is_searchable('bar'))

    def test_add_action(self):
        grid = self.make_grid()
        self.assertEqual(len(grid.actions), 0)

        grid.add_action('view')
        self.assertEqual(len(grid.actions), 1)
        self.assertIsInstance(grid.actions[0], mod.GridAction)

    def test_set_tools(self):
        grid = self.make_grid()
        self.assertEqual(grid.tools, {})

        # null
        grid.set_tools(None)
        self.assertEqual(grid.tools, {})

        # empty
        grid.set_tools({})
        self.assertEqual(grid.tools, {})

        # full dict is replaced
        grid.tools = {'foo': 'bar'}
        self.assertEqual(grid.tools, {'foo': 'bar'})
        grid.set_tools({'bar': 'baz'})
        self.assertEqual(grid.tools, {'bar': 'baz'})

        # can specify as list of html elements
        grid.set_tools(['foo', 'bar'])
        self.assertEqual(len(grid.tools), 2)
        self.assertEqual(list(grid.tools.values()), ['foo', 'bar'])

    def test_add_tool(self):
        grid = self.make_grid()
        self.assertEqual(grid.tools, {})

        # with key
        grid.add_tool('foo', key='foo')
        self.assertEqual(grid.tools, {'foo': 'foo'})

        # without key
        grid.add_tool('bar')
        self.assertEqual(len(grid.tools), 2)
        self.assertEqual(list(grid.tools.values()), ['foo', 'bar'])

    def test_get_pagesize_options(self):
        grid = self.make_grid()

        # default
        options = grid.get_pagesize_options()
        self.assertEqual(options, [5, 10, 20, 50, 100, 200])

        # override default
        options = grid.get_pagesize_options(default=[42])
        self.assertEqual(options, [42])

        # from config
        self.config.setdefault('wuttaweb.grids.default_pagesize_options', '1 2 3')
        options = grid.get_pagesize_options()
        self.assertEqual(options, [1, 2, 3])

    def test_get_pagesize(self):
        grid = self.make_grid()

        # default
        size = grid.get_pagesize()
        self.assertEqual(size, 20)

        # override default
        size = grid.get_pagesize(default=42)
        self.assertEqual(size, 42)

        # override default options
        self.config.setdefault('wuttaweb.grids.default_pagesize_options', '10 15 30')
        grid = self.make_grid()
        size = grid.get_pagesize()
        self.assertEqual(size, 10)

        # from config
        self.config.setdefault('wuttaweb.grids.default_pagesize', '15')
        size = grid.get_pagesize()
        self.assertEqual(size, 15)

    ##############################
    # configuration methods
    ##############################

    def test_load_settings(self):
        model = self.app.model

        # nb. first use a paging grid
        grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True,
                              pagesize=20, page=1)

        # settings are loaded, applied, saved
        self.assertEqual(grid.page, 1)
        self.assertNotIn('grid.foo.page', self.request.session)
        self.request.GET = {'pagesize': '10', 'page': '2'}
        grid.load_settings()
        self.assertEqual(grid.page, 2)
        self.assertEqual(self.request.session['grid.foo.page'], 2)

        # can skip the saving step
        self.request.GET = {'pagesize': '10', 'page': '3'}
        grid.load_settings(persist=False)
        self.assertEqual(grid.page, 3)
        self.assertEqual(self.request.session['grid.foo.page'], 2)

        # no error for non-paginated grid
        grid = self.make_grid(key='foo', paginated=False)
        grid.load_settings()
        self.assertFalse(grid.paginated)

        # nb. next use a sorting grid
        grid = self.make_grid(key='settings', model_class=model.Setting,
                              sortable=True, sort_on_backend=True)

        # settings are loaded, applied, saved
        self.assertEqual(grid.sort_defaults, [])
        self.assertFalse(hasattr(grid, 'active_sorters'))
        self.request.GET = {'sort1key': 'name', 'sort1dir': 'desc'}
        grid.load_settings()
        self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'desc'}])
        self.assertEqual(self.request.session['grid.settings.sorters.length'], 1)
        self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
        self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc')

        # can skip the saving step
        self.request.GET = {'sort1key': 'name', 'sort1dir': 'asc'}
        grid.load_settings(persist=False)
        self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}])
        self.assertEqual(self.request.session['grid.settings.sorters.length'], 1)
        self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
        self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc')

        # no error for non-sortable grid
        grid = self.make_grid(key='foo', sortable=False)
        grid.load_settings()
        self.assertFalse(grid.sortable)

        # with sort defaults
        grid = self.make_grid(model_class=model.Setting, sortable=True,
                              sort_on_backend=True, sort_defaults='name')
        self.assertFalse(hasattr(grid, 'active_sorters'))
        grid.load_settings()
        self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}])

        # with multi-column sort defaults
        grid = self.make_grid(model_class=model.Setting, sortable=True,
                              sort_on_backend=True)
        grid.sort_defaults = [
            mod.SortInfo('name', 'asc'),
            mod.SortInfo('value', 'desc'),
        ]
        self.assertFalse(hasattr(grid, 'active_sorters'))
        grid.load_settings()
        self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}])

        # load settings from session when nothing is in request
        self.request.GET = {}
        self.request.session.invalidate()
        self.assertNotIn('grid.settings.sorters.length', self.request.session)
        self.request.session['grid.settings.sorters.length'] = 1
        self.request.session['grid.settings.sorters.1.key'] = 'name'
        self.request.session['grid.settings.sorters.1.dir'] = 'desc'
        grid = self.make_grid(key='settings', model_class=model.Setting,
                              sortable=True, sort_on_backend=True,
                              paginated=True, paginate_on_backend=True)
        self.assertFalse(hasattr(grid, 'active_sorters'))
        grid.load_settings()
        self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'desc'}])

        # filter settings are loaded, applied, saved
        grid = self.make_grid(key='settings', model_class=model.Setting,
                              filterable=True)
        self.assertEqual(len(grid.filters), 2)
        self.assertEqual(len(grid.active_filters), 0)
        self.assertNotIn('grid.settings.filter.name.active', self.request.session)
        self.assertNotIn('grid.settings.filter.value.active', self.request.session)
        self.request.GET = {'name': 'john', 'name.verb': 'contains'}
        grid.load_settings()
        self.assertTrue(grid.filters['name'].active)
        self.assertEqual(grid.filters['name'].verb, 'contains')
        self.assertEqual(grid.filters['name'].value, 'john')
        self.assertTrue(self.request.session['grid.settings.filter.name.active'])
        self.assertEqual(self.request.session['grid.settings.filter.name.verb'], 'contains')
        self.assertEqual(self.request.session['grid.settings.filter.name.value'], 'john')

        # filter + sort settings are loaded, applied, saved
        self.request.session.invalidate()
        grid = self.make_grid(key='settings', model_class=model.Setting,
                              sortable=True, filterable=True)
        self.assertEqual(len(grid.filters), 2)
        self.assertEqual(len(grid.active_filters), 0)
        self.assertNotIn('grid.settings.filter.name.active', self.request.session)
        self.assertNotIn('grid.settings.filter.value.active', self.request.session)
        self.assertNotIn('grid.settings.sorters.length', self.request.session)
        self.request.GET = {'name': 'john', 'name.verb': 'contains',
                            'sort1key': 'name', 'sort1dir': 'asc'}
        grid.load_settings()
        self.assertTrue(grid.filters['name'].active)
        self.assertEqual(grid.filters['name'].verb, 'contains')
        self.assertEqual(grid.filters['name'].value, 'john')
        self.assertTrue(self.request.session['grid.settings.filter.name.active'])
        self.assertEqual(self.request.session['grid.settings.filter.name.verb'], 'contains')
        self.assertEqual(self.request.session['grid.settings.filter.name.value'], 'john')
        self.assertEqual(self.request.session['grid.settings.sorters.length'], 1)
        self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
        self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'asc')

        # can reset view to defaults
        self.request.GET = {'reset-view': 'true'}
        grid.load_settings()
        self.assertEqual(grid.active_filters, [])
        self.assertIsNone(grid.filters['name'].value)

    def test_request_has_settings(self):
        model = self.app.model
        grid = self.make_grid(key='settings', model_class=model.Setting)

        # paging
        self.assertFalse(grid.request_has_settings('page'))
        with patch.object(grid, 'paginated', new=True):
            with patch.object(self.request, 'GET', new={'pagesize': '20'}):
                self.assertTrue(grid.request_has_settings('page'))
            with patch.object(self.request, 'GET', new={'page': '1'}):
                self.assertTrue(grid.request_has_settings('page'))

        # sorting
        self.assertFalse(grid.request_has_settings('sort'))
        with patch.object(grid, 'sortable', new=True):
            with patch.object(self.request, 'GET', new={'sort1key': 'name'}):
                self.assertTrue(grid.request_has_settings('sort'))

        # filtering
        grid = self.make_grid(key='settings', model_class=model.Setting, filterable=True)
        self.assertFalse(grid.request_has_settings('filter'))
        with patch.object(grid, 'filterable', new=True):
            with patch.object(self.request, 'GET', new={'name': 'john', 'name.verb': 'contains'}):
                self.assertTrue(grid.request_has_settings('filter'))
        with patch.object(self.request, 'GET', new={'filter': '1'}):
            self.assertTrue(grid.request_has_settings('filter'))

    def test_get_setting(self):
        grid = self.make_grid(key='foo')
        settings = {}

        # default is null
        value = grid.get_setting(settings, 'pagesize')
        self.assertIsNone(value)

        # can read value from user session
        self.request.session['grid.foo.pagesize'] = 15
        value = grid.get_setting(settings, 'pagesize', src='session')
        self.assertEqual(value, 15)

        # string value not normalized
        self.request.session['grid.foo.pagesize'] = '15'
        value = grid.get_setting(settings, 'pagesize', src='session')
        self.assertEqual(value, '15')
        self.assertNotEqual(value, 15)

        # but can be normalized
        self.request.session['grid.foo.pagesize'] = '15'
        value = grid.get_setting(settings, 'pagesize', src='session', normalize=int)
        self.assertEqual(value, 15)

        # can read value from request
        self.request.GET = {'pagesize': '25'}
        value = grid.get_setting(settings, 'pagesize', src='request', normalize=int)
        self.assertEqual(value, 25)

        # null when normalization fails
        self.request.GET = {'pagesize': 'invalid'}
        value = grid.get_setting(settings, 'pagesize', src='request', normalize=int)
        self.assertIsNone(value)

        # reset
        del self.request.session['grid.foo.pagesize']
        self.request.GET = {}

        # value can come from provided settings
        settings['pagesize'] = '35'
        value = grid.get_setting(settings, 'pagesize', src='session', normalize=int)
        self.assertEqual(value, 35)

    def test_update_filter_settings(self):
        model = self.app.model

        # nothing happens if not filterable
        grid = self.make_grid(key='settings', model_class=model.Setting)
        settings = {}
        self.request.session['grid.settings.filter.name.active'] = True
        self.request.session['grid.settings.filter.name.verb'] = 'contains'
        self.request.session['grid.settings.filter.name.value'] = 'john'
        grid.update_filter_settings(settings, src='session')
        self.assertEqual(settings, {})

        # nb. now use a filterable grid
        grid = self.make_grid(key='settings', model_class=model.Setting,
                              filterable=True)

        # settings are updated from session
        settings = {}
        self.request.session['grid.settings.filter.name.active'] = True
        self.request.session['grid.settings.filter.name.verb'] = 'contains'
        self.request.session['grid.settings.filter.name.value'] = 'john'
        grid.update_filter_settings(settings, src='session')
        self.assertTrue(settings['filter.name.active'])
        self.assertEqual(settings['filter.name.verb'], 'contains')
        self.assertEqual(settings['filter.name.value'], 'john')

        # settings are updated from request
        self.request.GET = {'value': 'sally', 'value.verb': 'contains'}
        grid.update_filter_settings(settings, src='request')
        self.assertFalse(settings['filter.name.active'])
        self.assertTrue(settings['filter.value.active'])
        self.assertEqual(settings['filter.value.verb'], 'contains')
        self.assertEqual(settings['filter.value.value'], 'sally')

    def test_update_sort_settings(self):
        model = self.app.model

        # nothing happens if not sortable
        grid = self.make_grid(key='settings', model_class=model.Setting)
        settings = {'sorters.length': 0}
        self.request.session['grid.settings.sorters.length'] = 1
        self.request.session['grid.settings.sorters.1.key'] = 'name'
        self.request.session['grid.settings.sorters.1.dir'] = 'asc'
        grid.update_sort_settings(settings, src='session')
        self.assertEqual(settings['sorters.length'], 0)

        # nb. now use a sortable grid
        grid = self.make_grid(key='settings', model_class=model.Setting,
                              sortable=True, sort_on_backend=True)

        # settings are updated from session
        settings = {'sorters.length': 1, 'sorters.1.key': 'name', 'sorters.1.dir': 'asc'}
        self.request.session['grid.settings.sorters.length'] = 1
        self.request.session['grid.settings.sorters.1.key'] = 'name'
        self.request.session['grid.settings.sorters.1.dir'] = 'asc'
        grid.update_sort_settings(settings, src='session')
        self.assertEqual(settings['sorters.length'], 1)
        self.assertEqual(settings['sorters.1.key'], 'name')
        self.assertEqual(settings['sorters.1.dir'], 'asc')

        # settings are updated from request
        self.request.GET = {'sort1key': 'value', 'sort1dir': 'desc'}
        grid.update_sort_settings(settings, src='request')
        self.assertEqual(settings['sorters.length'], 1)
        self.assertEqual(settings['sorters.1.key'], 'value')
        self.assertEqual(settings['sorters.1.dir'], 'desc')

    def test_update_page_settings(self):

        # nothing happens if not paginated
        grid = self.make_grid(key='foo')
        settings = {'pagesize': 20, 'page': 1}
        self.request.session['grid.foo.pagesize'] = 10
        self.request.session['grid.foo.page'] = 2
        grid.update_page_settings(settings)
        self.assertEqual(settings['pagesize'], 20)
        self.assertEqual(settings['page'], 1)

        # nb. now use a paginated grid
        grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True)

        # settings are updated from session
        settings = {'pagesize': 20, 'page': 1}
        self.request.session['grid.foo.pagesize'] = 10
        self.request.session['grid.foo.page'] = 2
        grid.update_page_settings(settings)
        self.assertEqual(settings['pagesize'], 10)
        self.assertEqual(settings['page'], 2)

        # settings are updated from request
        self.request.GET = {'pagesize': '15', 'page': '4'}
        grid.update_page_settings(settings)
        self.assertEqual(settings['pagesize'], 15)
        self.assertEqual(settings['page'], 4)

    def test_persist_settings(self):
        model = self.app.model

        # nb. start out with paginated-only grid
        grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True)

        # invalid dest
        self.assertRaises(ValueError, grid.persist_settings, {}, dest='doesnotexist')

        # nb. no error if empty settings, but it saves null values
        grid.persist_settings({}, dest='session')
        self.assertIsNone(self.request.session['grid.foo.page'])

        # provided values are saved
        grid.persist_settings({'pagesize': 15, 'page': 3}, dest='session')
        self.assertEqual(self.request.session['grid.foo.page'], 3)

        # nb. now switch to sortable-only grid
        grid = self.make_grid(key='settings', model_class=model.Setting,
                              sortable=True, sort_on_backend=True)

        # no error if empty settings; does not save values
        grid.persist_settings({}, dest='session')
        self.assertNotIn('grid.settings.sorters.length', self.request.session)

        # provided values are saved
        grid.persist_settings({'sorters.length': 2,
                               'sorters.1.key': 'name',
                               'sorters.1.dir': 'desc',
                               'sorters.2.key': 'value',
                               'sorters.2.dir': 'asc'},
                              dest='session')
        self.assertEqual(self.request.session['grid.settings.sorters.length'], 2)
        self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
        self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc')
        self.assertEqual(self.request.session['grid.settings.sorters.2.key'], 'value')
        self.assertEqual(self.request.session['grid.settings.sorters.2.dir'], 'asc')

        # old values removed when new are saved
        grid.persist_settings({'sorters.length': 1,
                               'sorters.1.key': 'name',
                               'sorters.1.dir': 'desc'},
                              dest='session')
        self.assertEqual(self.request.session['grid.settings.sorters.length'], 1)
        self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
        self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc')
        self.assertNotIn('grid.settings.sorters.2.key', self.request.session)
        self.assertNotIn('grid.settings.sorters.2.dir', self.request.session)

        # nb. now switch to filterable-only grid
        grid = self.make_grid(key='settings', model_class=model.Setting,
                              filterable=True)
        self.assertIn('name', grid.filters)
        self.assertEqual(grid.filters['name'].key, 'name')

        # no error if empty settings; does not save values
        grid.persist_settings({}, dest='session')
        self.assertNotIn('grid.settings.filters.name', self.request.session)

        # provided values are saved
        grid.persist_settings({'filter.name.active': True,
                               'filter.name.verb': 'contains',
                               'filter.name.value': 'john'},
                              dest='session')
        self.assertTrue(self.request.session['grid.settings.filter.name.active'])
        self.assertEqual(self.request.session['grid.settings.filter.name.verb'], 'contains')
        self.assertEqual(self.request.session['grid.settings.filter.name.value'], 'john')

    ##############################
    # sorting methods
    ##############################

    def test_make_backend_sorters(self):
        model = self.app.model

        # default is empty
        grid = self.make_grid()
        sorters = grid.make_backend_sorters()
        self.assertEqual(sorters, {})

        # makes sorters if model class
        grid = self.make_grid(model_class=model.Setting)
        sorters = grid.make_backend_sorters()
        self.assertEqual(len(sorters), 2)
        self.assertIn('name', sorters)
        self.assertIn('value', sorters)

        # does not replace supplied sorters
        grid = self.make_grid(model_class=model.Setting)
        mysorters = {'value': 42}
        sorters = grid.make_backend_sorters(mysorters)
        self.assertEqual(len(sorters), 2)
        self.assertIn('name', sorters)
        self.assertIn('value', sorters)
        self.assertEqual(sorters['value'], 42)
        self.assertEqual(mysorters['value'], 42)

    def test_make_sorter(self):
        model = self.app.model
        sample_data = [
            {'name': 'foo1', 'value': 'ONE'},
            {'name': 'foo2', 'value': 'two'},
            {'name': 'foo3', 'value': 'three'},
            {'name': 'foo4', 'value': 'four'},
            {'name': 'foo5', 'value': 'five'},
            {'name': 'foo6', 'value': 'six'},
            {'name': 'foo7', 'value': 'seven'},
            {'name': 'foo8', 'value': 'eight'},
            {'name': 'foo9', 'value': 'nine'},
        ]
        for setting in sample_data:
            self.app.save_setting(self.session, setting['name'], setting['value'])
        self.session.commit()
        sample_query = self.session.query(model.Setting)

        # plain data
        grid = self.make_grid(columns=['name', 'value'])
        sorter = grid.make_sorter('name')
        sorted_data = sorter(sample_data, 'desc')
        self.assertEqual(sorted_data[0], {'name': 'foo9', 'value': 'nine'})
        sorted_data = sorter(sample_data, 'asc')
        self.assertEqual(sorted_data[0], {'name': 'foo1', 'value': 'ONE'})

        # model class, but still plain data
        grid = self.make_grid(model_class=model.Setting)
        sorter = grid.make_sorter('name')
        sorted_data = sorter(sample_data, 'desc')
        self.assertEqual(sorted_data[0], {'name': 'foo9', 'value': 'nine'})
        sorted_data = sorter(sample_data, 'asc')
        self.assertEqual(sorted_data[0], {'name': 'foo1', 'value': 'ONE'})

        # repeat previous test, w/ model property
        grid = self.make_grid(model_class=model.Setting)
        sorter = grid.make_sorter(model.Setting.name)
        sorted_data = sorter(sample_data, 'desc')
        self.assertEqual(sorted_data[0], {'name': 'foo9', 'value': 'nine'})
        sorted_data = sorter(sample_data, 'asc')
        self.assertEqual(sorted_data[0], {'name': 'foo1', 'value': 'ONE'})

        # sqlalchemy query
        grid = self.make_grid(model_class=model.Setting)
        sorter = grid.make_sorter('name')
        sorted_query = sorter(sample_query, 'desc')
        sorted_data = sorted_query.all()
        self.assertEqual(dict(sorted_data[0]), {'name': 'foo9', 'value': 'nine'})
        sorted_query = sorter(sample_query, 'asc')
        sorted_data = sorted_query.all()
        self.assertEqual(dict(sorted_data[0]), {'name': 'foo1', 'value': 'ONE'})

        # repeat previous test, w/ model property
        grid = self.make_grid(model_class=model.Setting)
        sorter = grid.make_sorter(model.Setting.name)
        sorted_query = sorter(sample_query, 'desc')
        sorted_data = sorted_query.all()
        self.assertEqual(dict(sorted_data[0]), {'name': 'foo9', 'value': 'nine'})
        sorted_query = sorter(sample_query, 'asc')
        sorted_data = sorted_query.all()
        self.assertEqual(dict(sorted_data[0]), {'name': 'foo1', 'value': 'ONE'})

        # sortfunc for "invalid" column will fail when called; however
        # it can work for manual sort w/ custom keyfunc
        grid = self.make_grid(model_class=model.Setting)
        sorter = grid.make_sorter('doesnotexist')
        self.assertRaises(TypeError, sorter, sample_query, 'desc')
        self.assertRaises(KeyError, sorter, sample_data, 'desc')
        sorter = grid.make_sorter('doesnotexist', keyfunc=lambda obj: obj['name'])
        sorted_data = sorter(sample_data, 'desc')
        self.assertEqual(len(sorted_data), 9)
        sorted_data = sorter(sample_data, 'asc')
        self.assertEqual(len(sorted_data), 9)

        # case folding is on by default
        grid = self.make_grid(model_class=model.Setting)
        sorter = grid.make_sorter('value')
        sorted_data = sorter(sample_data, 'desc')
        self.assertEqual(dict(sorted_data[0]), {'name': 'foo2', 'value': 'two'})
        sorted_data = sorter(sample_data, 'asc')
        self.assertEqual(dict(sorted_data[0]), {'name': 'foo8', 'value': 'eight'})

        # results are different with case folding off
        grid = self.make_grid(model_class=model.Setting)
        sorter = grid.make_sorter('value', foldcase=False)
        sorted_data = sorter(sample_data, 'desc')
        self.assertEqual(dict(sorted_data[0]), {'name': 'foo2', 'value': 'two'})
        sorted_data = sorter(sample_data, 'asc')
        self.assertEqual(dict(sorted_data[0]), {'name': 'foo1', 'value': 'ONE'})

    def test_set_joiner(self):

        # basic
        grid = self.make_grid(columns=['foo', 'bar'], sortable=True, sort_on_backend=True)
        self.assertEqual(grid.joiners, {})
        grid.set_joiner('foo', 42)
        self.assertEqual(grid.joiners, {'foo': 42})

    def test_remove_joiner(self):

        # basic
        grid = self.make_grid(columns=['foo', 'bar'], sortable=True, sort_on_backend=True,
                              joiners={'foo': 42})
        self.assertEqual(grid.joiners, {'foo': 42})
        grid.remove_joiner('foo')
        self.assertEqual(grid.joiners, {})

    def test_set_sorter(self):
        model = self.app.model

        # explicit sortfunc
        grid = self.make_grid()
        self.assertEqual(grid.sorters, {})
        sortfunc = lambda data, direction: data
        grid.set_sorter('foo', sortfunc)
        self.assertIs(grid.sorters['foo'], sortfunc)

        # auto from model property
        grid = self.make_grid(model_class=model.Setting, sortable=True, sorters={})
        self.assertEqual(grid.sorters, {})
        grid.set_sorter('name', model.Setting.name)
        self.assertTrue(callable(grid.sorters['name']))

        # auto from column name
        grid = self.make_grid(model_class=model.Setting, sortable=True, sorters={})
        self.assertEqual(grid.sorters, {})
        grid.set_sorter('name', 'name')
        self.assertTrue(callable(grid.sorters['name']))

        # auto from key
        grid = self.make_grid(model_class=model.Setting, sortable=True, sorters={})
        self.assertEqual(grid.sorters, {})
        grid.set_sorter('name')
        self.assertTrue(callable(grid.sorters['name']))

    def test_remove_sorter(self):
        model = self.app.model

        # basics
        grid = self.make_grid(model_class=model.Setting, sortable=True)
        self.assertEqual(len(grid.sorters), 2)
        self.assertIn('name', grid.sorters)
        self.assertIn('value', grid.sorters)
        grid.remove_sorter('value')
        self.assertNotIn('value', grid.sorters)

    def test_set_sort_defaults(self):
        model = self.app.model
        grid = self.make_grid(model_class=model.Setting, sortable=True)
        self.assertEqual(grid.sort_defaults, [])

        # can set just sortkey
        grid.set_sort_defaults('name')
        self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'asc')])

        # can set sortkey, sortdir
        grid.set_sort_defaults('name', 'desc')
        self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')])

        # can set sortkey, sortdir as tuple
        grid.set_sort_defaults(('value', 'asc'))
        self.assertEqual(grid.sort_defaults, [mod.SortInfo('value', 'asc')])

        # can set as list
        grid.sort_multiple = True
        grid.set_sort_defaults([('value', 'asc'), ('name', 'desc')])
        self.assertEqual(grid.sort_defaults, [mod.SortInfo('value', 'asc'),
                                              mod.SortInfo('name', 'desc')])

        # list is pruned if multi-sort disabled
        grid.sort_multiple = False
        grid.set_sort_defaults([('value', 'asc'), ('name', 'desc')])
        self.assertEqual(grid.sort_defaults, [mod.SortInfo('value', 'asc')])

        # error if any other single arg
        self.assertRaises(ValueError, grid.set_sort_defaults, 42)

        # error if more than 2 args
        self.assertRaises(ValueError, grid.set_sort_defaults, 'name', 'asc', 'value', 'desc')

    def test_is_sortable(self):
        model = self.app.model

        # basics, frontend sorting
        grid = self.make_grid(model_class=model.Setting, sortable=True, sort_on_backend=False)
        self.assertTrue(grid.is_sortable('name'))
        self.assertTrue(grid.is_sortable('value'))
        grid.remove_sorter('value')
        # nb. columns are always sortable for frontend, despite remove_sorter()
        self.assertTrue(grid.is_sortable('value'))
        # nb. when grid is not sortable, no column is either
        grid.sortable = False
        self.assertFalse(grid.is_sortable('name'))

        # same test but with backend sorting
        grid = self.make_grid(model_class=model.Setting, sortable=True, sort_on_backend=True)
        self.assertTrue(grid.is_sortable('name'))
        self.assertTrue(grid.is_sortable('value'))
        grid.remove_sorter('value')
        self.assertFalse(grid.is_sortable('value'))
        # nb. when grid is not sortable, no column is either
        grid.sortable = False
        self.assertFalse(grid.is_sortable('name'))

    def test_make_backend_filters(self):
        model = self.app.model

        # default is empty
        grid = self.make_grid()
        filters = grid.make_backend_filters()
        self.assertEqual(filters, {})

        # makes filters if model class
        with patch.object(mod.Grid, 'make_filter'):
            # nb. filters are MagicMock instances
            grid = self.make_grid(model_class=model.Setting)
            filters = grid.make_backend_filters()
        self.assertEqual(len(filters), 2)
        self.assertIn('name', filters)
        self.assertIn('value', filters)

        # does not replace supplied filters
        myfilters = {'value': 42}
        with patch.object(mod.Grid, 'make_filter'):
            # nb. filters are MagicMock instances
            grid = self.make_grid(model_class=model.Setting)
            filters = grid.make_backend_filters(myfilters)
        self.assertEqual(len(filters), 2)
        self.assertIn('name', filters)
        self.assertIn('value', filters)
        self.assertEqual(filters['value'], 42)
        self.assertEqual(myfilters['value'], 42)

        # filters for all *true* columns by default, despite grid.columns
        with patch.object(mod.Grid, 'make_filter'):
            # nb. filters are MagicMock instances
            grid = self.make_grid(model_class=model.User,
                                  columns=['username', 'person'])
            filters = grid.make_backend_filters()
            self.assertIn('username', filters)
            self.assertIn('active', filters)
            # nb. relationship not included by default
            self.assertNotIn('person', filters)
            # nb. uuid fields not included by default
            self.assertNotIn('uuid', filters)
            self.assertNotIn('person_uuid', filters)

    def test_make_filter(self):
        model = self.app.model

        # arg is column name
        grid = self.make_grid(model_class=model.Setting)
        filtr = grid.make_filter('name')
        self.assertIsInstance(filtr, StringAlchemyFilter)

        # arg is column name, but model class is invalid
        grid = self.make_grid(model_class=42)
        self.assertRaises(ValueError, grid.make_filter, 'name')

        # arg is model property
        grid = self.make_grid(model_class=model.Setting)
        filtr = grid.make_filter(model.Setting.name)
        self.assertIsInstance(filtr, StringAlchemyFilter)

        # model property as kwarg
        grid = self.make_grid(model_class=model.Setting)
        filtr = grid.make_filter(None, model_property=model.Setting.name)
        self.assertIsInstance(filtr, StringAlchemyFilter)

        # default factory
        grid = self.make_grid(model_class=model.Setting)
        with patch.dict(default_sqlalchemy_filters, {None: GridFilter}, clear=True):
            filtr = grid.make_filter(model.Setting.name)
        self.assertIsInstance(filtr, GridFilter)
        self.assertNotIsInstance(filtr, StringAlchemyFilter)

        # factory override
        grid = self.make_grid(model_class=model.Setting)
        filtr = grid.make_filter(model.Setting.name, factory=GridFilter)
        self.assertIsInstance(filtr, GridFilter)
        self.assertNotIsInstance(filtr, StringAlchemyFilter)

    def test_set_filter(self):
        model = self.app.model

        with patch.object(mod.Grid, 'make_filter', return_value=42):

            # auto from model property
            grid = self.make_grid(model_class=model.Setting)
            self.assertEqual(grid.filters, {})
            grid.set_filter('name', model.Setting.name)
            self.assertIn('name', grid.filters)

            # auto from column name
            grid = self.make_grid(model_class=model.Setting)
            self.assertEqual(grid.filters, {})
            grid.set_filter('name', 'name')
            self.assertIn('name', grid.filters)

            # auto from key
            grid = self.make_grid(model_class=model.Setting)
            self.assertEqual(grid.filters, {})
            grid.set_filter('name')
            self.assertIn('name', grid.filters)

            # explicit is not yet implemented
            grid = self.make_grid(model_class=model.Setting)
            self.assertEqual(grid.filters, {})
            self.assertRaises(NotImplementedError, grid.set_filter, 'name', lambda q: q)

    def test_remove_filter(self):
        model = self.app.model

        # basics
        with patch.object(mod.Grid, 'make_filter'):
            # nb. filters are MagicMock instances
            grid = self.make_grid(model_class=model.Setting, filterable=True)
        self.assertEqual(len(grid.filters), 2)
        self.assertIn('name', grid.filters)
        self.assertIn('value', grid.filters)
        grid.remove_filter('value')
        self.assertNotIn('value', grid.filters)

    def test_set_filter_defaults(self):
        model = self.app.model

        # empty by default
        grid = self.make_grid(model_class=model.Setting, filterable=True)
        self.assertEqual(grid.filter_defaults, {})

        # can specify via method call
        grid.set_filter_defaults(name={'active': True})
        self.assertEqual(grid.filter_defaults, {'name': {'active': True}})

        # can specify via constructor
        grid = self.make_grid(model_class=model.Setting, filterable=True,
                              filter_defaults={'name': {'active': True}})
        self.assertEqual(grid.filter_defaults, {'name': {'active': True}})

    ##############################
    # data methods
    ##############################

    def test_get_visible_data(self):
        model = self.app.model
        sample_data = [
            {'name': 'foo1', 'value': 'ONE'},
            {'name': 'foo2', 'value': 'two'},
            {'name': 'foo3', 'value': 'three'},
            {'name': 'foo4', 'value': 'four'},
            {'name': 'foo5', 'value': 'five'},
            {'name': 'foo6', 'value': 'six'},
            {'name': 'foo7', 'value': 'seven'},
            {'name': 'foo8', 'value': 'eight'},
            {'name': 'foo9', 'value': 'nine'},
        ]
        for setting in sample_data:
            self.app.save_setting(self.session, setting['name'], setting['value'])
        self.session.commit()
        sample_query = self.session.query(model.Setting)

        # data is sorted and paginated
        grid = self.make_grid(model_class=model.Setting,
                              data=sample_query,
                              filterable=True,
                              sortable=True, sort_on_backend=True,
                              sort_defaults=('name', 'desc'),
                              paginated=True, paginate_on_backend=True,
                              pagesize=4, page=2)
        grid.load_settings()
        # nb. for now the filtering is mocked
        with patch.object(grid, 'filter_data') as filter_data:
            filter_data.side_effect = lambda q: q
            visible = grid.get_visible_data()
            filter_data.assert_called_once_with(sample_query)
        self.assertEqual([s.name for s in visible], ['foo5', 'foo4', 'foo3', 'foo2'])

    def test_filter_data(self):
        model = self.app.model
        sample_data = [
            {'name': 'foo1', 'value': 'ONE'},
            {'name': 'foo2', 'value': 'two'},
            {'name': 'foo3', 'value': 'ggg'},
            {'name': 'foo4', 'value': 'ggg'},
            {'name': 'foo5', 'value': 'ggg'},
            {'name': 'foo6', 'value': 'six'},
            {'name': 'foo7', 'value': 'seven'},
            {'name': 'foo8', 'value': 'eight'},
            {'name': 'foo9', 'value': 'nine'},
        ]
        for setting in sample_data:
            self.app.save_setting(self.session, setting['name'], setting['value'])
        self.session.commit()
        sample_query = self.session.query(model.Setting)

        grid = self.make_grid(key='settings', model_class=model.Setting, filterable=True)
        self.assertEqual(list(grid.filters), ['name', 'value'])
        self.assertIsInstance(grid.filters['name'], StringAlchemyFilter)
        self.assertIsInstance(grid.filters['value'], StringAlchemyFilter)

        # not filtered by default
        grid.load_settings()
        self.assertEqual(grid.active_filters, [])
        filtered_query = grid.filter_data(sample_query)
        self.assertIs(filtered_query, sample_query)

        # can be filtered per session settings
        self.request.session['grid.settings.filter.value.active'] = True
        self.request.session['grid.settings.filter.value.verb'] = 'contains'
        self.request.session['grid.settings.filter.value.value'] = 'ggg'
        grid.load_settings()
        self.assertEqual(len(grid.active_filters), 1)
        self.assertEqual(grid.active_filters[0].key, 'value')
        filtered_query = grid.filter_data(sample_query)
        self.assertIsInstance(filtered_query, orm.Query)
        self.assertIsNot(filtered_query, sample_query)
        self.assertEqual(filtered_query.count(), 3)

        # can be filtered per request settings
        self.request.GET = {'value': 's', 'value.verb': 'contains'}
        grid.load_settings()
        self.assertEqual(len(grid.active_filters), 1)
        self.assertEqual(grid.active_filters[0].key, 'value')
        filtered_query = grid.filter_data(sample_query)
        self.assertIsInstance(filtered_query, orm.Query)
        self.assertEqual(filtered_query.count(), 2)

        # not filtered if verb is invalid
        self.request.GET = {'value': 'ggg', 'value.verb': 'doesnotexist'}
        grid.load_settings()
        self.assertEqual(len(grid.active_filters), 1)
        self.assertEqual(grid.active_filters[0].verb, 'doesnotexist')
        filtered_query = grid.filter_data(sample_query)
        self.assertIs(filtered_query, sample_query)
        self.assertEqual(filtered_query.count(), 9)

        # not filtered if error
        self.request.GET = {'value': 'ggg', 'value.verb': 'contains'}
        grid.load_settings()
        self.assertEqual(len(grid.active_filters), 1)
        self.assertEqual(grid.active_filters[0].verb, 'contains')
        filtered_query = grid.filter_data(sample_query)
        self.assertIsNot(filtered_query, sample_query)
        self.assertEqual(filtered_query.count(), 3)
        with patch.object(grid.active_filters[0], 'filter_contains', side_effect=RuntimeError):
            filtered_query = grid.filter_data(sample_query)
        self.assertIs(filtered_query, sample_query)
        self.assertEqual(filtered_query.count(), 9)

        # joiner is invoked
        self.assertEqual(len(grid.active_filters), 1)
        self.assertEqual(grid.active_filters[0].key, 'value')
        joiner = MagicMock(side_effect=lambda q: q)
        grid.joiners = {'value': joiner}
        grid.joined = set()
        filtered_query = grid.filter_data(sample_query)
        joiner.assert_called_once_with(sample_query)
        self.assertEqual(filtered_query.count(), 3)

    def test_sort_data(self):
        model = self.app.model
        sample_data = [
            {'name': 'foo1', 'value': 'ONE'},
            {'name': 'foo2', 'value': 'two'},
            {'name': 'foo3', 'value': 'ggg'},
            {'name': 'foo4', 'value': 'ggg'},
            {'name': 'foo5', 'value': 'ggg'},
            {'name': 'foo6', 'value': 'six'},
            {'name': 'foo7', 'value': 'seven'},
            {'name': 'foo8', 'value': 'eight'},
            {'name': 'foo9', 'value': 'nine'},
        ]
        for setting in sample_data:
            self.app.save_setting(self.session, setting['name'], setting['value'])
        self.session.commit()
        sample_query = self.session.query(model.Setting)

        grid = self.make_grid(model_class=model.Setting,
                              sortable=True, sort_on_backend=True,
                              sort_defaults=('name', 'desc'))
        grid.load_settings()

        # can sort a simple list of data
        sorted_data = grid.sort_data(sample_data)
        self.assertIsInstance(sorted_data, list)
        self.assertEqual(len(sorted_data), 9)
        self.assertEqual(sorted_data[0]['name'], 'foo9')
        self.assertEqual(sorted_data[-1]['name'], 'foo1')

        # can also sort a data query
        sorted_query = grid.sort_data(sample_query)
        self.assertIsInstance(sorted_query, orm.Query)
        sorted_data = sorted_query.all()
        self.assertEqual(len(sorted_data), 9)
        self.assertEqual(sorted_data[0]['name'], 'foo9')
        self.assertEqual(sorted_data[-1]['name'], 'foo1')

        # cannot sort data if sorter missing in overrides
        sorted_data = grid.sort_data(sample_data, sorters=[])
        # nb. sorted data is in same order as original sample (not sorted)
        self.assertEqual(sorted_data[0]['name'], 'foo1')
        self.assertEqual(sorted_data[-1]['name'], 'foo9')

        # multi-column sorting for list data
        sorted_data = grid.sort_data(sample_data, sorters=[{'key': 'value', 'dir': 'asc'},
                                                           {'key': 'name', 'dir': 'asc'}])
        self.assertEqual(dict(sorted_data[0]), {'name': 'foo8', 'value': 'eight'})
        self.assertEqual(dict(sorted_data[1]), {'name': 'foo3', 'value': 'ggg'})
        self.assertEqual(dict(sorted_data[3]), {'name': 'foo5', 'value': 'ggg'})
        self.assertEqual(dict(sorted_data[-1]), {'name': 'foo2', 'value': 'two'})

        # multi-column sorting for query
        sorted_query = grid.sort_data(sample_query, sorters=[{'key': 'value', 'dir': 'asc'},
                                                             {'key': 'name', 'dir': 'asc'}])
        self.assertEqual(dict(sorted_data[0]), {'name': 'foo8', 'value': 'eight'})
        self.assertEqual(dict(sorted_data[1]), {'name': 'foo3', 'value': 'ggg'})
        self.assertEqual(dict(sorted_data[3]), {'name': 'foo5', 'value': 'ggg'})
        self.assertEqual(dict(sorted_data[-1]), {'name': 'foo2', 'value': 'two'})

        # cannot sort data if sortfunc is missing for column
        grid.remove_sorter('name')
        sorted_data = grid.sort_data(sample_data, sorters=[{'key': 'value', 'dir': 'asc'},
                                                           {'key': 'name', 'dir': 'asc'}])
        # nb. sorted data is in same order as original sample (not sorted)
        self.assertEqual(sorted_data[0]['name'], 'foo1')
        self.assertEqual(sorted_data[-1]['name'], 'foo9')

        # now try with a joiner
        query = self.session.query(model.User)
        grid = self.make_grid(model_class=model.User,
                              data=query,
                              columns=['username', 'full_name'],
                              sortable=True, sort_on_backend=True,
                              sort_defaults='full_name',
                              joiners={
                                  'full_name': lambda q: q.join(model.Person),
                              })
        grid.set_sorter('full_name', model.Person.full_name)
        grid.load_settings()
        data = grid.get_visible_data()
        self.assertIsInstance(data, orm.Query)

    def test_paginate_data(self):
        model = self.app.model
        sample_data = [
            {'name': 'foo1', 'value': 'ONE'},
            {'name': 'foo2', 'value': 'two'},
            {'name': 'foo3', 'value': 'three'},
            {'name': 'foo4', 'value': 'four'},
            {'name': 'foo5', 'value': 'five'},
            {'name': 'foo6', 'value': 'six'},
            {'name': 'foo7', 'value': 'seven'},
            {'name': 'foo8', 'value': 'eight'},
            {'name': 'foo9', 'value': 'nine'},
        ]
        for setting in sample_data:
            self.app.save_setting(self.session, setting['name'], setting['value'])
        self.session.commit()
        sample_query = self.session.query(model.Setting)

        # basic list pager
        grid = self.make_grid(paginated=True, paginate_on_backend=True)
        pager = grid.paginate_data(sample_data)
        self.assertIsInstance(pager, Page)

        # basic query pager
        grid = self.make_grid(paginated=True, paginate_on_backend=True)
        pager = grid.paginate_data(sample_query)
        self.assertIsInstance(pager, SqlalchemyOrmPage)

        # page is reset to 1 for empty data
        self.request.session['grid.foo.page'] = 2
        grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True)
        grid.load_settings()
        self.assertEqual(grid.page, 2)
        self.assertEqual(self.request.session['grid.foo.page'], 2)
        pager = grid.paginate_data(sample_data)
        self.assertEqual(pager.page, 1)
        self.assertEqual(grid.page, 1)
        self.assertEqual(self.request.session['grid.foo.page'], 1)

    ##############################
    # rendering methods
    ##############################

    def test_render_batch_id(self):
        grid = self.make_grid(columns=['foo', 'bar'])

        # null
        obj = MagicMock(foo=None)
        self.assertEqual(grid.render_batch_id(obj, 'foo', None), "")

        # int
        obj = MagicMock(foo=42)
        self.assertEqual(grid.render_batch_id(obj, 'foo', 42), "00000042")

    def test_render_boolean(self):
        grid = self.make_grid(columns=['foo', 'bar'])

        # null
        obj = MagicMock(foo=None)
        self.assertEqual(grid.render_boolean(obj, 'foo', None), "")

        # true
        obj = MagicMock(foo=True)
        self.assertEqual(grid.render_boolean(obj, 'foo', True), "Yes")

        # false
        obj = MagicMock(foo=False)
        self.assertEqual(grid.render_boolean(obj, 'foo', False), "No")

    def test_render_currency(self):
        grid = self.make_grid(columns=['foo', 'bar'])
        obj = MagicMock()

        # null
        self.assertEqual(grid.render_currency(obj, 'foo', None), '')

        # basic decimal example
        value = decimal.Decimal('42.00')
        self.assertEqual(grid.render_currency(obj, 'foo', value), '$42.00')

        # basic float example
        value = 42.00
        self.assertEqual(grid.render_currency(obj, 'foo', value), '$42.00')

        # decimal places will be rounded
        value = decimal.Decimal('42.12345')
        self.assertEqual(grid.render_currency(obj, 'foo', value), '$42.12')

        # negative numbers get parens
        value = decimal.Decimal('-42.42')
        self.assertEqual(grid.render_currency(obj, 'foo', value), '($42.42)')

    def test_render_percent(self):
        grid = self.make_grid(columns=['foo', 'bar'])
        obj = MagicMock()

        # null
        self.assertEqual(grid.render_percent(obj, 'foo', None), "")

        # typical
        self.assertEqual(grid.render_percent(obj, 'foo', 12.3419), '12.34 %')

        # more decimal places
        self.assertEqual(grid.render_percent(obj, 'foo', 12.3419, decimals=3), '12.342 %')
        self.assertEqual(grid.render_percent(obj, 'foo', 12.3419, decimals=4), '12.3419 %')

        # negative
        self.assertEqual(grid.render_percent(obj, 'foo', -12.3419), '(12.34 %)')
        self.assertEqual(grid.render_percent(obj, 'foo', -12.3419, decimals=3), '(12.342 %)')

    def test_render_quantity(self):
        grid = self.make_grid(columns=['foo', 'bar'])
        obj = MagicMock()

        # null
        self.assertEqual(grid.render_quantity(obj, 'foo', None), "")

        # integer decimals become integers
        value = decimal.Decimal('1.000')
        self.assertEqual(grid.render_quantity(obj, 'foo', value), "1")

        # but decimal places are preserved
        value = decimal.Decimal('1.234')
        self.assertEqual(grid.render_quantity(obj ,'foo', value), "1.234")

        # zero is *not* empty string (with this renderer)
        self.assertEqual(grid.render_quantity(obj, 'foo', 0), "0")

    def test_render_date(self):
        grid = self.make_grid(columns=['foo', 'bar'])

        # null
        obj = MagicMock(dt=None)
        result = grid.render_date(obj, 'dt', None)
        self.assertEqual(result, '')

        # typical
        dt = datetime.date(2025, 1, 13)
        obj = MagicMock(dt=dt)
        result = grid.render_date(obj, 'dt', str(dt))
        self.assertEqual(result, '2025-01-13')

    def test_render_datetime(self):
        grid = self.make_grid(columns=['foo', 'bar'])

        obj = MagicMock(dt=None)
        result = grid.render_datetime(obj, 'dt', None)
        self.assertEqual(result, '')

        dt = datetime.datetime(2024, 12, 12, 13, 44, tzinfo=datetime.timezone.utc)
        obj = MagicMock(dt=dt)
        result = grid.render_datetime(obj, 'dt', str(dt))
        self.assertEqual(result, '2024-12-12 13:44+0000')

    def test_render_vue_tag(self):
        grid = self.make_grid(columns=['foo', 'bar'])
        html = grid.render_vue_tag()
        self.assertEqual(html, '<wutta-grid></wutta-grid>')

    def test_render_vue_template(self):
        self.pyramid_config.include('pyramid_mako')
        self.pyramid_config.add_subscriber('wuttaweb.subscribers.before_render',
                                           'pyramid.events.BeforeRender')

        grid = self.make_grid(columns=['foo', 'bar'])
        html = grid.render_vue_template()
        self.assertIn('<script type="text/x-template" id="wutta-grid-template">', html)

    def test_render_table_element(self):
        self.pyramid_config.include('pyramid_mako')
        self.pyramid_config.add_subscriber('wuttaweb.subscribers.before_render',
                                           'pyramid.events.BeforeRender')

        grid = self.make_grid(key='foobar', columns=['foo', 'bar'])

        # form not required
        html = grid.render_table_element()
        self.assertNotIn('<script ', html)
        self.assertIn('<b-table ', html)

        # form will register grid data
        form = Form(self.request)
        self.assertEqual(len(form.grid_vue_context), 0)
        html = grid.render_table_element(form)
        self.assertEqual(len(form.grid_vue_context), 1)
        self.assertIn('foobar', form.grid_vue_context)

    def test_render_vue_finalize(self):
        grid = self.make_grid()
        html = grid.render_vue_finalize()
        self.assertIn('<script>', html)
        self.assertIn("Vue.component('wutta-grid', WuttaGrid)", html)

    def test_get_vue_columns(self):

        # error if no columns are set
        grid = self.make_grid()
        self.assertRaises(ValueError, grid.get_vue_columns)

        # otherwise get back field/label dicts
        grid = self.make_grid(columns=['foo', 'bar'])
        columns = grid.get_vue_columns()
        first = columns[0]
        self.assertEqual(first['field'], 'foo')
        self.assertEqual(first['label'], 'Foo')

    def test_get_vue_active_sorters(self):
        model = self.app.model

        # empty
        grid = self.make_grid(key='foo', sortable=True, sort_on_backend=True)
        grid.load_settings()
        sorters = grid.get_vue_active_sorters()
        self.assertEqual(sorters, [])

        # format is different
        grid = self.make_grid(key='settings', model_class=model.Setting,
                              sortable=True, sort_on_backend=True,
                              sort_defaults='name')
        grid.load_settings()
        self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}])
        sorters = grid.get_vue_active_sorters()
        self.assertEqual(sorters, [{'field': 'name', 'order': 'asc'}])

    def test_get_vue_filters(self):
        model = self.app.model

        # basic
        grid = self.make_grid(key='settings', model_class=model.Setting, filterable=True)
        grid.load_settings()
        filters = grid.get_vue_filters()
        self.assertEqual(len(filters), 2)

    def test_object_to_dict(self):
        grid = self.make_grid()
        setting = {'name': 'foo', 'value': 'bar'}

        # new dict but with same values
        dct = grid.object_to_dict(setting)
        self.assertIsInstance(dct, dict)
        self.assertIsNot(dct, setting)
        self.assertEqual(dct, setting)

        # random object, not iterable
        class MockSetting:
            def __init__(self, **kw):
                self.__dict__.update(kw)
        mock = MockSetting(**setting)
        dct = grid.object_to_dict(mock)
        self.assertIsInstance(dct, dict)
        self.assertEqual(dct, setting)

    def test_get_vue_context(self):

        # empty if no columns defined
        grid = self.make_grid()
        context = grid.get_vue_context()
        self.assertEqual(context, {'data': [], 'row_classes': {}})

        # typical data is a list
        mydata = [
            {'foo': 'bar'},
        ]
        grid = self.make_grid(columns=['foo'], data=mydata)
        context = grid.get_vue_context()
        self.assertEqual(context, {'data': [{'foo': 'bar'}], 'row_classes': {}})

        # if grid has actions, that list may be supplemented
        grid.actions.append(mod.GridAction(self.request, 'view', url='/blarg'))
        context = grid.get_vue_context()
        self.assertIsNot(context['data'], mydata)
        self.assertEqual(context, {'data': [{'foo': 'bar', '_action_url_view': '/blarg'}],
                                   'row_classes': {}})

        # can override value rendering
        grid.set_renderer('foo', lambda record, key, value: "blah blah")
        context = grid.get_vue_context()
        self.assertEqual(context, {'data': [{'foo': 'blah blah', '_action_url_view': '/blarg'}],
                                   'row_classes': {}})

        # can set row class
        grid.row_class = 'whatever'
        context = grid.get_vue_context()
        self.assertEqual(context, {'data': [{'foo': 'blah blah', '_action_url_view': '/blarg'}],
                                   'row_classes': {'0': 'whatever'}})

    def test_get_vue_data(self):

        # empty if no columns defined
        grid = self.make_grid()
        data = grid.get_vue_data()
        self.assertEqual(data, [])

        # typical data is a list
        mydata = [
            {'foo': 'bar'},
        ]
        grid = self.make_grid(columns=['foo'], data=mydata)
        data = grid.get_vue_data()
        self.assertEqual(data, [{'foo': 'bar'}])

        # if grid has actions, that list may be supplemented
        grid.actions.append(mod.GridAction(self.request, 'view', url='/blarg'))
        data = grid.get_vue_data()
        self.assertIsNot(data, mydata)
        self.assertEqual(data, [{'foo': 'bar', '_action_url_view': '/blarg'}])

        # can override value rendering
        grid.set_renderer('foo', lambda record, key, value: "blah blah")
        data = grid.get_vue_data()
        self.assertEqual(data, [{'foo': 'blah blah', '_action_url_view': '/blarg'}])

    def test_get_row_class(self):
        model = self.app.model
        user = model.User(username='barney', active=True)
        self.session.add(user)
        self.session.commit()
        data = dict(user)

        # null by default
        grid = self.make_grid()
        self.assertIsNone(grid.get_row_class(user, data, 1))

        # can use static class
        grid.row_class = 'foo'
        self.assertEqual(grid.get_row_class(user, data, 1), 'foo')

        # can use callable
        def status(u, d, i):
            if not u.active:
                return 'inactive'
        grid.row_class = status
        self.assertIsNone(grid.get_row_class(user, data, 1))
        user.active = False
        self.assertEqual(grid.get_row_class(user, data, 1), 'inactive')

    def test_get_vue_pager_stats(self):
        data = [
            {'foo': 1, 'bar': 1},
            {'foo': 2, 'bar': 2},
            {'foo': 3, 'bar': 3},
            {'foo': 4, 'bar': 4},
            {'foo': 5, 'bar': 5},
            {'foo': 6, 'bar': 6},
            {'foo': 7, 'bar': 7},
            {'foo': 8, 'bar': 8},
            {'foo': 9, 'bar': 9},
        ]

        grid = self.make_grid(columns=['foo', 'bar'], pagesize=4, page=2)
        grid.pager = grid.paginate_data(data)
        stats = grid.get_vue_pager_stats()
        self.assertEqual(stats['item_count'], 9)
        self.assertEqual(stats['items_per_page'], 4)
        self.assertEqual(stats['page'], 2)
        self.assertEqual(stats['first_item'], 5)
        self.assertEqual(stats['last_item'], 8)


class TestGridAction(TestCase):

    def setUp(self):
        self.config = WuttaConfig()
        self.request = testing.DummyRequest(wutta_config=self.config, use_oruga=False)

    def make_action(self, key, **kwargs):
        return mod.GridAction(self.request, key, **kwargs)

    def test_render_icon(self):

        # icon is derived from key by default
        action = self.make_action('blarg')
        html = action.render_icon()
        self.assertIn('<i class="fas fa-blarg">', html)

        # oruga has different output
        self.request.use_oruga = True
        html = action.render_icon()
        self.assertIn('<o-icon icon="blarg">', html)

    def test_render_label(self):

        # label is derived from key by default
        action = self.make_action('blarg')
        label = action.render_label()
        self.assertEqual(label, "Blarg")

        # otherwise use what caller provides
        action = self.make_action('foo', label="Bar")
        label = action.render_label()
        self.assertEqual(label, "Bar")

    def test_render_icon_and_label(self):
        action = self.make_action('blarg')
        with patch.multiple(action,
                            render_icon=lambda: 'ICON',
                            render_label=lambda: 'LABEL'):
            html = action.render_icon_and_label()
            self.assertEqual('ICON LABEL', html)

    def test_get_url(self):
        obj = {'foo': 'bar'}

        # null by default
        action = self.make_action('blarg')
        url = action.get_url(obj)
        self.assertIsNone(url)

        # or can be "static"
        action = self.make_action('blarg', url='/foo')
        url = action.get_url(obj)
        self.assertEqual(url, '/foo')

        # or can be "dynamic"
        action = self.make_action('blarg', url=lambda o, i: '/yeehaw')
        url = action.get_url(obj)
        self.assertEqual(url, '/yeehaw')