#!/usr/bin/env python3
"""Test suite for dlist v2 core functionality

Adapted from the original dlist test_refactoring.py,
updated for the new architecture:
- charSep is '__' (double underscore)
- map/assign are non-mutating
- I/O tests excluded (separate module)
"""

import pytest
from dlist import Dlist
from dlist.helpers import flattenDict, structDict, merge_dicts, getDitem


# ─── Test data ───────────────────────────────────────────────

@pytest.fixture
def simple_data():
    return [
        {'name': 'willy', 'size': 'M', 'No': '1'},
        {'name': 'john', 'size': 'L', 'No': '2'},
        {'foo': 'bar', 'No': '3'}
    ]

@pytest.fixture
def city_data():
    return [
        {'name': 'Alice', 'age': '30', 'city': 'NYC'},
        {'name': 'Bob', 'age': '25', 'city': 'LA'},
        {'name': 'Charlie', 'age': '35', 'city': 'NYC'}
    ]

@pytest.fixture
def nested_data():
    return [
        {'id': '1', 'name': 'John', 'address': {'city': 'NYC', 'zip': '10001'}},
        {'id': '2', 'name': 'Jane', 'address': {'city': 'LA', 'zip': '90001'}},
    ]

@pytest.fixture
def product_data():
    return [
        {'id': '1', 'product': 'laptop', 'price': '1200'},
        {'id': '2', 'product': 'mouse', 'price': '25'},
        {'id': '3', 'product': 'keyboard', 'price': '80'}
    ]


# ─── Basic functionality ─────────────────────────────────────

class TestBasic:
    def test_create_with_id(self, simple_data):
        d = Dlist(simple_data, id_='No')
        assert len(d) == 3
        assert d.id == 'No'

    def test_create_without_id(self, city_data):
        d = Dlist(city_data)
        assert len(d) == 3
        assert d.id == ''

    def test_create_empty(self):
        d = Dlist()
        assert len(d) == 0

    def test_duplicate_id_raises(self):
        with pytest.raises(ValueError):
            Dlist([{'id': '1'}, {'id': '1'}], id_='id')

    def test_keys(self, simple_data):
        d = Dlist(simple_data, id_='No')
        ks = d.keys()
        assert 'name' in ks
        assert 'size' in ks
        assert 'No' in ks
        assert 'foo' in ks

    def test_keys_all(self, simple_data):
        d = Dlist(simple_data, id_='No')
        ks = d.keys(All=True)
        assert 'No' in ks
        assert 'foo' not in ks  # foo only in one item

    def test_keys_count(self, simple_data):
        d = Dlist(simple_data, id_='No')
        ks = d.keys(count=True)
        assert ks['No'] == 3
        assert ks['foo'] == 1

    def test_vals(self, simple_data):
        d = Dlist(simple_data, id_='No')
        names = d.vals('name')
        assert 'willy' in names
        assert 'john' in names

    def test_vals_count(self, city_data):
        d = Dlist(city_data)
        counts = d.vals('city', count=True)
        assert counts['NYC'] == 2
        assert counts['LA'] == 1

    def test_str(self, simple_data):
        d = Dlist(simple_data, id_='No')
        assert 'Dlist(3 items' in str(d)

    def test_repr(self, simple_data):
        d = Dlist(simple_data, id_='No')
        r = repr(d)
        assert 'Dlist(' in r
        assert "id_='No'" in r

    def test_all_values_become_strings(self):
        d = Dlist([{'a': 123, 'b': True}])
        assert d[0]['a'] == '123'
        assert d[0]['b'] == 'True'


# ─── Iterators and indexing ──────────────────────────────────

class TestIterators:
    def test_iter(self, city_data):
        d = Dlist(city_data)
        items = list(d)
        assert len(items) == 3

    def test_len(self, city_data):
        d = Dlist(city_data)
        assert len(d) == 3

    def test_getitem_int(self, city_data):
        d = Dlist(city_data)
        assert d[0]['name'] == 'Alice'

    def test_getitem_id(self, product_data):
        d = Dlist(product_data, id_='id')
        item = d['2']
        assert item['product'] == 'mouse'

    def test_getitem_list_int(self, city_data):
        d = Dlist(city_data)
        sub = d[[0, 2]]
        assert len(sub) == 2

    def test_getitem_list_id(self, product_data):
        d = Dlist(product_data, id_='id')
        sub = d[['1', '3']]
        assert len(sub) == 2

    def test_getitem_empty_list(self, city_data):
        d = Dlist(city_data)
        sub = d[[]]
        assert len(sub) == 0

    def test_getitem_str_without_id_raises(self, city_data):
        d = Dlist(city_data)
        with pytest.raises(ValueError):
            d['Alice']

    def test_index_by_id(self, product_data):
        d = Dlist(product_data, id_='id')
        assert d.index('2') == 1

    def test_index_by_dict(self, city_data):
        d = Dlist(city_data)
        assert d.index(d[0]) == 0

    def test_index_not_found(self, product_data):
        d = Dlist(product_data, id_='id')
        assert d.index('999') == -1


# ─── Setitem ─────────────────────────────────────────────────

class TestSetitem:
    def test_setitem_int(self, product_data):
        d = Dlist(product_data, id_='id')
        d[0] = {'price': '999'}
        assert d[0]['price'] == '999'
        assert d[0]['product'] == 'laptop'  # merged

    def test_setitem_id(self, product_data):
        d = Dlist(product_data, id_='id')
        d['2'] = {'price': '30'}
        assert d['2']['price'] == '30'

    def test_setitem_list(self, product_data):
        d = Dlist(product_data, id_='id')
        d[['1', '2']] = {'status': 'active'}
        assert d['1']['status'] == 'active'
        assert d['2']['status'] == 'active'

    def test_setitem_dlist(self, product_data):
        d = Dlist(product_data, id_='id')
        sub = d.filter(product='laptop')
        d[sub] = {'status': 'premium'}
        assert d['1']['status'] == 'premium'


# ─── Sort ────────────────────────────────────────────────────

class TestSort:
    def test_sort_by_id(self):
        d = Dlist([{'id': '3'}, {'id': '1'}, {'id': '2'}], id_='id')
        d.sort()
        assert d[0]['id'] == '1'
        assert d[2]['id'] == '3'

    def test_sort_by_key(self, city_data):
        d = Dlist(city_data)
        d.sort('name')
        assert d[0]['name'] == 'Alice'
        assert d[2]['name'] == 'Charlie'

    def test_sort_reverse(self, city_data):
        d = Dlist(city_data)
        d.sort('name', reverse=True)
        assert d[0]['name'] == 'Charlie'

    def test_sort_no_key_noop(self, city_data):
        d = Dlist(city_data)
        original = [item['name'] for item in d]
        d.sort()  # no id, no key -> noop
        after = [item['name'] for item in d]
        assert original == after

    def test_sort_returns_self(self, product_data):
        d = Dlist(product_data, id_='id')
        assert d.sort() is d


# ─── Filter ──────────────────────────────────────────────────

class TestFilter:
    def test_filter_basic(self, city_data):
        d = Dlist(city_data)
        nyc = d.filter(city='NYC')
        assert len(nyc) == 2

    def test_filter_no_query_returns_self(self, city_data):
        d = Dlist(city_data)
        assert d.filter() is d

    def test_filter_complement(self, city_data):
        d = Dlist(city_data)
        nyc, rest = d.filter(complement=True, city='NYC')
        assert len(nyc) == 2
        assert len(rest) == 1

    def test_filter_glob(self, city_data):
        d = Dlist(city_data)
        result = d.filter(name='A*')
        assert len(result) == 1
        assert result[0]['name'] == 'Alice'

    def test_filter_negation(self, city_data):
        d = Dlist(city_data)
        result = d.filter(city='-NYC')
        assert len(result) == 1
        assert result[0]['city'] == 'LA'

    def test_filter_key_present(self, simple_data):
        d = Dlist(simple_data, id_='No')
        result = d.filter(foo=True)
        assert len(result) == 1

    def test_filter_key_absent(self, simple_data):
        d = Dlist(simple_data, id_='No')
        result = d.filter(foo=False)
        assert len(result) == 2

    def test_filter_non_mutating(self, city_data):
        d = Dlist(city_data)
        _ = d.filter(city='NYC')
        assert len(d) == 3


# ─── Nested dicts ────────────────────────────────────────────

class TestNested:
    def test_nested_keys(self, nested_data):
        d = Dlist(nested_data, id_='id')
        ks = d.keys()
        assert 'address' in ks
        assert 'name' in ks

    def test_nested_subkeys(self, nested_data):
        d = Dlist(nested_data, id_='id')
        ks = d.keys('address')
        assert 'city' in ks
        assert 'zip' in ks

    def test_nested_vals(self, nested_data):
        d = Dlist(nested_data, id_='id')
        cities = d.vals('address__city')
        assert 'NYC' in cities
        assert 'LA' in cities

    def test_nested_filter(self, nested_data):
        d = Dlist(nested_data, id_='id')
        nyc = d.filter(address__city='NYC')
        assert len(nyc) == 1
        assert nyc[0]['name'] == 'John'


# ─── Map ─────────────────────────────────────────────────────

class TestMap:
    def test_map_basic(self, city_data):
        d = Dlist(city_data)
        d2 = d.map(lambda name: name.upper(), ['name'], ['upper_name'])
        assert d2[0]['upper_name'] == 'ALICE'
        assert len(d2) == 3

    def test_map_non_mutating(self, city_data):
        d = Dlist(city_data)
        d2 = d.map(lambda name: name.upper(), ['name'], ['upper_name'])
        assert 'upper_name' not in d[0]

    def test_map_with_query(self, city_data):
        d = Dlist(city_data)
        d2 = d.map(lambda name: name.upper(), ['name'], ['upper_name'],
                    query={'city': 'NYC'})
        # NYC items get upper_name
        alice = [x for x in d2 if x['name'] == 'Alice'][0]
        assert alice['upper_name'] == 'ALICE'
        # LA item untouched
        bob = [x for x in d2 if x['name'] == 'Bob'][0]
        assert 'upper_name' not in bob

    def test_map_preserves_order(self, city_data):
        d = Dlist(city_data)
        d2 = d.map(lambda name: name.upper(), ['name'], ['upper_name'],
                    query={'city': 'NYC'})
        names = [x['name'] for x in d2]
        assert names == ['Alice', 'Bob', 'Charlie']

    def test_map_single_value_return(self, city_data):
        d = Dlist(city_data)
        # return bare value, not list
        d2 = d.map(lambda name: name.upper(), ['name'], ['upper_name'])
        assert d2[0]['upper_name'] == 'ALICE'

    def test_map_tuple_return(self, city_data):
        d = Dlist(city_data)
        d2 = d.map(lambda name, age: (name.upper(), int(age) + 1),
                    ['name', 'age'], ['upper', 'next_age'])
        assert d2[0]['upper'] == 'ALICE'
        assert d2[0]['next_age'] == '31'

    def test_map_none_keeps_record(self, city_data):
        d = Dlist(city_data)
        d2 = d.map(lambda name: None, ['name'], ['x'])
        assert len(d2) == 3
        assert 'x' not in d2[0]

    def test_map_missing_input_keeps_record(self, simple_data):
        d = Dlist(simple_data, id_='No')
        # 'foo' only exists in one item
        d2 = d.map(lambda foo: foo.upper(), ['foo'], ['upper_foo'])
        assert len(d2) == 3


# ─── Partition ───────────────────────────────────────────────

class TestPartition:
    def test_partition_basic(self, city_data):
        d = Dlist(city_data)
        p = d.partition('city')
        assert 'NYC' in p
        assert 'LA' in p
        assert len(p['NYC']) == 2
        assert len(p['LA']) == 1

    def test_partition_preserves_id(self, product_data):
        d = Dlist(product_data, id_='id')
        p = d.partition('product')
        assert p['laptop'].id == 'id'

    def test_partition_missing_key(self, simple_data):
        d = Dlist(simple_data, id_='No')
        p = d.partition('name')
        assert None in p  # item without 'name' goes to None
        assert len(p[None]) == 1


# ─── Assign ──────────────────────────────────────────────────

class TestAssign:
    def test_assign_all(self, city_data):
        d = Dlist(city_data)
        d2 = d.assign(status='active')
        assert all(item['status'] == 'active' for item in d2)

    def test_assign_non_mutating(self, city_data):
        d = Dlist(city_data)
        d2 = d.assign(status='active')
        assert 'status' not in d[0]

    def test_assign_with_query(self, city_data):
        d = Dlist(city_data)
        d2 = d.assign(query={'city': 'NYC'}, status='east')
        nyc = [x for x in d2 if x['city'] == 'NYC']
        la = [x for x in d2 if x['city'] == 'LA']
        assert all(x['status'] == 'east' for x in nyc)
        assert all('status' not in x for x in la)

    def test_assign_preserves_all_records(self, city_data):
        d = Dlist(city_data)
        d2 = d.assign(query={'city': 'NYC'}, status='east')
        assert len(d2) == 3


# ─── Arithmetic ops ─────────────────────────────────────────

class TestArithmetic:
    def test_sub_with_id(self):
        d1 = Dlist([{'id': '1', 'a': 'x'}, {'id': '2', 'a': 'y'}], id_='id')
        d2 = Dlist([{'id': '1', 'a': 'x'}], id_='id')
        result = d1 - d2
        assert len(result) == 1
        assert result[0]['id'] == '2'

    def test_sub_without_id(self):
        d1 = Dlist([{'a': '1'}, {'a': '2'}, {'a': '3'}])
        d2 = Dlist([{'a': '2'}])
        result = d1 - d2
        assert len(result) == 2

    def test_add_with_id(self):
        d1 = Dlist([{'id': '1', 'a': 'x'}], id_='id')
        d2 = Dlist([{'id': '1', 'b': 'y'}, {'id': '2', 'c': 'z'}], id_='id')
        result = d1 + d2
        assert len(result) == 2
        # d1 values preserved on conflict
        item1 = result['1']
        assert item1['a'] == 'x'
        assert item1['b'] == 'y'

    def test_eq(self):
        d1 = Dlist([{'a': '1'}, {'a': '2'}])
        d2 = Dlist([{'a': '1'}, {'a': '2'}])
        assert d1 == d2

    def test_neq(self):
        d1 = Dlist([{'a': '1'}])
        d2 = Dlist([{'a': '2'}])
        assert not (d1 == d2)


# ─── Helpers ─────────────────────────────────────────────────

class TestHelpers:
    def test_flatten_struct_roundtrip(self):
        d = {'k1': 'v1', 'k2': {'a': 'va', 'b': 'vb'}}
        flat = flattenDict(d)
        back = structDict(flat)
        assert back == d

    def test_merge_dicts_over(self):
        d1 = {'a': '1', 'b': '2'}
        d2 = {'b': '99', 'c': '3'}
        result = merge_dicts(d1, d2, over=True)
        assert result['b'] == '99'  # d2 wins
        assert result['c'] == '3'

    def test_merge_dicts_conservative(self):
        d1 = {'a': '1', 'b': '2'}
        d2 = {'b': '99', 'c': '3'}
        result = merge_dicts(d1, d2, over=False)
        assert result['b'] == '2'  # d1 preserved
        assert result['c'] == '3'

    def test_getDitem(self):
        d = {'a': {'b': {'c': 'deep'}}}
        assert getDitem(d, 'a__b__c') == 'deep'
        assert getDitem(d, 'a__x') == {}

    def test_getDitem_empty_key(self):
        d = {'a': '1'}
        assert getDitem(d, '') == d


# ─── Tree ────────────────────────────────────────────────────

class TestTree:
    def test_tree_returns_string(self, nested_data):
        d = Dlist(nested_data, id_='id')
        out = d.tree(printTree=False)
        assert isinstance(out, str)
        assert 'Dlist' in out

    def test_tree_contains_keys(self, nested_data):
        d = Dlist(nested_data, id_='id')
        out = d.tree(printTree=False)
        assert 'address' in out
        assert 'name' in out
        assert 'city' in out

    def test_tree_with_root(self, nested_data):
        d = Dlist(nested_data, id_='id')
        out = d.tree(root='address', printTree=False)
        assert 'city' in out
        assert 'zip' in out

    def test_tree_depth(self, nested_data):
        d = Dlist(nested_data, id_='id')
        out = d.tree(depth=0, printTree=False)
        assert '...' in out  # branches collapsed
        assert 'address' in out

    def test_tree_top(self):
        items = [{'id': str(i), 'v': ['a', 'b', 'c', 'd', 'e'][i % 5]} for i in range(20)]
        d = Dlist(items, id_='id')
        out = d.tree(top=2, printTree=False)
        assert '...' in out  # more values than top


# ─── List fields ────────────────────────────────────────────

@pytest.fixture
def tagged_data():
    return [
        {'id': '1', 'name': 'photo1', 'tags': ['photo', 'landscape']},
        {'id': '2', 'name': 'video1', 'tags': ['video', 'interview']},
        {'id': '3', 'name': 'photo2', 'tags': ['photo', 'portrait']},
        {'id': '4', 'name': 'doc1',   'tags': 'report'},
        {'id': '5', 'name': 'mixed',  'tags': ['photo', 'video']},
    ]


class TestListFields:
    """Tests for fields containing list values."""

    def test_init_preserves_lists(self, tagged_data):
        d = Dlist(tagged_data, id_='id')
        assert isinstance(d['1']['tags'], list)
        assert d['1']['tags'] == ['photo', 'landscape']

    def test_init_scalars_still_strings(self, tagged_data):
        d = Dlist(tagged_data, id_='id')
        assert d['4']['tags'] == 'report'
        assert isinstance(d['4']['tags'], str)

    def test_filter_list_membership(self, tagged_data):
        d = Dlist(tagged_data, id_='id')
        result = d.filter(tags='photo')
        ids = [i['id'] for i in result]
        assert '1' in ids
        assert '3' in ids
        assert '5' in ids
        assert '2' not in ids
        assert '4' not in ids

    def test_filter_list_negation(self, tagged_data):
        d = Dlist(tagged_data, id_='id')
        result = d.filter(tags='-photo')
        ids = [i['id'] for i in result]
        assert '2' in ids
        assert '4' in ids
        assert '1' not in ids

    def test_filter_list_glob(self, tagged_data):
        d = Dlist(tagged_data, id_='id')
        result = d.filter(tags='*port*')
        ids = [i['id'] for i in result]
        assert '3' in ids   # portrait
        assert '4' in ids   # report (string field)

    def test_filter_list_complement(self, tagged_data):
        d = Dlist(tagged_data, id_='id')
        matched, rest = d.filter(complement=True, tags='video')
        assert len(matched) == 2  # video1, mixed
        assert len(rest) == 3

    def test_filter_string_field_unchanged(self, tagged_data):
        d = Dlist(tagged_data, id_='id')
        result = d.filter(tags='report')
        assert len(result) == 1
        assert result[0]['id'] == '4'

    def test_vals_expands_lists(self, tagged_data):
        d = Dlist(tagged_data, id_='id')
        vs = d.vals('tags')
        assert 'photo' in vs
        assert 'video' in vs
        assert 'landscape' in vs
        assert 'report' in vs

    def test_vals_count_with_lists(self, tagged_data):
        d = Dlist(tagged_data, id_='id')
        vc = d.vals('tags', count=True)
        assert vc['photo'] == 3      # items 1, 3, 5
        assert vc['video'] == 2      # items 2, 5
        assert vc['report'] == 1

    def test_partition_with_lists(self, tagged_data):
        d = Dlist(tagged_data, id_='id')
        p = d.partition('tags')
        # List values become tuple keys
        assert ('photo', 'landscape') in p
        assert ('video', 'interview') in p
        assert 'report' in p  # string stays as-is

    def test_tree_with_lists(self, tagged_data):
        d = Dlist(tagged_data, id_='id')
        out = d.tree(printTree=False)
        assert 'tags' in out
        assert isinstance(out, str)

    def test_keys_same_with_lists(self, tagged_data):
        d = Dlist(tagged_data, id_='id')
        ks = d.keys()
        assert 'tags' in ks
        assert 'name' in ks


# ─── set_id ──────────────────────────────────────────────────

class TestSetId:
    @pytest.fixture
    def no_id_data(self):
        return Dlist([
            {'name': 'alpha', 'val': '10'},
            {'name': 'beta',  'val': '20'},
            {'name': 'gamma', 'val': '30'},
        ])

    @pytest.fixture
    def five_items(self):
        return Dlist([{'x': str(i)} for i in range(5)])

    @pytest.fixture
    def big_data(self):
        return Dlist([{'x': str(i)} for i in range(150)])

    # ── promote existing field ──

    def test_promote_existing_field(self, no_id_data):
        d2 = no_id_data.set_id('name')
        assert d2.id == 'name'
        assert d2['alpha']['val'] == '10'

    def test_promote_does_not_mutate_original(self, no_id_data):
        d2 = no_id_data.set_id('name')
        assert no_id_data.id == ''
        assert d2.id == 'name'

    def test_promote_duplicate_raises(self):
        d = Dlist([{'a': '1', 'b': 'x'}, {'a': '1', 'b': 'y'}])
        with pytest.raises(ValueError):
            d.set_id('a')

    # ── generate ids ──

    def test_generate_basic(self, no_id_data):
        d2 = no_id_data.set_id('id', generate='R')
        assert d2.id == 'id'
        # 3 records → len("3")=1 → digits=2 → R01, R02, R03
        assert d2[0]['id'] == 'R01'
        assert d2[2]['id'] == 'R03'

    def test_generate_auto_digits_5_records(self, five_items):
        d2 = five_items.set_id('id', generate='X')
        # 5 records → len("5")=1 → digits=2
        assert d2[0]['id'] == 'X01'
        assert d2[4]['id'] == 'X05'

    def test_generate_auto_digits_150_records(self, big_data):
        d2 = big_data.set_id('id', generate='E')
        # 150 records → len("150")=3 → digits=4
        assert d2[0]['id'] == 'E0001'
        assert d2[149]['id'] == 'E0150'

    def test_generate_explicit_digits(self, no_id_data):
        d2 = no_id_data.set_id('id', generate='P', digits=5)
        assert d2[0]['id'] == 'P00001'
        assert d2[2]['id'] == 'P00003'

    def test_generate_preserves_existing_fields(self, no_id_data):
        d2 = no_id_data.set_id('id', generate='R')
        assert d2[0]['name'] == 'alpha'
        assert d2[1]['val'] == '20'

    def test_generate_does_not_mutate_original(self, no_id_data):
        d2 = no_id_data.set_id('id', generate='R')
        assert 'id' not in no_id_data[0]

    def test_generate_ids_are_unique(self, big_data):
        d2 = big_data.set_id('id', generate='Z')
        ids = [item['id'] for item in d2]
        assert len(ids) == len(set(ids))

    def test_generate_indexable_by_id(self, no_id_data):
        d2 = no_id_data.set_id('id', generate='R')
        assert d2['R02']['name'] == 'beta'

    def test_generate_raises_if_key_exists(self, no_id_data):
        with pytest.raises(ValueError, match="already exists"):
            no_id_data.set_id('name', generate='R')


# ─── Sort ────────────────────────────────────────────────────

class TestSort:
    def test_sort_single_key(self):
        d = Dlist([{'n': 'c'}, {'n': 'a'}, {'n': 'b'}])
        d.sort('n')
        assert [r['n'] for r in d] == ['a', 'b', 'c']

    def test_sort_reverse(self):
        d = Dlist([{'n': 'a'}, {'n': 'c'}, {'n': 'b'}])
        d.sort('n', reverse=True)
        assert [r['n'] for r in d] == ['c', 'b', 'a']

    def test_sort_by_id_default(self):
        d = Dlist([{'id': '3'}, {'id': '1'}, {'id': '2'}], id_='id')
        d.sort()
        assert [r['id'] for r in d] == ['1', '2', '3']

    def test_sort_multi_key(self):
        d = Dlist([
            {'type': 'B', 'name': 'z'},
            {'type': 'A', 'name': 'y'},
            {'type': 'A', 'name': 'x'},
            {'type': 'B', 'name': 'w'},
        ])
        d.sort(['type', 'name'])
        assert [r['name'] for r in d] == ['x', 'y', 'w', 'z']

    def test_sort_multi_key_reverse(self):
        d = Dlist([
            {'type': 'A', 'name': 'x'},
            {'type': 'B', 'name': 'z'},
            {'type': 'A', 'name': 'y'},
            {'type': 'B', 'name': 'w'},
        ])
        d.sort(['type', 'name'], reverse=True)
        assert [r['name'] for r in d] == ['z', 'w', 'y', 'x']

    def test_sort_returns_self(self):
        d = Dlist([{'n': 'b'}, {'n': 'a'}])
        result = d.sort('n')
        assert result is d

    def test_sort_empty_key(self):
        d = Dlist([{'n': 'b'}, {'n': 'a'}])
        d.sort('')
        assert [r['n'] for r in d] == ['b', 'a']  # unchanged

    def test_sort_empty_list_key(self):
        d = Dlist([{'n': 'b'}, {'n': 'a'}])
        d.sort([])
        assert [r['n'] for r in d] == ['b', 'a']  # unchanged

    def test_sort_nested_key(self):
        d = Dlist([
            {'name': 'a', 'addr': {'city': 'NYC'}},
            {'name': 'b', 'addr': {'city': 'LA'}},
        ])
        d.sort('addr__city')
        assert [r['name'] for r in d] == ['b', 'a']

    def test_sort_multi_key_with_nested(self):
        d = Dlist([
            {'type': 'A', 'addr': {'city': 'NYC'}},
            {'type': 'A', 'addr': {'city': 'LA'}},
            {'type': 'B', 'addr': {'city': 'SF'}},
        ])
        d.sort(['type', 'addr__city'])
        cities = [r['addr']['city'] for r in d]
        assert cities == ['LA', 'NYC', 'SF']
