# test adding user
# Derek Fujimoto
# Mar 2026

from fixtures import *
import time

def open_addshifter(page):
    """Open the add shifter dialog and return the page it lives in

    Returns the main page rather than a page window: the dialog is cloned into this document
    by open_shifter_dlg(). Callers keep the return value so they read the same handle either
    way.
    """

    # a dialog left over from an earlier step would make the wait below pass without the
    # button ever having been clicked, so name that case here instead
    expect(by_id(page, 'dlgAddShifter')).to_have_count(0)

    by_id(page, 'addshifter_button').click()
    expect(by_id(page, 'dlgAddShifter')).to_be_visible()
    return page

def submit_addshifter(page, name='', email='', phone='', affiliation=''):
    """Fill in any non-empty fields and click Submit"""
    if name:
        by_id(page, 'addshifter_name').fill(name)
    if email:
        by_id(page, 'addshifter_email').fill(email)
    if phone:
        by_id(page, 'addshifter_phone_call').fill(phone)
    if affiliation:
        by_id(page, 'addshifter_affiliation').fill(affiliation)
    by_id(page, 'addshifter_submit').click()

def wait_submitted(page):
    """Wait for the add dialog to destroy itself, which it does once the submission has been
    written

    add_shifter() makes eight sequential rpc calls before closing the dialog, so a fixed sleep
    here races the writes when the machine is busy.
    """
    expect(by_id(page, 'dlgAddShifter')).to_have_count(0, timeout=WRITE_TIMEOUT_MS)

def close_addshifter(page):
    """Dismiss the add dialog with its Cancel button

    Needed rather than just leaving it open: dlgShow() lays #dlgBlackout over the page while a
    modal dialog is up, and that intercepts any later click on the main page.
    """
    by_id(page, 'addshifter_cancel').click()
    expect(by_id(page, 'dlgAddShifter')).to_have_count(0)

def open_editshifter(page):
    """Open the edit shifter dialog and return the page it lives in"""
    expect(by_id(page, 'dlgEditShifter')).to_have_count(0)

    by_id(page, 'editshifter_button').click()
    expect(by_id(page, 'dlgEditShifter')).to_be_visible()
    return page

def close_editshifter(page):
    """Dismiss the edit dialog with its Cancel button, clearing the modal blackout"""
    by_id(page, 'editshifter_cancel').click()
    expect(by_id(page, 'dlgEditShifter')).to_have_count(0)

def select_edit_user(page, name):
    """Select a user in the edit dialog and wait for populate_editshifter_fields() to complete"""
    by_id(page, 'editshifter_name_select').select_option(name)

    # email is required in the ODB, so it is always non-empty once the fetch has landed
    expect(by_id(page, 'editshifter_email')).not_to_have_value('')

def submit_editshifter(page):
    """Click the Update button in the edit dialog"""
    by_id(page, 'editshifter_submit').click()

def is_valid(page, field_id):
    """Whether a form field passes the browser's own validity check"""
    return by_id(page, field_id).evaluate('el => el.validity.valid')

def test_adduser(page, client):

    # add shifter
    open_addshifter(page)
    submit_addshifter(page, 'test guy', 'test@test.ca', '1 123-123-1234', 'university')

    # check ODB for matching entry
    @try_again
    def check():
        contacts = client.odb_get('Shifts/ContactInfo')
        assert len(list(contacts.keys())) == 1, \
            f"Expected exactly 1 contact after adding a user, found {len(list(contacts.keys()))}: {list(contacts.keys())}"
        name = list(contacts.keys())[0]
        assert name == 'test guy', \
            f"Expected contact name in ODB to be 'test guy', got '{name}'"

        info = contacts[name]
        assert info['email'] == 'test@test.ca', \
            f"Expected contact email to be 'test@test.ca', got '{info['email']}'"
        assert info['phone_call'] == '1 123-123-1234', \
            f"Expected contact phone number to be '1 123-123-1234', got '{info['phone_call']}'"
        assert info['affiliation'] == 'university', \
            f"Expected contact affiliation to be 'university', got '{info['affiliation']}'"
    check()

# ==================== add shifter dialog ====================

# check that adding a second distinct user results in two ODB entries
def test_adduser_two_users(page, client):

    # add first user - dialog closes automatically on success
    open_addshifter(page)
    submit_addshifter(page, name='alice', email='alice@test.ca',
                      phone='1 111-111-1111', affiliation='university')
    wait_submitted(page)

    # add second user
    open_addshifter(page)
    submit_addshifter(page, name='bob', email='bob@test.ca',
                      phone='1 222-222-2222', affiliation='institute')
    wait_submitted(page)

    @try_again
    def check():
        contacts = client.odb_get('Shifts/ContactInfo')
        keys = sorted(contacts.keys())

        assert len(keys) == 2, \
            f"Expected 2 contacts after adding two users, got {len(keys)}: {keys}"
        assert 'alice' in keys, \
            f"Expected 'alice' to be present in ODB contacts, got {keys}"
        assert 'bob' in keys, \
            f"Expected 'bob' to be present in ODB contacts, got {keys}"

        assert contacts['alice']['email'] == 'alice@test.ca', \
            f"Expected alice's email to be 'alice@test.ca', got '{contacts['alice']['email']}'"
        assert contacts['bob']['affiliation'] == 'institute', \
            f"Expected bob's affiliation to be 'institute', got '{contacts['bob']['affiliation']}'"
    check()

# check that adding a duplicate name shows a dlgAlert and does not create a second ODB entry
def test_adduser_duplicate(page, client):

    # add the user once - dialog closes automatically
    open_addshifter(page)
    submit_addshifter(page, name='test guy', email='test@test.ca',
                      phone='1 123-123-1234', affiliation='university')
    wait_submitted(page)

    # attempt to add the same name again
    open_addshifter(page)
    submit_addshifter(page, name='test guy', email='other@test.ca',
                      phone='1 999-999-9999', affiliation='other place')
    time.sleep(0.2)

    @try_again
    def check():

        # dialog should still be open because add_shifter() called dlgAlert and returned early
        assert by_id(page, 'dlgAddShifter').is_visible(), \
            "Expected the add shifter dialog to remain open after a duplicate name submission"

        # a dlgAlert dialog should be visible
        dialog = by_id(page, 'dlgMessageTitle').first
        assert dialog.is_visible(), \
            f"Expected a dlgAlert dialog to appear after submitting a duplicate name, but it was not displayed"
    check()

    # dismiss the alert and close the dialog
    by_id(page, 'dlgMessageButton').first.click()
    close_addshifter(page)

    @try_again
    def check2():

        # ODB should still have exactly one entry for this name
        contacts = client.odb_get('Shifts/ContactInfo')
        keys = list(contacts.keys())
        assert len(keys) == 1, \
            f"Expected exactly 1 contact after a duplicate submission was blocked, got {len(keys)}: {keys}"
        assert keys[0] == 'test guy', \
            f"Expected the single contact to be 'test guy', got '{keys[0]}'"

        # the original data should be unchanged
        assert contacts['test guy']['email'] == 'test@test.ca', \
            f"Expected original email 'test@test.ca' to be preserved after blocked duplicate, " \
            f"got '{contacts['test guy']['email']}'"
    check2()

# check that submitting with an empty name field is blocked by browser validation
# (name field has required + minlength="2")
def test_adduser_empty_name(page, client):

    open_addshifter(page)
    submit_addshifter(page, name='', email='test@test.ca',
                      phone='1 123-123-1234', affiliation='university')
    time.sleep(0.1)

    @try_again
    def check():
        # dialog should still be open - browser validity check blocked add_shifter()
        assert by_id(page, 'dlgAddShifter').is_visible(), \
            "Expected the add shifter dialog to remain open after submitting with an empty name"

        assert not is_valid(page, 'addshifter_name'), \
            f"Expected the name field to be invalid when empty, but validity.valid was True"
    check()

    close_addshifter(page)

    @try_again
    def check1():
        assert not client.odb_exists('Shifts/ContactInfo'), \
            f"Expected ContactInfo directory to be missing from /Shifts, it exists"
    check1()

# check that a name shorter than 2 characters is blocked (minlength="2")
def test_adduser_name_too_short(page, client):

    open_addshifter(page)
    submit_addshifter(page, name='x', email='test@test.ca',
                      phone='1 123-123-1234', affiliation='university')
    time.sleep(0.1)

    @try_again
    def check():
        assert by_id(page, 'dlgAddShifter').is_visible(), \
            "Expected the add shifter dialog to remain open after submitting a 1-character name"

        assert not is_valid(page, 'addshifter_name'), \
            f"Expected the name field to be invalid for a 1-character value (minlength=2), but validity.valid was True"
    check()

    close_addshifter(page)

    @try_again
    def check1():
        assert not client.odb_exists('Shifts/ContactInfo'), \
            f"Expected ContactInfo directory to be missing from /Shifts, it exists"
    check1()

# check that submitting with an empty email field is blocked (email is required)
def test_adduser_empty_email(page, client):

    open_addshifter(page)
    submit_addshifter(page, name='test guy', email='',
                      phone='1 123-123-1234', affiliation='university')
    time.sleep(0.1)

    assert by_id(page, 'dlgAddShifter').is_visible(), \
        "Expected the add shifter dialog to remain open after submitting with an empty email"

    assert not is_valid(page, 'addshifter_email'), \
        f"Expected the email field to be invalid when empty, but validity.valid was True"

    close_addshifter(page)

    @try_again
    def check1():
        assert not client.odb_exists('Shifts/ContactInfo'), \
            f"Expected ContactInfo directory to be missing from /Shifts, it exists"
    check1()

# check that a malformed email address is blocked (type="email")
def test_adduser_invalid_email(page, client):

    open_addshifter(page)
    submit_addshifter(page, name='test guy', email='not-an-email',
                      phone='1 123-123-1234', affiliation='university')
    time.sleep(0.1)

    assert by_id(page, 'dlgAddShifter').is_visible(), \
        "Expected the add shifter dialog to remain open after submitting a malformed email"

    assert not is_valid(page, 'addshifter_email'), \
        f"Expected the email field to be invalid for 'not-an-email', but validity.valid was True"

    close_addshifter(page)

    @try_again
    def check1():
        assert not client.odb_exists('Shifts/ContactInfo'), \
            f"Expected ContactInfo directory to be missing from /Shifts, it exists"
    check1()

# check that a phone number not matching the required pattern is blocked
# pattern: ^[0-9]{1,}[ .\-][0-9]{3}[ .\-][0-9]{3}[ .\-][0-9]{4}$
def test_adduser_invalid_phone(page, client):

    open_addshifter(page)
    submit_addshifter(page, name='test guy', email='test@test.ca',
                      phone='not-a-number', affiliation='university')
    time.sleep(0.1)

    @try_again
    def check():
        assert by_id(page, 'dlgAddShifter').is_visible(), \
            "Expected the add shifter dialog to remain open after submitting an invalid phone number"

        assert not is_valid(page, 'addshifter_phone_call'), \
            f"Expected the phone field to be invalid for 'not-a-number', but validity.valid was True"
    check()

    close_addshifter(page)

    @try_again
    def check1():
        assert not client.odb_exists('Shifts/ContactInfo'), \
            f"Expected ContactInfo directory to be missing from /Shifts, it exists"
    check1()

# check that affiliation is optional: omitting it still creates a valid ODB entry
# (affiliation has no 'required' attribute)
def test_adduser_affiliation_optional(page, client):

    open_addshifter(page)
    submit_addshifter(page, name='test guy', email='test@test.ca',
                      phone='1 123-123-1234', affiliation='')
    time.sleep(0.2)

    @try_again
    def check():
        contacts = client.odb_get('Shifts/ContactInfo')
        assert contacts is not None and 'test guy' in contacts.keys(), \
            f"Expected 'test guy' to be added to ODB even without an affiliation, " \
            f"got {list(contacts.keys()) if contacts else None}"
    check()

# check that a newly added user appears in the avail dropdown on the main page
# (add_shifter() calls populate_avail_dropdown() on the opener before closing)
def test_adduser_appears_in_avail_dropdown(page, client):

    open_addshifter(page)
    submit_addshifter(page, name='test guy', email='test@test.ca',
                      phone='1 123-123-1234', affiliation='university')
    time.sleep(0.2)

    @try_again
    def check():
        option_values = by_id(page, 'avail_select').evaluate(
            'el => [...el.options].map(o => o.value)')

        assert 'test guy' in option_values, \
            f"Expected 'test guy' to appear in the avail dropdown after being added, got {option_values}"
    check()

# ==================== edit shifter dialog ====================

# check that the edit shifter dialog opens with the name dropdown populated
def test_editshifter_dlg_opens(page, users_added):

    open_editshifter(page)
    time.sleep(0.2)

    @try_again
    def check1():
        assert by_id(page, 'dlgEditShifter').is_visible(), \
            "Expected the edit shifter dialog to be open"

        name_select = by_id(page, 'editshifter_name_select')
        assert name_select.count() > 0, \
            f"Expected a name dropdown in the edit dialog, but it was not found"

        options = [value for value in name_select.evaluate('el => [...el.options].map(o => o.value)')
                if value != '']
        assert len(options) > 0, \
            f"Expected name_select to be populated with at least one user on load, got {options}"

        for field_id in ('editshifter_email', 'editshifter_phone_call',
                         'editshifter_affiliation', 'editshifter_submit'):
            assert by_id(page, field_id).count() > 0, \
                f"Expected field with id='{field_id}' to be present in the edit shifter dialog, but it was not found"
    check1()

    close_editshifter(page)

# check that selecting a user in the edit dialog populates fields with their ODB values
def test_editshifter_fields_populate(page, client, users_added):

    open_editshifter(page)
    time.sleep(0.2)

    select_edit_user(page, 'test0')

    @try_again
    def check():
        contacts = client.odb_get('Shifts/ContactInfo')

        email_val = by_id(page, 'editshifter_email').input_value()
        assert email_val == contacts['test0']['email'], \
            f"Expected email field to show '{contacts['test0']['email']}' after selecting test0, got '{email_val}'"

        phone_val = by_id(page, 'editshifter_phone_call').input_value()
        assert phone_val == contacts['test0']['phone_call'], \
            f"Expected phone field to show '{contacts['test0']['phone_call']}' after selecting test0, got '{phone_val}'"

        affiliation_val = by_id(page, 'editshifter_affiliation').input_value()
        assert affiliation_val == contacts['test0']['affiliation'], \
            f"Expected affiliation field to show '{contacts['test0']['affiliation']}' after selecting test0, got '{affiliation_val}'"
    check()

    close_editshifter(page)

# check that switching between users in the edit dialog repopulates fields correctly
def test_editshifter_fields_switch_user(page, client, users_added):

    open_editshifter(page)
    time.sleep(0.2)

    @try_again
    def check():
        contacts = client.odb_get('Shifts/ContactInfo')

        for name in ('test0', 'test1', 'test0'):
            select_edit_user(page, name)

            email_val = by_id(page, 'editshifter_email').input_value()
            assert email_val == contacts[name]['email'], \
                f"Expected email field to show '{contacts[name]['email']}' after selecting {name}, got '{email_val}'"

            phone_val = by_id(page, 'editshifter_phone_call').input_value()
            assert phone_val == contacts[name]['phone_call'], \
                f"Expected phone field to show '{contacts[name]['phone_call']}' after selecting {name}, got '{phone_val}'"
    check()

    close_editshifter(page)

# check that editing a user's email updates the ODB entry
def test_edituser_email(page, client, users_added):

    open_editshifter(page)
    time.sleep(0.2)

    select_edit_user(page, 'test0')

    by_id(page, 'editshifter_email').fill('updated@test.ca')

    submit_editshifter(page)
    time.sleep(0.1)

    @try_again
    def check():
        contacts = client.odb_get('Shifts/ContactInfo')
        assert contacts['test0']['email'] == 'updated@test.ca', \
            f"Expected test0's email to be 'updated@test.ca' after edit, got '{contacts['test0']['email']}'"
    check()

# check that editing a user's phone number updates the ODB entry
def test_edituser_phone(page, client, users_added):

    open_editshifter(page)
    time.sleep(0.2)

    select_edit_user(page, 'test0')

    by_id(page, 'editshifter_phone_call').fill('1 604-555-9999')

    submit_editshifter(page)
    time.sleep(0.1)

    @try_again
    def check():
        contacts = client.odb_get('Shifts/ContactInfo')
        assert contacts['test0']['phone_call'] == '1 604-555-9999', \
            f"Expected test0's phone to be '1 604-555-9999' after edit, got '{contacts['test0']['phone_call']}'"
    check()

# check that editing a user's affiliation updates the ODB entry
def test_edituser_affiliation(page, client, users_added):

    open_editshifter(page)
    time.sleep(0.2)

    select_edit_user(page, 'test0')

    by_id(page, 'editshifter_affiliation').fill('new university')

    submit_editshifter(page)
    time.sleep(0.1)

    @try_again
    def check():
        contacts = client.odb_get('Shifts/ContactInfo')
        assert contacts['test0']['affiliation'] == 'new university', \
            f"Expected test0's affiliation to be 'new university' after edit, got '{contacts['test0']['affiliation']}'"
    check()

# check that submitting an invalid email in the edit dialog is blocked
def test_edituser_invalid_email(page, client, users_added):

    open_editshifter(page)
    time.sleep(0.2)

    select_edit_user(page, 'test0')
    email_before = client.odb_get('Shifts/ContactInfo')['test0']['email']

    by_id(page, 'editshifter_email').fill('not-an-email')

    submit_editshifter(page)
    time.sleep(0.1)

    @try_again
    def check():

        # dialog should remain open
        assert by_id(page, 'dlgEditShifter').is_visible(), \
            "Expected the edit dialog to remain open after submitting an invalid email"

        assert not is_valid(page, 'editshifter_email'), \
            f"Expected the email field to be invalid for 'not-an-email', but validity.valid was True"
    check()

    close_editshifter(page)

    @try_again
    def check2():
        contacts_after = client.odb_get('Shifts/ContactInfo')
        assert contacts_after['test0']['email'] == email_before, \
            f"Expected test0's email to remain '{email_before}' after blocked invalid edit, " \
            f"got '{contacts_after['test0']['email']}'"
    check2()

# check that submitting an invalid phone in the edit dialog is blocked
def test_edituser_invalid_phone(page, client, users_added):

    open_editshifter(page)
    time.sleep(0.2)

    select_edit_user(page, 'test0')
    phone_before = client.odb_get('Shifts/ContactInfo')['test0']['phone_call']

    by_id(page, 'editshifter_phone_call').fill('not-a-number')

    submit_editshifter(page)
    time.sleep(0.1)

    @try_again
    def check():
        assert by_id(page, 'dlgEditShifter').is_visible(), \
            "Expected the edit dialog to remain open after submitting an invalid phone number"

        assert not is_valid(page, 'editshifter_phone_call'), \
            f"Expected the phone field to be invalid for 'not-a-number', but validity.valid was True"
    check()

    close_editshifter(page)

    @try_again
    def check2():
        contacts_after = client.odb_get('Shifts/ContactInfo')
        assert contacts_after['test0']['phone_call'] == phone_before, \
            f"Expected test0's phone to remain '{phone_before}' after blocked invalid edit, " \
            f"got '{contacts_after['test0']['phone_call']}'"
    check2()

# check that closing the edit dialog without submitting leaves the ODB unchanged
def test_edituser_cancel(page, client, users_added):

    contacts_before = client.odb_get('Shifts/ContactInfo')
    email_before = contacts_before['test0']['email']
    affiliation_before = contacts_before['test0']['affiliation']

    open_editshifter(page)
    time.sleep(0.2)

    select_edit_user(page, 'test0')

    # modify a field but close without clicking Update
    by_id(page, 'editshifter_email').fill('should_not_save@test.ca')

    close_editshifter(page)
    time.sleep(0.1)

    @try_again
    def check():
        contacts_after = client.odb_get('Shifts/ContactInfo')
        assert contacts_after['test0']['email'] == email_before, \
            f"Expected test0's email to be unchanged ('{email_before}') after closing without submitting, " \
            f"got '{contacts_after['test0']['email']}'"
        assert contacts_after['test0']['affiliation'] == affiliation_before, \
            f"Expected test0's affiliation to be unchanged ('{affiliation_before}') after closing without submitting, " \
            f"got '{contacts_after['test0']['affiliation']}'"
    check()

# check that editing one user does not affect any other user's ODB entry
def test_edituser_isolation(page, client, users_added):

    contacts_before = client.odb_get('Shifts/ContactInfo')

    open_editshifter(page)
    time.sleep(0.2)

    select_edit_user(page, 'test0')

    by_id(page, 'editshifter_email').fill('changed@test.ca')

    submit_editshifter(page)
    time.sleep(0.1)

    @try_again
    def check():
        contacts_after = client.odb_get('Shifts/ContactInfo')

        # test0 should be updated
        assert contacts_after['test0']['email'] == 'changed@test.ca', \
            f"Expected test0's email to be 'changed@test.ca' after edit, got '{contacts_after['test0']['email']}'"

        # all other users should be completely untouched
        for name in contacts_before.keys():
            if name == 'test0':
                continue
            for field in ('email', 'phone_call', 'affiliation'):
                assert contacts_after[name][field] == contacts_before[name][field], \
                    f"Expected {name}'s '{field}' to be unchanged after editing test0, " \
                    f"got '{contacts_after[name][field]}' (was '{contacts_before[name][field]}')"
    check()

# the ODB is not case sensitive, so a name differing only in case resolves to the existing
# key: the duplicate check has to match the same way, or the paste overwrites the original
def test_adduser_duplicate_different_case(page, client):

    open_addshifter(page)
    submit_addshifter(page, name='Test Guy', email='test@test.ca',
                      phone='1 123-123-1234', affiliation='university')
    wait_submitted(page)

    # same name, different case
    open_addshifter(page)
    submit_addshifter(page, name='test guy', email='other@test.ca',
                      phone='1 999-999-9999', affiliation='other place')
    time.sleep(0.2)

    @try_again
    def check():
        # the dialog stays open because add_shifter alerted and returned early
        assert by_id(page, 'dlgAddShifter').is_visible(), \
            "Expected the dialog to stay open after a duplicate name differing only in case"
        assert by_id(page, 'dlgMessageTitle').first.is_visible(), \
            "Expected a dlgAlert after submitting a name that differs only in case"
    check()

    by_id(page, 'dlgMessageButton').first.click()
    close_addshifter(page)

    @try_again
    def check2():
        contacts = client.odb_get('Shifts/ContactInfo')
        keys = list(contacts.keys())
        assert len(keys) == 1, \
            f"Expected exactly 1 contact, got {len(keys)}: {keys}"

        # the original details must be untouched
        assert contacts[keys[0]]['email'] == 'test@test.ca', \
            f"Expected the original email to survive, got '{contacts[keys[0]]['email']}'"
    check2()

# ==================== dialog lifecycle ====================
# these have no popup-era equivalent: a popup window was a fresh document on every open, so
# nothing could carry over between opens and nothing could accumulate

# check that reopening the add dialog shows empty fields rather than the last entry
def test_addshifter_dlg_fields_reset(page):

    open_addshifter(page)
    by_id(page, 'addshifter_name').fill('discarded')
    by_id(page, 'addshifter_email').fill('discarded@test.ca')
    close_addshifter(page)

    open_addshifter(page)

    @try_again
    def check():
        for field in ('name', 'email', 'phone_call', 'affiliation'):
            value = by_id(page, f'addshifter_{field}').input_value()
            assert value == '', \
                f"Expected '{field}' to be empty on reopening the add dialog, got '{value}'"
    check()

    close_addshifter(page)

# check that reopening the edit dialog does not duplicate the name dropdown options
def test_editshifter_dlg_names_not_duplicated(page, users_added):

    def option_values():
        """Values currently in the edit dialog's name dropdown, blank entry excluded"""
        select = by_id(page, 'editshifter_name_select')
        options = select.evaluate('el => [...el.options].map(o => o.value)')
        return sorted(value for value in options if value != '')

    open_editshifter(page)
    time.sleep(0.2)
    first = option_values()
    assert len(first) > 0, \
        f"Expected the name dropdown to be populated on the first open, got {first}"
    close_editshifter(page)

    open_editshifter(page)
    time.sleep(0.2)
    second = option_values()
    close_editshifter(page)

    assert second == first, \
        f"Expected the same name dropdown options on a second open, got {second} after {first}"

# check that pressing Enter in a field submits the add dialog, as clicking Submit does
def test_addshifter_dlg_enter_submits(page, client):

    open_addshifter(page)
    by_id(page, 'addshifter_name').fill('enter guy')
    by_id(page, 'addshifter_email').fill('enter@test.ca')
    by_id(page, 'addshifter_phone_call').fill('1 333-333-3333')
    by_id(page, 'addshifter_affiliation').press('Enter')

    wait_submitted(page)

    @try_again
    def check():
        contacts = client.odb_get('Shifts/ContactInfo')
        assert 'enter guy' in contacts, \
            f"Expected Enter to submit the add dialog, but ODB contacts are {list(contacts.keys())}"
    check()

# check that the ODB keys are the bare field names, not the prefixed element ids
def test_addshifter_dlg_odb_key_names(page, client):

    open_addshifter(page)
    submit_addshifter(page, name='key guy', email='key@test.ca',
                      phone='1 444-444-4444', affiliation='university')
    wait_submitted(page)

    @try_again
    def check():
        keys = sorted(client.odb_get('Shifts/ContactInfo')['key guy'].keys())
        assert keys == ['affiliation', 'email', 'phone_call'], \
            f"Expected the bare field names as ODB keys, got {keys}"
    check()

# check that the titlebar close icon dismisses the edit dialog without writing
def test_editshifter_dlg_titlebar_close(page, client, users_added):

    email_before = client.odb_get('Shifts/ContactInfo')['test0']['email']

    open_editshifter(page)
    select_edit_user(page, 'test0')
    by_id(page, 'editshifter_email').fill('should_not_save@test.ca')

    # the close icon dlgShow() injects into the titlebar. it destroys the dialog rather than
    # hiding it because open_shifter_dlg() sets shouldDestroy
    by_id(page, 'dlgEditShifter').locator('canvas').click()
    expect(by_id(page, 'dlgEditShifter')).to_have_count(0)

    @try_again
    def check():
        email_after = client.odb_get('Shifts/ContactInfo')['test0']['email']
        assert email_after == email_before, \
            f"Expected the titlebar close to discard the edit, but email became '{email_after}'"
    check()

# check that no input field extends past the edge of the dialog holding it
#
# the dialogs need an explicit width because .dlgFrame and .dlgPanel size themselves with
# min-width:max-content, and firefox does not count a flex item's min-width toward that
# intrinsic width. without it the frame came out ~25px narrower than its own fields, which is
# small enough to miss by eye and to creep back in unnoticed
@pytest.mark.parametrize('open_dlg, close_dlg, dlg_id',
                         [(open_addshifter, close_addshifter, 'dlgAddShifter'),
                          (open_editshifter, close_editshifter, 'dlgEditShifter')])
def test_shifter_dlg_fields_fit(page, users_added, open_dlg, close_dlg, dlg_id):

    open_dlg(page)

    # measure against the frame's own right edge rather than a fixed pixel width, so the check
    # survives a deliberate change to the dialog width
    geometry = by_id(page, dlg_id).evaluate("""frame => {
        const right = frame.getBoundingClientRect().right;
        const rows = [...frame.querySelectorAll('.shifter_row')];
        return {
            overhangs: rows.map(row => {
                const field = row.querySelector('input, select');
                return {id: field.id,
                        past_edge: field.getBoundingClientRect().right - right};
            }),
            row_overflow: Math.max(...rows.map(row => row.scrollWidth - row.clientWidth)),
            panel_overflow: (p => p.scrollWidth - p.clientWidth)(frame.querySelector('.dlgPanel')),
        };
    }""")

    for overhang in geometry['overhangs']:
        assert overhang['past_edge'] <= 0, \
            f"Expected '{overhang['id']}' to fit inside {dlg_id}, but it extends " \
            f"{overhang['past_edge']:.0f}px past the dialog's right edge"

    assert geometry['row_overflow'] == 0, \
        f"Expected no field row in {dlg_id} to overflow its own box, got " \
        f"{geometry['row_overflow']}px of overflow"
    assert geometry['panel_overflow'] == 0, \
        f"Expected the {dlg_id} panel not to overflow, got {geometry['panel_overflow']}px"

    close_dlg(page)
