# test assignment
# Derek Fujimoto
# Mar 2026

from fixtures import *
import time, os
import pandas as pd

def database_path(client):
    """Absolute path to the current state's assign database

    Built from __file__, not the working directory: an "os.path.exists('../data/...')"
    check silently passes from anywhere else, which would make every "the file is gone"
    assertion vacuous.
    """
    current_state = client.odb_get("/Shifts/Variables/stateid")
    path = os.path.dirname(os.path.abspath(__file__))
    return os.path.join(path, '..', 'data', str(current_state), 'shift_database.csv')

def get_database(client):
    return pd.read_csv(database_path(client))

def remove_one_at_a_time(page, client, order):
    """Unassign test0-2 from cell (1,1) in the given order, checking who is left each time

    Args:
        page: playwright page
        client: midas client
        order (list of int): the shifter numbers to unassign, in order
    """
    remaining = [f'test{i}' for i in range(3)]

    for shifter in order:
        assign(page, shifter, 1, 1)
        remaining.remove(f'test{shifter}')

        # bind the current expectation, since try_again re-runs the body later
        @try_again
        def check(expected=list(remaining)):
            if expected:
                names = sorted(get_database(client).name.tolist())
                assert names == expected, \
                    f"Expected {expected} to remain assigned, got {names}"
            else:
                assert not os.path.exists(database_path(client)), \
                    f"Expected the assign database to be removed once nobody is assigned, " \
                    f"but it still exists at '{database_path(client)}'"
        check()

# assign one user
def test_assign1(page, client, users_added):

    # assign test0 to a cell
    assign(page, 0, 1, 1)

    # check that its in the database
    @try_again
    def check():
        assert os.path.isdir(os.path.dirname(database_path(client))), \
            f"Expected state directory '{os.path.dirname(database_path(client))}' to exist after assignment, but it was not created"
        assert os.path.exists(database_path(client)), \
            f"Expected shift_database.csv to exist at '{database_path(client)}' after assignment, but it was not created"
        df = get_database(client)
        assert len(df.name) == 1, \
            f"Expected exactly 1 name in database after assigning one user, got {len(df.name)}: {df.name.tolist()}"
        assert df.name[0] == 'test0', \
            f"Expected assigned user name to be 'test0', got '{df.name[0]}'"
        assert df.role[0] == '.', \
            f"Expected assigned role to be '.' (default), got '{df.role[0]}'"
    check()

# assign then remove user
def test_assign1_remove1(page, client, users_added):

    # assign
    test_assign1(page, client, users_added)

    # unassign
    time.sleep(0.01)
    assign(page, 0, 1, 1)

    # check database is removed
    @try_again
    def check():
        assert os.path.isdir(os.path.dirname(database_path(client))), \
            f"Expected state directory '{os.path.dirname(database_path(client))}' to exist after unassignment, but it was not created"
        assert not os.path.exists(database_path(client)), \
            f"Expected shift_database.csv to be deleted after removing the only assigned user, but it still exists at '{database_path(client)}'"
    check()

# assign multiple users to the same cell
def test_assign_same(page, client, users_added):

    # assign test users to a cell
    for i in range(3):
        assign(page, i, 1, 1)

    # check that its in the database
    @try_again
    def check():
        df = get_database(client)

        assert len(df.name) == 3, \
            f"Expected exactly 3 names in database after assigning 3 users to the same cell, got {len(df.name)}: {df.name.tolist()}"
        assert df.name[0] == 'test0', \
            f"Expected first assigned user to be 'test0', got '{df.name[0]}'"
        assert df.name[1] == 'test1', \
            f"Expected second assigned user to be 'test1', got '{df.name[1]}'"
        assert df.name[2] == 'test2', \
            f"Expected third assigned user to be 'test2', got '{df.name[2]}'"
        assert df.role[0] == '.', \
            f"Expected test0's role to be '.' (default), got '{df.role[0]}'"
        assert df.role[1] == '.', \
            f"Expected test1's role to be '.' (default), got '{df.role[1]}'"
        assert df.role[2] == '.', \
            f"Expected test2's role to be '.' (default), got '{df.role[2]}'"
        assert df.time[0] == df.time[1], \
            f"Expected test0 and test1 to share the same timestamp, got {df.time[0]} and {df.time[1]}"
        assert df.time[0] == df.time[2], \
            f"Expected test0 and test2 to share the same timestamp, got {df.time[0]} and {df.time[2]}"
        assert df.time[0] % 1000 == 1, \
            f"Expected cell time index to be 1 (time % 1000 == 1), got {df.time[0] % 1000}"
    check()
# assign multiple users to the same cell then remove users until none
def test_assign_same_remove_queue(page, client, users_added):

    # assign users
    test_assign_same(page, client, users_added)

    # remove in the order they were assigned
    remove_one_at_a_time(page, client, [0, 1, 2])

def test_assign_same_remove_stack(page, client, users_added):

    # assign users
    test_assign_same(page, client, users_added)

    # remove in the reverse of the order they were assigned
    remove_one_at_a_time(page, client, [2, 1, 0])

# assign one user to multiple cells
def test_assign1_var(page, client, users_added):

    for row in range(1, 4):
        for col in range(1, 4):
            assign(page, 0, row, col)

    @try_again
    def check():

        # check that its in the database
        df = get_database(client)

        # check database contents
        assert len(df.name) == 9, \
            f"Expected exactly 9 rows in database after assigning one user to a 3x3 grid, got {len(df.name)}"
        for name in df.name:
            assert name == 'test0', \
                f"Expected all assigned names to be 'test0', got '{name}'"

        for role in df.role:
            assert role == '.', \
                f"Expected all roles to be '.' (default), got '{role}'"

        # check row id
        for i, t in enumerate(df.time):
            expected_row = i//3 + 1
            assert t % 1000 == expected_row, \
                f"Expected row index {expected_row} for entry {i} (time % 1000 == {expected_row}), got {t % 1000}"

        # check column id
        t0 = df.time[0]
        for i, t in enumerate(df.time):
            expected_offset = 86400000 * (i%3) + i//3
            assert t-t0 == expected_offset, \
                f"Expected time offset {expected_offset}ms for entry {i} (col {i%3+1}, row {i//3+1}), got {t-t0}"
    check()

# assign one user to multiple cells, then slowly remove in queue order
def test_assign1_var_remove(page, client, users_added):

    # assign users
    test_assign1_var(page, client, users_added)

    # check user is removed
    @try_again
    def check(i):
        if i > 0:
            df = get_database(client)
            assert len(df.name) == i, \
                f"Expected {i} row(s) remaining after removing cell (row {row}, col {col}), got {len(df.name)}"
        else:
            assert not os.path.exists(database_path(client)), \
                f"Expected shift_database.csv to be deleted after removing all assignments, but it still exists at '{database_path(client)}'"

    # slowly remove users in queue order
    i = 9
    for row in range(1, 4):
        for col in range(1, 4):
            assign(page, 0, row, col)
            i -= 1
            check(i)

# assign users to various cells and check that these are correct on refresh
def test_assign_refresh(page, client, users_added):

    for i in range(1, 8):
        assign(page, 0, 1, i)

    for i in [1, 3, 5, 7]:
        assign(page, 1, 1, i)

    for i in [2, 4, 6]:
        assign(page, 2, 2, i)
        assign(page, 3, 2, i)

    # check the database
    @try_again
    def check():
        df = get_database(client)

        df.loc[:,'time'] -= df.time.min() - 1
        df.sort_values('time', inplace=True)

        for name, g in df.groupby('name'):

            if name == 'test0':
                assert len(g) == 7, \
                    f"Expected test0 to have 7 assignments (all columns of row 1), got {len(g)}"
                for t in g.time:
                    assert t % 1000 == 1, \
                        f"Expected all test0 entries to be in row 1 (time % 1000 == 1), got {t % 1000}"
                assert all(g.time.diff().dropna() == 86400000), \
                    f"Expected test0's entries to be spaced 1 day apart (86400000ms), got {g.time.diff().dropna().tolist()}"

            elif name == 'test1':
                assert len(g) == 4, \
                    f"Expected test1 to have 4 assignments (odd columns of row 1), got {len(g)}"
                for t in g.time:
                    assert t % 1000 == 1, \
                        f"Expected all test1 entries to be in row 1 (time % 1000 == 1), got {t % 1000}"
                assert all(g.time.diff().dropna() == 86400000*2), \
                    f"Expected test1's entries to be spaced 2 days apart (86400000*2ms), got {g.time.diff().dropna().tolist()}"

            elif name in ('test2', 'test3'):
                assert len(g) == 3, \
                    f"Expected {name} to have 3 assignments (even columns of row 2), got {len(g)}"
                for t in g.time:
                    assert t % 1000 == 2, \
                        f"Expected all {name} entries to be in row 2 (time % 1000 == 2), got {t % 1000}"
                assert all(g.time.diff().dropna() == 86400000*2), \
                    f"Expected {name}'s entries to be spaced 2 days apart (86400000*2ms), got {g.time.diff().dropna().tolist()}"
    check()

    # refresh the page
    page.reload()

    # check that the web elements are correct ==============

    @try_again
    def check2():
        # check test0 is only first row and none others
        for row in range(1, 4):
            for col in range(1, 8):
                field = by_id(page, f'test0_{row}_{col}_.')
                expected = (row == 1)
                assert field.is_checked() == expected, \
                    f"Expected test0 checkbox at (row {row}, col {col}) to be {'checked' if expected else 'unchecked'} after refresh, but it was {'checked' if field.is_checked() else 'unchecked'}"

        # check test1 is only first row, odd columns
        for row in range(1, 4):
            for col in range(1, 8):
                field = by_id(page, f'test1_{row}_{col}_.')
                expected = (row == 1 and col%2 == 1)
                assert field.is_checked() == expected, \
                    f"Expected test1 checkbox at (row {row}, col {col}) to be {'checked' if expected else 'unchecked'} after refresh, but it was {'checked' if field.is_checked() else 'unchecked'}"

        # check test2 and test3 is only second row, even columns greater than zero
        for row in range(1, 4):
            for col in range(1, 8):
                field = by_id(page, f'test2_{row}_{col}_.')
                expected = (row == 2 and col>0 and col%2 == 0)
                assert field.is_checked() == expected, \
                    f"Expected test2 checkbox at (row {row}, col {col}) to be {'checked' if expected else 'unchecked'} after refresh, but it was {'checked' if field.is_checked() else 'unchecked'}"

                field = by_id(page, f'test3_{row}_{col}_.')
                assert field.is_checked() == expected, \
                    f"Expected test3 checkbox at (row {row}, col {col}) to be {'checked' if expected else 'unchecked'} after refresh, but it was {'checked' if field.is_checked() else 'unchecked'}"
    check2()

# colors on assignment
def test_assign_color(page, users_added):
    assign(page, 0, 1, 1)
    assign(page, 0, 1, 2)

    @try_again
    def check():
        for row in range(1, 4):
            for col in range(1, 8):
                cell = by_id(page, f'cell_{row}_{col}')
                if row == 1 and col in (1,2):
                    cell_color = conftest.rgb2hex(css_value(cell, 'background'))
                    target_color = page.evaluate('COLOR_NAME_HIGHLIGHT')
                    assert cell_color == target_color, \
                        f"Expected assigned cell at (row {row}, col {col}) to have COLOR_NAME_HIGHLIGHT ({target_color}), got '{cell_color}'"
                else:
                    cell_color = css_value(cell, 'background')
                    assert cell_color == 'none', \
                        f"Expected unassigned cell at (row {row}, col {col}) to have background 'none', got '{cell_color}'"
    check()

# assign a second person, hover, check that first color goes to green, second to purple
def test_assign_color_hover(page, users_added):
    # assign
    assign(page, 1, 1, 2)
    assign(page, 1, 1, 3)
    assign(page, 0, 1, 1)

    # check all colors
    for row in range(1, 4):
        for col in range(1, 8):
            cell = by_id(page, f'cell_{row}_{col}')
            cell_color = css_value(cell, 'background')
            if row == 1 and col in (1,2,3):
                target_color = page.evaluate('COLOR_SHIFT_ASSIGNED[0]')
            else:
                target_color = 'none'

            if cell_color != 'none':
                 cell_color = conftest.rgb2hex(cell_color)

            assert cell_color == target_color.lower(), \
                f"Expected cell at (row {row}, col {col}) to have color '{target_color.lower()}', got '{cell_color}'"

    # hover over person 2
    cell = by_id(page, f'cell_1_2')
    checkbox = by_id(page, f'test1_1_2_.')
    cell.click()
    checkbox.hover()
    checkbox.locator('xpath=..').hover()

    # check all colors again
    for row in range(1, 4):
        for col in range(1, 8):
            cell = by_id(page, f'cell_{row}_{col}')
            cell_color = css_value(cell, 'background')
            if row == 1 and col in (1,):
                target_color = page.evaluate('COLOR_SHIFT_ASSIGNED[0]')
            elif row == 1 and col in (2,3):
                target_color = page.evaluate('COLOR_NAME_HIGHLIGHT')
            else:
                target_color = 'none'

            if cell_color != 'none':
                 cell_color = conftest.rgb2hex(cell_color)

            assert cell_color == target_color.lower(), \
                f"Expected cell at (row {row}, col {col}) to have color '{target_color.lower()}' while hovering over test1, got '{cell_color}'"

    # hover over person 1
    cell = by_id(page, f'cell_1_1')
    checkbox = by_id(page, f'test0_1_1_.')
    cell.click()
    checkbox.hover()
    checkbox.locator('xpath=..').hover()

    # check all colors again
    for row in range(1, 4):
        for col in range(1, 8):
            cell = by_id(page, f'cell_{row}_{col}')
            cell_color = css_value(cell, 'background')
            if row == 1 and col in (1,):
                target_color = page.evaluate('COLOR_NAME_HIGHLIGHT')
            elif row == 1 and col in (2,3):
                target_color = page.evaluate('COLOR_SHIFT_ASSIGNED[0]')
            else:
                target_color = 'none'

            if cell_color != 'none':
                 cell_color = conftest.rgb2hex(cell_color)

            assert cell_color == target_color.lower(), \
                f"Expected cell at (row {row}, col {col}) to have color '{target_color.lower()}' while hovering over test0, got '{cell_color}'"

# check color cycling for more than one person per shift
def test_assign_color_cycle(page, users_added):

    target_colors = page.evaluate('COLOR_SHIFT_ASSIGNED')
    target_colors = [t.lower() for t in target_colors]

    # assign one and an extra person to get the color to change
    for npeople in range(9):
        assign(page, npeople, 1, 1)
        assign(page, 9, 2, 1)

        # check color
        cell = by_id(page, f'cell_1_1')
        cell_color = css_value(cell, 'background')
        cell_color = conftest.rgb2hex(cell_color)

        target_color = target_colors[min((npeople, len(target_colors)-1))]
        assert cell_color == target_color, \
            f"Expected cell color for {npeople+1} assignee(s) to be '{target_color}' (COLOR_SHIFT_ASSIGNED index {min(npeople, len(target_colors)-1)}), got '{cell_color}'"

# assign various roles
def test_assign_roles(page, client, users_added):

    # assign same in different roles
    assign(page, 0, 1, 1)
    select_role(page, 'Cryo')
    assign(page, 0, 1, 2, 'Cryo')
    select_role(page, 'Exp')
    assign(page, 0, 1, 3, 'Exp')

    # check database
    @try_again
    def check():
        df = get_database(client)

        assert all(df.name == 'test0'), \
            f"Expected all assigned names to be 'test0', got {df.name.tolist()}"
        assert df.loc[0, 'role'] == '.', \
            f"Expected first assignment role to be '.' (default), got '{df.loc[0, 'role']}'"
        assert df.loc[1, 'role'] == 'Cryo', \
            f"Expected second assignment role to be 'Cryo', got '{df.loc[1, 'role']}'"
        assert df.loc[2, 'role'] == 'Exp', \
            f"Expected third assignment role to be 'Exp', got '{df.loc[2, 'role']}'"
    check()

    # check colors
    @try_again
    def check2():
        target_color = page.evaluate('COLOR_NAME_HIGHLIGHT')
        for col in range(1, 3):
            cell = by_id(page, f'cell_1_{col}')
            cell_color = css_value(cell, 'background')
            cell_color = conftest.rgb2hex(cell_color)
            assert cell_color == target_color, \
                f"Expected cell at (row 1, col {col}) to have COLOR_NAME_HIGHLIGHT ({target_color}), got '{cell_color}'"

        cell = by_id(page, f'cell_1_3')
        color_grad = css_value(cell, 'background-image')
        color_grad = color_grad.split('rgb')[1:]
        color_grad = [c.split(')')[0] for c in color_grad]

        target_color_name = page.evaluate('COLOR_NAME_HIGHLIGHT')
        target_color_role = page.evaluate('COLOR_ROLE_HIGHLIGHT')

        assert conftest.rgb2hex(color_grad[0]) == target_color_name, \
            f"Expected gradient stop 0 of role cell to be COLOR_NAME_HIGHLIGHT ({target_color_name}), got '{conftest.rgb2hex(color_grad[0])}'"
        assert conftest.rgb2hex(color_grad[1]) == target_color_name, \
            f"Expected gradient stop 1 of role cell to be COLOR_NAME_HIGHLIGHT ({target_color_name}), got '{conftest.rgb2hex(color_grad[1])}'"
        assert conftest.rgb2hex(color_grad[2]) == target_color_role, \
            f"Expected gradient stop 2 of role cell to be COLOR_ROLE_HIGHLIGHT ({target_color_role}), got '{conftest.rgb2hex(color_grad[2])}'"
        assert conftest.rgb2hex(color_grad[3]) == target_color_role, \
            f"Expected gradient stop 3 of role cell to be COLOR_ROLE_HIGHLIGHT ({target_color_role}), got '{conftest.rgb2hex(color_grad[3])}'"
    check2()

    # check text in summary label
    @try_again
    def check3():
        summary = by_id(page, f'summary_1_1')
        assert summary.inner_html() == 'test0', \
            f"Expected summary at (row 1, col 1) to be 'test0' (default role), got '{summary.inner_html()}'"

        summary = by_id(page, f'summary_1_2')
        assert summary.inner_html() == 'test0 (Cryo)', \
            f"Expected summary at (row 1, col 2) to be 'test0 (Cryo)', got '{summary.inner_html()}'"

        summary = by_id(page, f'summary_1_3')
        assert summary.inner_html() == 'test0 (Exp)', \
            f"Expected summary at (row 1, col 3) to be 'test0 (Exp)', got '{summary.inner_html()}'"
    check3()

# check summary text
def test_assign_summary(page, users_added):

    summary = by_id(page, f'summary_1_1')

    # one
    assign(page, 0, 1, 1)

    @try_again
    def check1():
        text = summary.inner_html()
        assert text == 'test0', \
            f"Expected summary to show 'test0' after assigning one user, got '{text}'"
    check1()

    # two
    assign(page, 1, 1, 1)

    @try_again
    def check2():
        text = summary.inner_html()
        assert text == 'test0\ntest1', \
            f"Expected summary to show test0 and test1 on separate lines after assigning two users, got '{text}'"
    check2()

    # three
    assign(page, 2, 1, 1)

    @try_again
    def check3():
        text = summary.inner_html()
        assert text == 'test0\ntest1\ntest2', \
            f"Expected summary to show test0, test1 and test2 on separate lines after assigning three users, got '{text}'"
    check3()

    # different role
    select_role(page, 'Cryo')
    assign(page, 3, 1, 1, 'Cryo')

    @try_again
    def check4():
        text = summary.inner_html()
        assert text == 'test0\ntest1\ntest2\ntest3 (Cryo)', \
            f"Expected summary to list test0, test1, test2 and test3 (Cryo) on separate lines, got '{text}'"
    check4()

# check dropdown menus sync
def test_assign_dropdownsync(page, users_added):
    select_avail = by_id(page, 'avail_select')
    select_highl = by_id(page, 'name_highlight_select')

    # check avail dropdown on assignment
    @try_again
    def check(i):
        # an option's accessible name is its label property
        labels_avail = select_avail.evaluate('el => [...el.selectedOptions].map(o => o.label)')
        assert len(labels_avail) == 1, \
            f"Expected exactly 1 selected option in avail dropdown after assigning test{i}, got {len(labels_avail)}"
        opt = labels_avail[0]
        assert opt == f'test{i}', \
            f"Expected avail dropdown to show 'test{i}' after assigning test{i}, got '{opt}'"

    for i in range(3):
        assign(page, i, 1, 1)
        check(i)

    # check avail dropdown and highlight dropdown on hover
    cell = by_id(page, f'cell_1_2')

    # check selects
    @try_again
    def check2(i):
        labels_avail = select_avail.evaluate('el => [...el.selectedOptions].map(o => o.label)')
        opt = labels_avail[0]
        assert len(labels_avail) == 1, \
            f"Expected exactly 1 selected option in avail dropdown while hovering over test{i}, got {len(labels_avail)}"
        assert opt == f'test{i}', \
            f"Expected avail dropdown to show 'test{i}' while hovering over test{i}, got '{opt}'"

        labels_highl = select_highl.evaluate('el => [...el.selectedOptions].map(o => o.label)')
        opt = labels_highl[0]
        assert len(labels_highl) == 1, \
            f"Expected exactly 1 selected option in highlight dropdown while hovering over test{i}, got {len(labels_highl)}"
        assert opt == f'test{i}', \
            f"Expected highlight dropdown to show 'test{i}' while hovering over test{i}, got '{opt}'"

    for i in range(3):
        checkbox = by_id(page, f'test{i}_1_2_.')
        if i == 0: cell.click()
        checkbox.hover()
        checkbox.locator('xpath=..').hover()
        check2(i)

# check that checking "assign only available" hides unavailable shifters
# and unchecking restores them
def test_assign_only_available_checkbox(page, users_added):

    # set availability for test0 only, at row 1, col 1
    select_user(page, 'test0')
    cell = by_id(page, 'availcell_1_1')
    cell.click()
    time.sleep(0.05)

    # open the dropdown for cell (row 1, col 1)
    cell = by_id(page, 'cell_1_1')
    cell.click()

    # with checkbox unchecked (default), all users should be visible
    chkbx = by_id(page, 'allow_only_avail_chkbx')

    @try_again
    def check1():
        assert not chkbx.is_checked(), \
            f"Expected 'assign only available' checkbox to be unchecked by default"

        for i in range(3):
            opt = by_id(page, f'test{i}_1_1_.')
            assert opt.evaluate('el => el.parentElement.hidden') == False, \
                f"Expected test{i} option to be visible in cell (row 1, col 1) before enabling 'only available', but it was hidden"
    check1()

    # enable the checkbox
    chkbx.click()
    time.sleep(0.1)

    # reopen the dropdown
    cell.click()

    # test0 is available: should be visible
    opt0 = by_id(page, 'test0_1_1_.')

    @try_again
    def check2():
        assert opt0.evaluate('el => el.parentElement.parentElement.hidden') == False, \
            f"Expected test0 option to remain visible in cell (row 1, col 1) after enabling 'only available' (test0 is available), but it was hidden"

        # test1 and test2 are not available: should be hidden
        for i in (1, 2):
            opt = by_id(page, f'test{i}_1_1_.')
            assert opt.evaluate('el => el.parentElement.parentElement.hidden') == True, \
                f"Expected test{i} option to be hidden in cell (row 1, col 1) after enabling 'only available' (test{i} is not available), but it was visible"
    check2()

    # uncheck the checkbox
    chkbx.click()
    time.sleep(0.1)

    # reopen the dropdown
    cell.click()

    @try_again
    def check3():
        # all users should be visible again
        for i in range(3):
            opt = by_id(page, f'test{i}_1_1_.')
            assert opt.evaluate('el => el.parentElement.parentElement.hidden') == False, \
                f"Expected test{i} option to be visible in cell (row 1, col 1) after disabling 'only available', but it was hidden"
    check3()

# check that a shifter's checkbox is disabled when they are assigned a shift
# within the minimum rest period, and re-enabled outside of it
def test_assign_rest_time_disabled(page, client, users_added):

    # set odb value for rest period
    client.odb_set('/Shifts/Settings/min_hours_between_shifts', 0)
    time.sleep(0.05)

    # assign test0 to row 1, col 1
    assign(page, 0, 1, 1)

    # open cell (row 2, col 1) which is one shift later
    cell = by_id(page, 'cell_2_1')
    cell.click()

    # test0's checkbox in the adjacent cell should be enabled
    checkbox = by_id(page, 'test0_2_1_.')

    @try_again
    def check0():
        assert checkbox.is_disabled() == False, \
            f"Expected test0's checkbox at (row 2, col 1) to be enabled due to rest time of zero hours after assigning (row 1, col 1), but it was disabled"
    check0()

    # set odb value for rest period to something larger
    client.odb_set('/Shifts/Settings/min_hours_between_shifts', 7)
    page.reload()

    time.sleep(0.05)

    # open cell (row 2, col 1) which is one shift later
    cell = by_id(page, 'cell_2_1')
    cell.click()

    @try_again
    def check1():

        # test0's checkbox in the adjacent cell should be disabled
        checkbox = by_id(page, 'test0_2_1_.')
        assert checkbox.is_disabled() == True, \
            f"Expected test0's checkbox at (row 2, col 1) to be disabled due to rest time of seven hours after assigning (row 1, col 1), but it was enabled"

        # the label should be struck through
        decoration = checkbox.evaluate(
            "el => getComputedStyle(el.parentElement.getElementsByClassName('inputLabel')[0]).textDecorationLine")
        assert decoration == 'line-through', \
            f"Expected test0's label at (row 2, col 1) to be struck through when disabled due to rest time, got '{decoration}'"

        # check mouseover text
        title = checkbox.evaluate('el => el.parentElement.title')
        assert title == 'Shifts must be at least 7 hours apart', \
            f"Expected test0's title at (row 2, col 1) to be 'Shifts must be at least 7 hours apart', got '{title}' "
    check1()

    # close the cell
    cell.click()

    # open a cell far enough away that rest time is not an issue (two shifts later)
    cell = by_id(page, 'cell_3_1')
    cell.click()

    @try_again
    def check2():
        checkbox_far = by_id(page, 'test0_3_1_.')
        assert checkbox_far.is_disabled() == False, \
            f"Expected test0's checkbox at (row 3, col 1) to be enabled (outside rest period from col 1), but it was disabled"
    check2()

# check that a previously disabled checkbox becomes re-enabled after the
# conflicting assignment is removed
def test_assign_rest_time_reenabled(page, client, users_added):

    # set odb value for rest period
    client.odb_set('/Shifts/Settings/min_hours_between_shifts', 7)
    time.sleep(0.05)

    # assign test0 to row 1, col 1
    assign(page, 0, 1, 1)

    # confirm test0 is disabled at row 2
    cell = by_id(page, 'cell_2_1')
    cell.click()

    @try_again
    def check0():
        checkbox = by_id(page, 'test0_2_1_.')
        assert checkbox.is_disabled() == True, \
            f"Expected test0's checkbox at (row 1, col 2) to be disabled before removing the conflicting assignment, but it was enabled"
    check0()

    # close the dropdown by clicking elsewhere
    page.locator('h1').click()

    # remove the assignment at col 1
    assign(page, 0, 1, 1)

    # reopen cell (row 2, col 1)
    cell = by_id(page, 'cell_2_1')
    cell.click()

    @try_again
    def check1():
        # test0 should now be re-enabled
        checkbox = by_id(page, 'test0_2_1_.')
        assert checkbox.is_disabled() == False, \
            f"Expected test0's checkbox at (row 2, col 1) to be re-enabled after removing the conflicting assignment at (row 1, col 1), but it was still disabled"

        # the label should no longer be struck through
        decoration = checkbox.evaluate(
            "el => getComputedStyle(el.parentElement.getElementsByClassName('inputLabel')[0]).textDecorationLine")
        assert decoration == 'none', \
            f"Expected test0's label at (row 2, col 1) to no longer be struck through after re-enabling, got '{decoration}'"
    check1()

# check that the role dropdown filters which checkboxes are visible per cell
def test_assign_dropdown_role_filter(page, users_added):

    # with default role '.', open a cell and check all users are visible
    cell = by_id(page, 'cell_1_1')
    cell.click()

    @try_again
    def check0():
        for i in range(3):
            opt = by_id(page, f'test{i}_1_1_.')
            assert opt.evaluate('el => el.parentElement.hidden') == False, \
                f"Expected test{i} default-role option to be visible at (row 1, col 1) with default role selected, but it was hidden"
    check0()

    # close dropdown
    page.locator('h1').click()
    time.sleep(0.05)

    # switch to role 'Cryo'
    select_role(page, 'Cryo')
    time.sleep(0.1)

    # reopen cell
    cell.click()

    @try_again
    def check1():

        # Cryo-role checkboxes should be visible, default-role checkboxes should be hidden
        for i in range(3):
            opt_cryo = by_id(page, f'test{i}_1_1_Cryo')
            assert opt_cryo.evaluate('el => el.parentElement.parentElement.hidden') == False, \
                f"Expected test{i} Cryo-role option to be visible at (row 1, col 1) with 'Cryo' role selected, but it was hidden"

            opt_default = by_id(page, f'test{i}_1_1_.')
            assert opt_default.evaluate('el => el.parentElement.parentElement.hidden') == True, \
                f"Expected test{i} default-role option to be hidden at (row 1, col 1) with 'Cryo' role selected, but it was visible"
    check1()

    # close dropdown
    page.locator('h1').click()
    time.sleep(0.05)

    # switch back to default role '.'
    select_role(page, '.')
    time.sleep(0.1)

    # reopen cell
    cell.click()

    @try_again
    def check2():

        # default-role checkboxes should be visible again, Cryo hidden
        for i in range(3):
            opt_default = by_id(page, f'test{i}_1_1_.')
            assert opt_default.evaluate('el => el.parentElement.parentElement.hidden') == False, \
                f"Expected test{i} default-role option to be visible again at (row 1, col 1) after switching back to default role, but it was hidden"

            opt_cryo = by_id(page, f'test{i}_1_1_Cryo')
            assert opt_cryo.evaluate('el => el.parentElement.parentElement.hidden') == True, \
                f"Expected test{i} Cryo-role option to be hidden at (row 1, col 1) after switching back to default role, but it was visible"

    check2()

# a shifter whose name contains the character that separates the DOM id fields must still
# be assignable: the id was split apart positionally, so "van_der_waals" used to be read
# back as name "van" in role "der"
def test_assign_underscore_name(page, client):

    # add a shifter directly, then reload so the calendar is built with them in it
    client.odb_set('/Shifts/ContactInfo/van_der_waals/email', 'vdw@test.ca')
    client.odb_set('/Shifts/ContactInfo/van_der_waals/affiliation', 'uni')
    client.odb_set('/Shifts/ContactInfo/van_der_waals/phone_call', '1 111-111-1111')
    page.reload()

    by_id(page, 'cell_1_1').click()
    by_id(page, 'van_der_waals_1_1_.').click()
    time.sleep(0.1)

    @try_again
    def check():
        df = get_database(client)

        assert len(df) == 1, \
            f"Expected 1 assignment row, got {len(df)}: {df.to_dict('records')}"
        assert df.loc[0, 'name'] == 'van_der_waals', \
            f"Expected the full name to be stored, got '{df.loc[0, 'name']}'"

        # role is written as NaN by pandas when blank, which is the '.' default
        role = df.loc[0, 'role']
        assert pd.isna(role) or role == '.', \
            f"Expected a blank or '.' role, got '{role}'"
    check()

    # the cell summary is rebuilt from the same fields, so it catches the split too
    @try_again
    def check_summary():
        summary = by_id(page, 'summary_1_1').inner_text()
        assert summary == 'van_der_waals', \
            f"Expected cell summary to read 'van_der_waals', got '{summary}'"
    check_summary()

# blanking a shift name is the documented way to retire a shift. the names were filtered
# before being paired with the shift ids but the ids were not, so every row below a blanked
# entry was built with the wrong shiftid
def test_assign_blank_middle_shift(page, client, users_added):

    # retire the middle shift (DAY, id 2), leaving OWL id 1 and EVE id 3
    names = client.odb_get('/Shifts/ShiftSetup/name')
    names[1] = ''
    client.odb_set('/Shifts/ShiftSetup/name', names)
    page.reload()

    @try_again
    def check_rows():
        # two shift rows remain, and the second one must be EVE's id, not DAY's
        assert by_id(page, 'cell_1_1').is_visible(), \
            "Expected the OWL row (shiftid 1) to still be present"
        assert by_id(page, 'cell_3_1').is_visible(), \
            "Expected the EVE row (shiftid 3) to be present after blanking the DAY name"
        assert by_id(page, 'cell_2_1').count() == 0, \
            "Expected no row for the blanked shift (shiftid 2)"
    check_rows()

    # assigning on the second visible row must record shiftid 3
    by_id(page, 'cell_3_1').click()
    by_id(page, 'test0_3_1_.').click()
    time.sleep(0.1)

    @try_again
    def check_db():
        df = get_database(client)
        assert len(df) == 1, f"Expected 1 assignment, got {len(df)}"
        assert df.time[0] % 1000 == 3, \
            f"Expected the assignment to carry shiftid 3 (EVE), got {df.time[0] % 1000}"
    check_db()

@pytest.fixture
def prefix_user(page, client, users_added):
    """Add a shifter called 'test', whose name is a prefix of the existing 'test2'

    The pair is what makes a substring match on the summary text visible: highlighting
    'test' must not color the cells belonging to 'test2'.
    """
    client.odb_set("/Shifts/ContactInfo/test/email", 'test@test.ca')
    client.odb_set("/Shifts/ContactInfo/test/affiliation", 'uni')
    client.odb_set("/Shifts/ContactInfo/test/phone_call", '1 000-000-0000')

    # nothing bumps the state id when contacts change, so the page has to be redrawn from
    # scratch to pick the new shifter up
    page.reload()
    expect(by_id(page, 'cell_1_1')).to_be_attached()

    yield

def cell_color(page, row, col):
    """Base background color of a schedule cell, as a hex string

    Moves the pointer off the table first: a hovered cell shows a darkened color rather
    than the color update_summaries gave it.
    """
    page.mouse.move(0, 0)
    return conftest.rgb2hex(css_value(by_id(page, f'cell_{row}_{col}'), 'background-color'))

# check that highlighting a shifter colors only that shifter's cells, even when another
# shifter's name contains theirs
def test_highlight_name_exact_match(page, client, prefix_user):
    color_highlight = page.evaluate('COLOR_NAME_HIGHLIGHT').lower()
    color_assigned = page.evaluate('COLOR_SHIFT_ASSIGNED[0]').lower()

    # 'test' on cell (1,1) and 'test2' on cell (1,2). assign() builds the checkbox id as
    # f'test{id}', so an empty id picks the shifter named plain 'test'
    assign(page, '', 1, 1)
    assign(page, 2, 1, 2)

    select = by_id(page, 'name_highlight_select')

    @try_again
    def check(highlighted_col, plain_col, name):
        color = cell_color(page, 1, highlighted_col)
        assert color == color_highlight, \
            f"Expected cell (1,{highlighted_col}) to be highlighted ({color_highlight}) " \
            f"while highlighting '{name}', got {color}"

        color = cell_color(page, 1, plain_col)
        assert color == color_assigned, \
            f"Expected cell (1,{plain_col}) to keep the assigned color ({color_assigned}) " \
            f"while highlighting '{name}', got {color}"

    # 'test' is a substring of 'test2', so this is the case that colored both cells
    page.mouse.move(0, 0)
    select.select_option('test')
    check(1, 2, 'test')

    # the other way round, which never had the substring problem
    select.select_option('test2')
    check(2, 1, 'test2')

    # clearing the selection returns both cells to the assigned color
    select.select_option('')

    @try_again
    def check_cleared():
        for col in (1, 2):
            color = cell_color(page, 1, col)
            assert color == color_assigned, \
                f"Expected cell (1,{col}) to return to the assigned color " \
                f"({color_assigned}) once the highlight is cleared, got {color}"
    check_cleared()

# check that the role highlight follows the role assigned to a shifter, and not text which
# merely looks like a role in the summary
def test_highlight_role_exact_match(page, client, users_added):

    # the role select gates which dropdown entries are shown, so each assignment needs its
    # own role selected first. '.', the no-role placeholder, is what the page starts on
    assign(page, 1, 1, 2)
    select_role(page, 'Cryo')
    assign(page, 0, 1, 1, 'Cryo')

    def gradient(col):
        page.mouse.move(0, 0)
        return css_value(by_id(page, f'cell_1_{col}'), 'background-image')

    @try_again
    def check_cryo():
        assert gradient(1) != 'none', \
            "Expected the cell holding a Cryo shifter to show the role gradient"
        assert gradient(2) == 'none', \
            "Expected the cell holding a shifter with no role to show no role gradient"
    check_cryo()

    # a different role clears the gradient everywhere
    select_role(page, 'Exp')

    @try_again
    def check_exp():
        for col in (1, 2):
            assert gradient(col) == 'none', \
                f"Expected no role gradient on cell (1,{col}) while highlighting Exp"
    check_exp()

# check that the highlight previewed by hovering a name in a shift dropdown is undone once
# the pointer leaves the cell
def test_highlight_hover_preview_reverts(page, client, users_added):
    assign(page, 0, 1, 1)
    assign(page, 1, 1, 1)

    select_highl = by_id(page, 'name_highlight_select')
    select_avail = by_id(page, 'avail_select')

    # settle on a selection with the pointer away from the table, so the assignment clicks
    # above cannot leave a preview of their own in place
    page.mouse.move(0, 0)
    select_highl.select_option('test0')

    # hovering a name previews it
    by_id(page, 'cell_1_1').click()
    by_id(page, 'test1_1_1_.').locator('xpath=..').hover()

    @try_again
    def check_preview():
        value = select_highl.input_value()
        assert value == 'test1', \
            f"Expected the highlight dropdown to preview 'test1' while hovering it, got '{value}'"
    check_preview()

    # leaving the cell puts the selection made before the hover back
    page.mouse.move(0, 0)

    @try_again
    def check_reverted():
        value = select_highl.input_value()
        assert value == 'test0', \
            f"Expected the highlight dropdown to return to 'test0' after the pointer left " \
            f"the cell, got '{value}'"

        value = select_avail.input_value()
        assert value == 'test0', \
            f"Expected the availability dropdown to return to 'test0' after the pointer " \
            f"left the cell, got '{value}'"
    check_reverted()
