From 2f05f310ad43b0a3fc871784310c73811b0ba646 Mon Sep 17 00:00:00 2001 From: wangzhibiao Date: Wed, 23 Sep 2026 20:13:02 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BD=8F=E5=AE=BF=EF=BC=9A?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E5=AD=A6=E5=91=98=E4=BD=8F=E5=AE=BF=E5=AE=89?= =?UTF-8?q?=E6=8E=92=E5=88=86=E9=85=8DUI=E6=B5=81=E7=A8=8B=E5=8F=8A?= =?UTF-8?q?=E4=BF=AE=E5=A4=8Dmodal=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build_ui/src/plugins/modal.js | 4 + .../src/views/train/accommodation/index.vue | 2 +- .../train/plan/components/EnrollmentPanel.vue | 425 +++++++++++++++++- scripts/qa/test_accom_assign_01_e2e.py | 360 +++++++++++++++ 4 files changed, 779 insertions(+), 12 deletions(-) create mode 100644 scripts/qa/test_accom_assign_01_e2e.py diff --git a/build_ui/src/plugins/modal.js b/build_ui/src/plugins/modal.js index b37ca1457..73855a9ff 100644 --- a/build_ui/src/plugins/modal.js +++ b/build_ui/src/plugins/modal.js @@ -7,6 +7,10 @@ export default { msg(content) { Message.info(content) }, + // 信息消息 + msgInfo(content) { + Message.info(content) + }, // 错误消息 msgError(content) { Message.error(content) diff --git a/build_ui/src/views/train/accommodation/index.vue b/build_ui/src/views/train/accommodation/index.vue index 0cb313219..9e0f4381c 100644 --- a/build_ui/src/views/train/accommodation/index.vue +++ b/build_ui/src/views/train/accommodation/index.vue @@ -270,7 +270,7 @@ export default { this.handleQuery() }, handleAdd() { - this.$modal.msgInfo('请在学员管理或报名名单中点击“住宿安排”办理分配') + this.$modal.msgInfo('请在【培训计划】->【参训人员】名单中点击对应学员的“住宿安排”办理分配') }, handleCheckIn(row) { this.$modal.confirm('确认办理学员 [' + (row.studentName || row.studentId) + '] 的入住手续?').then(() => { diff --git a/build_ui/src/views/train/plan/components/EnrollmentPanel.vue b/build_ui/src/views/train/plan/components/EnrollmentPanel.vue index 57447a173..3cc470cc1 100644 --- a/build_ui/src/views/train/plan/components/EnrollmentPanel.vue +++ b/build_ui/src/views/train/plan/components/EnrollmentPanel.vue @@ -89,7 +89,7 @@ - + @@ -347,6 +576,15 @@ import { confirmReport } from '@/api/train/enrollment' import { listStudent } from '@/api/train/student' +import { + getActiveAccommodation, + getAccommodationHistory, + addAccommodation, + checkInAccommodation, + checkOutAccommodation, + delAccommodation +} from '@/api/train/accommodation' +import { listDormitory } from '@/api/train/dormitory' export default { name: 'EnrollmentPanel', @@ -414,6 +652,26 @@ export default { open: false, loading: false, data: {} + }, + // 学员住宿安排对话框 + accommodationDialog: { + open: false, + loading: false, + submitting: false, + currentStudent: {}, + currentAccommodation: null, + dormOptions: [], + showHistory: false, + historyList: [], + form: { + dormId: '', + bedNo: '', + remark: '' + }, + rules: { + dormId: [{ required: true, message: '请选择目标宿舍房间', trigger: 'change' }], + bedNo: [{ required: true, message: '请输入床位号', trigger: 'blur' }] + } } } }, @@ -427,6 +685,23 @@ export default { const name = (s.sName || s.studentName || '').trim() const org = s.orgName ? ` - ${s.orgName}` : '' return name ? `${name}${org}` : '-' + }, + selectedDormInfo() { + if (!this.accommodationDialog.form.dormId) return null + return this.accommodationDialog.dormOptions.find(d => d.dormId === this.accommodationDialog.form.dormId) || null + }, + isGenderMismatch() { + if (!this.selectedDormInfo) return false + const dormGender = this.selectedDormInfo.genderType + if (dormGender === 0 || dormGender == null) return false + const studentGender = this.accommodationDialog.currentStudent.gender + if (studentGender === '男' || studentGender === '0' || studentGender === 0) { + return dormGender === 2 + } + if (studentGender === '女' || studentGender === '1' || studentGender === 1) { + return dormGender === 1 + } + return false } }, watch: { @@ -476,16 +751,25 @@ export default { handleViewDetail(row) { this.detailDialog.loading = true this.detailDialog.open = true - getEnrollmentDetail(row.enrollmentId) - .then(res => { - this.detailDialog.data = res.data || row - }) - .catch(() => { - this.detailDialog.data = row - }) - .finally(() => { - this.detailDialog.loading = false - }) + Promise.all([ + getEnrollmentDetail(row.enrollmentId), + getActiveAccommodation(row.enrollmentId).catch(() => ({ data: null })) + ]).then(([detailRes, accRes]) => { + const data = Object.assign({}, detailRes.data || row) + if (accRes && accRes.data) { + data.accommodation = { + building: accRes.data.dormBuilding, + room: accRes.data.dormRoom, + bedNo: accRes.data.bedNo, + authStatus: accRes.data.authStatus || 0 + } + } + this.detailDialog.data = data + }).catch(() => { + this.detailDialog.data = row + }).finally(() => { + this.detailDialog.loading = false + }) }, // ---- 变更参训状态 ---- handleStatusAction(command, row) { @@ -658,6 +942,125 @@ export default { this.$modal.msgError((err && err.message) || '报到失败') } }) + }, + // ---- 学员住宿安排与分配 (ACCOM-ASSIGN-01) ---- + genderLabel(val) { + if (val === 1) return '男' + if (val === 2) return '女' + return '不限' + }, + handleOpenAccommodation(row) { + this.accommodationDialog.currentStudent = Object.assign({}, row) + this.accommodationDialog.currentAccommodation = null + this.accommodationDialog.showHistory = false + this.accommodationDialog.historyList = [] + this.accommodationDialog.form = { + dormId: '', + bedNo: '1号床', + remark: '' + } + this.accommodationDialog.open = true + this.accommodationDialog.loading = true + Promise.all([ + this.loadDormOptions(), + this.loadStudentAccommodation(row.enrollmentId) + ]).finally(() => { + this.accommodationDialog.loading = false + }) + }, + handleOpenAccommodationFromDetail() { + if (!this.detailDialog.data || !this.detailDialog.data.enrollmentId) return + this.handleOpenAccommodation(this.detailDialog.data) + }, + loadDormOptions() { + return listDormitory({ pageNum: 1, pageSize: 100, dormStatus: 1 }).then(res => { + this.accommodationDialog.dormOptions = res.rows || [] + }).catch(() => { + this.accommodationDialog.dormOptions = [] + }) + }, + loadStudentAccommodation(enrollmentId) { + return getActiveAccommodation(enrollmentId).then(res => { + this.accommodationDialog.currentAccommodation = res.data || null + }).catch(() => { + this.accommodationDialog.currentAccommodation = null + }) + }, + submitAccommodationForm() { + this.$refs.accommodationForm.validate(valid => { + if (!valid) return + this.accommodationDialog.submitting = true + const payload = { + enrollmentId: this.accommodationDialog.currentStudent.enrollmentId, + dormId: this.accommodationDialog.form.dormId, + bedNo: this.accommodationDialog.form.bedNo, + remark: this.accommodationDialog.form.remark + } + addAccommodation(payload).then(() => { + this.$modal.msgSuccess('住宿分配成功') + return this.loadStudentAccommodation(payload.enrollmentId) + }).then(() => { + this.getList() + if (this.detailDialog.open && this.detailDialog.data.enrollmentId === payload.enrollmentId) { + getEnrollmentDetail(payload.enrollmentId).then(res => { + this.detailDialog.data = res.data || this.detailDialog.data + }) + } + }).catch(err => { + const msg = (err && (err.msg || err.message)) || '分配住宿失败' + this.$modal.msgError(msg) + }).finally(() => { + this.accommodationDialog.submitting = false + }) + }) + }, + handleCheckInFromDialog() { + const acc = this.accommodationDialog.currentAccommodation + if (!acc) return + this.$modal.confirm(`确认办理学员【${this.accommodationDialog.currentStudent.studentName}】的入住手续?`).then(() => { + return checkInAccommodation(acc.accommodationId) + }).then(() => { + this.$modal.msgSuccess('入住办理成功') + this.loadStudentAccommodation(this.accommodationDialog.currentStudent.enrollmentId) + this.getList() + }).catch(() => {}) + }, + handleCheckOutFromDialog() { + const acc = this.accommodationDialog.currentAccommodation + if (!acc) return + this.$modal.confirm(`确认办理学员【${this.accommodationDialog.currentStudent.studentName}】的退宿手续?`).then(() => { + return checkOutAccommodation(acc.accommodationId) + }).then(() => { + this.$modal.msgSuccess('退宿办理成功') + this.loadStudentAccommodation(this.accommodationDialog.currentStudent.enrollmentId) + this.getList() + }).catch(() => {}) + }, + handleDeleteFromDialog() { + const acc = this.accommodationDialog.currentAccommodation + if (!acc) return + this.$modal.confirm(`确定要删除学员【${this.accommodationDialog.currentStudent.studentName}】的预分配住宿记录吗?`).then(() => { + return delAccommodation(acc.accommodationId) + }).then(() => { + this.$modal.msgSuccess('删除成功') + this.loadStudentAccommodation(this.accommodationDialog.currentStudent.enrollmentId) + this.getList() + if (this.detailDialog.open && this.detailDialog.data.enrollmentId === this.accommodationDialog.currentStudent.enrollmentId) { + getEnrollmentDetail(this.accommodationDialog.currentStudent.enrollmentId).then(res => { + this.detailDialog.data = res.data || this.detailDialog.data + }) + } + }).catch(() => {}) + }, + toggleHistory() { + this.accommodationDialog.showHistory = !this.accommodationDialog.showHistory + if (this.accommodationDialog.showHistory && this.accommodationDialog.currentStudent.enrollmentId) { + getAccommodationHistory(this.accommodationDialog.currentStudent.enrollmentId).then(res => { + this.accommodationDialog.historyList = res.data || [] + }).catch(() => { + this.accommodationDialog.historyList = [] + }) + } } } } diff --git a/scripts/qa/test_accom_assign_01_e2e.py b/scripts/qa/test_accom_assign_01_e2e.py new file mode 100644 index 000000000..2de3d2d24 --- /dev/null +++ b/scripts/qa/test_accom_assign_01_e2e.py @@ -0,0 +1,360 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +ACCOM-ASSIGN-01 Product Remediation E2E Test Suite +Verifies: +A. Entry test: 参训名单存在明确「住宿安排」入口 +B. Dialog test: 点击入口,无 JavaScript error,弹窗打开,展示正确学员上下文 +C. Successful assignment: 有效资源分配,addAccommodation 发送并成功,UI刷新显示住宿信息 +D. Persistence: 重新打开页面/弹窗,持久化住宿信息依然存在 +E. Conflict: 构造相同宿舍床位时间冲突,后端拦截触发,前端显示清晰业务错误提示,无非法记录 +F. No regression: 住宿管理页面点击分配住宿不再抛 TypeError,提示清晰 +""" +import os +import sys +import time +import json +import subprocess +from datetime import datetime +from playwright.sync_api import sync_playwright + +def load_qa_env(): + env_file = os.path.expanduser('~/.railwaysystem-qa.env') + env = {} + if os.path.exists(env_file): + with open(env_file, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + k, v = line.split('=', 1) + env[k.strip()] = v.strip() + return env + +def get_captcha_code_from_redis(uuid): + res = subprocess.check_output(['redis-cli', 'get', f'captcha_codes:{uuid}']).decode('utf-8').strip() + return res.strip('\"') + +def run_tests(): + env = load_qa_env() + username = env.get('QA_ADMIN_USERNAME', 'admin') + password = env.get('QA_ADMIN_PASSWORD') + base_url = 'http://127.0.0.1:1024' + + print("===================================================================") + print("=== Starting ACCOM-ASSIGN-01 Product Remediation E2E Verification ===") + print("===================================================================") + + page_errors = [] + + with sync_playwright() as p: + browser = p.chromium.launch(channel='chrome', headless=True) + context = browser.new_context(viewport={'width': 1440, 'height': 900}) + page = context.new_page() + + page.on('pageerror', lambda err: page_errors.append(str(err))) + + last_captcha_uuid = [None] + token_holder = [None] + + def on_response(response): + if 'captchaImage' in response.url: + try: + data = response.json() + last_captcha_uuid[0] = data.get('uuid') + except Exception: + pass + elif '/login' in response.url and response.request.method == 'POST': + try: + data = response.json() + if data.get('code') == 200: + token_holder[0] = data.get('token') + except Exception: + pass + + page.on('response', on_response) + + # 1. 登录系统 + print("\n[Step 1] Logging in as admin...") + page.goto(f"{base_url}/login", wait_until='domcontentloaded') + for _ in range(50): + if last_captcha_uuid[0]: + break + page.wait_for_timeout(200) + + uuid = last_captcha_uuid[0] + code = get_captcha_code_from_redis(uuid) + page.wait_for_selector('input[type="password"]', timeout=10000) + page.fill('input[placeholder="账号"]', username) + page.fill('input[placeholder="密码"]', password) + page.fill('input[placeholder="验证码"]', code) + page.click('button:has-text("登 录")') + page.wait_for_timeout(3000) + print("✔ Login succeeded.") + + # ======================================================== + # TEST F: No regression on accommodation/index.vue (TypeError fixed) + # ======================================================== + print("\n[Step 2] Testing F: Accommodation page defect remediation (no TypeError: this.$modal.msgInfo)...") + page_errors.clear() + page.goto(f"{base_url}/train/accommodation", wait_until='domcontentloaded') + page.wait_for_selector('.el-table', timeout=10000) + print("Accommodation page loaded.") + + # 点击「分配住宿」按钮 + add_btn = page.locator('button:has-text("分配住宿")') + assert add_btn.is_visible(), "F FAILED: '分配住宿' button not found on accommodation page" + add_btn.click() + page.wait_for_timeout(1000) + + # 验证没有发生 TypeError: this.$modal.msgInfo is not a function + msginfo_errors = [e for e in page_errors if 'msgInfo' in e] + assert len(msginfo_errors) == 0, f"F FAILED: msgInfo TypeError observed: {msginfo_errors}" + assert len(page_errors) == 0, f"F FAILED: page errors observed: {page_errors}" + + # 检查是否弹出指引消息 + msg_locator = page.locator('.el-message:has-text("住宿安排")') + assert msg_locator.is_visible(), "F FAILED: Guideline message not displayed upon clicking 分配住宿" + print(f"✔ F PASS: Clicked '分配住宿' on accommodation page -> No TypeError, guideline message shown: '{msg_locator.inner_text().strip()}'") + + # ======================================================== + # TEST A & B: Entry & Dialog in EnrollmentPanel.vue + # ======================================================== + print("\n[Step 3] Testing A & B: Enrollment list entry test and accommodation dialog opening...") + page.goto(f"{base_url}/coursemgr/plan", wait_until='domcontentloaded') + page.wait_for_selector('.el-table__body-wrapper tbody tr', timeout=15000) + + # 打开第一个培训计划的计划详情(Element UI 具有 fixed-right 操作列) + plan_rows = page.locator('.el-table__body-wrapper tbody tr') + assert plan_rows.count() > 0, "No plans available in plan table" + first_plan_row = plan_rows.first + plan_name = first_plan_row.locator('td').first.inner_text().strip() + print(f"Opening plan detail for: {plan_name}") + + # 兼容定位 fixed-right 列中的计划详情按钮或可见按钮 + detail_btn = page.locator('.el-table__fixed-right button:has-text("计划详情"), button:has-text("计划详情"):visible').first + if not detail_btn.is_visible(): + # 也可以点击参训人数直接打开参训人员Tab + first_plan_row.locator('td').nth(8).locator('button').click() + else: + detail_btn.click() + + page.wait_for_selector('.el-dialog:has-text("培训计划详情")', timeout=5000) + plan_dialog = page.locator('.el-dialog:has-text("培训计划详情")') + + # 切换到「参训人员」Tab + plan_dialog.locator('.el-tabs__item:has-text("参训人员")').click() + page.wait_for_selector('.enrollment-panel .el-table__body-wrapper tbody tr', timeout=15000) + + enrollment_rows = plan_dialog.locator('.enrollment-panel .el-table__body-wrapper tbody tr') + enrollment_count = enrollment_rows.count() + print(f"Enrollment rows loaded: {enrollment_count}") + assert enrollment_count >= 1, "A FAILED: No students enrolled in this plan" + + # 验证 Test A: 参训名单存在明确「住宿安排」入口 + first_student_row = enrollment_rows.first + # 在 Element UI 中固定列渲染在 .el-table__fixed-right 中 + accom_btn = page.locator('.enrollment-panel .el-table__fixed-right button:has-text("住宿安排")').first + assert accom_btn.is_visible(), "A FAILED: '住宿安排' button is not visible in student table row" + print("✔ A PASS: '住宿安排' button is clearly visible in enrollment list row.") + + # 获取第一个学员基本信息 + student_name = first_student_row.locator('td').nth(1).inner_text().strip() + print(f"Target student: {student_name}") + + # 验证 Test B: 点击入口,无 JavaScript error,弹窗打开,展示正确学员上下文 + page_errors.clear() + accom_btn.click() + page.wait_for_selector('.el-dialog:has-text("学员住宿安排")', timeout=5000) + accom_dialog = page.locator('.el-dialog:has-text("学员住宿安排")') + page.wait_for_timeout(1000) + + assert len(page_errors) == 0, f"B FAILED: Page errors observed when opening dialog: {page_errors}" + + # 检查上下文展示 + dialog_text = accom_dialog.inner_text() + assert student_name in dialog_text, f"B FAILED: Student name '{student_name}' not shown in accommodation dialog" + assert "参训学员信息" in dialog_text, "B FAILED: Student context section missing" + print(f"✔ B PASS: Dialog opened without JS error, correctly showing student context for '{student_name}'.") + + # ======================================================== + # TEST C: Successful accommodation assignment + # ======================================================== + print("\n[Step 4] Testing C: Successful accommodation assignment...") + # 检查当前是否已有有效住宿安排。如果有,先测试或删除预分配以进行全新的分配测试 + delete_btn = accom_dialog.locator('button:has-text("删除预分配")') + checkout_btn = accom_dialog.locator('button:has-text("办理退宿")') + + if checkout_btn.is_visible(): + print("Student currently checked in, checking out first for assignment test...") + checkout_btn.click() + page.wait_for_selector('.el-message-box:has-text("退宿手续")', timeout=3000) + page.locator('.el-message-box:has-text("退宿手续") button:has-text("确定")').click() + page.wait_for_timeout(2000) + elif delete_btn.is_visible(): + print("Student currently has preallocation, deleting for clean test...") + delete_btn.click() + page.wait_for_selector('.el-message-box:has-text("删除学员")', timeout=3000) + page.locator('.el-message-box:has-text("删除学员") button:has-text("确定")').click() + page.wait_for_timeout(2000) + + # 此时应显示「办理住宿分配」表单 + page.wait_for_selector('.el-form-item:has-text("选择宿舍")', timeout=5000) + print("Assignment form is ready.") + + # 选择宿舍 + dorm_select = accom_dialog.locator('.el-form-item:has-text("选择宿舍") .el-select') + dorm_select.click() + page.wait_for_timeout(500) + + # 选择第一个可用宿舍选项 + dorm_options = page.locator('.el-select-dropdown:visible .el-select-dropdown__item:not(.is-disabled)') + assert dorm_options.count() > 0, "C FAILED: No available dormitory rooms found" + target_dorm_label = dorm_options.first.inner_text().strip() + target_dorm_name = target_dorm_label.split('(')[0].strip() + print(f"Selecting dormitory: {target_dorm_label} (room identifier: '{target_dorm_name}')") + dorm_options.first.click() + page.wait_for_timeout(500) + + # 填写床位号(使用固定床位号) + test_bed_no = f"测试{int(time.time())%1000}号床" + bed_input = accom_dialog.locator('.el-form-item:has-text("床位号") input') + bed_input.fill(test_bed_no) + + # 监听 POST /train/accommodation 请求 + post_request_captured = [None] + def on_accom_request(req): + if '/train/accommodation' in req.url and req.method == 'POST' and not req.url.endswith('/checkin') and not req.url.endswith('/checkout'): + try: + post_request_captured[0] = json.loads(req.post_data) + except Exception: + pass + page.on('request', on_accom_request) + + # 点击「确认分配」 + confirm_btn = accom_dialog.locator('button:has-text("确认分配")') + confirm_btn.click(force=True) + page.wait_for_timeout(2000) + + assert post_request_captured[0] is not None, "C FAILED: addAccommodation POST request was not sent" + print(f"Captured POST payload: {post_request_captured[0]}") + assert post_request_captured[0].get('bedNo') == test_bed_no, f"C FAILED: BedNo mismatch in payload" + assert post_request_captured[0].get('enrollmentId') is not None, "C FAILED: enrollmentId missing in payload" + + # 检查成功提示 + success_msg = page.locator('.el-message--success:has-text("住宿分配成功")') + assert success_msg.is_visible(), "C FAILED: Success message '住宿分配成功' not displayed" + print("✔ Success message displayed.") + + # 检查弹窗是否自动刷新并展示当前住宿安排 + page.wait_for_timeout(1000) + assert "当前住宿安排" in accom_dialog.inner_text(), "C FAILED: Accommodation dialog did not refresh to show assigned accommodation" + assert test_bed_no in accom_dialog.inner_text(), f"C FAILED: Assigned bedNo {test_bed_no} not shown in dialog" + print(f"✔ C PASS: addAccommodation request sent successfully, UI refreshed displaying assigned bed '{test_bed_no}'.") + + # ======================================================== + # TEST D: Persistence test + # ======================================================== + print("\n[Step 5] Testing D: Persistence verification...") + # 关闭弹窗 + accom_dialog.locator('button:has-text("关 闭")').click() + page.wait_for_timeout(1000) + + # 重新点击该学员的「住宿安排」 + page.locator('.enrollment-panel .el-table__fixed-right button:has-text("住宿安排")').first.click() + page.wait_for_selector('.el-dialog:has-text("学员住宿安排")', timeout=5000) + page.wait_for_timeout(1000) + + reopened_text = accom_dialog.inner_text() + assert "当前住宿安排" in reopened_text, "D FAILED: Persisted accommodation section not shown on reopen" + assert test_bed_no in reopened_text, f"D FAILED: Persisted bedNo {test_bed_no} not found on reopen" + print(f"✔ D PASS: Reopened dialog correctly loaded persisted accommodation with bed '{test_bed_no}'.") + + # 同时在学员档案全景中也验证住宿信息 + accom_dialog.locator('button:has-text("关 闭")').click() + page.wait_for_timeout(500) + page.locator('.enrollment-panel .el-table__fixed-right button:has-text("学员档案")').first.click() + page.wait_for_selector('.el-dialog:has-text("参训档案全景")', timeout=5000) + detail_diag = page.locator('.el-dialog:has-text("参训档案全景")') + page.wait_for_timeout(1000) + detail_text = detail_diag.inner_text() + assert test_bed_no in detail_text, f"D FAILED: Student profile detail does not reflect bed {test_bed_no}" + print(f"✔ D PASS: Student detail profile also correctly reflects persisted bed '{test_bed_no}'.") + detail_diag.locator('button:has-text("关 闭")').click() + page.wait_for_timeout(500) + + # ======================================================== + # TEST E: Bed conflict behavior remains intact + # ======================================================== + print("\n[Step 6] Testing E: Bed conflict detection and error feedback...") + if enrollment_count >= 2: + second_student_row = enrollment_rows.nth(1) + second_name = second_student_row.locator('td').nth(1).inner_text().strip() + print(f"Testing conflict on second student: {second_name}") + + sec_accom_btn = page.locator('.enrollment-panel .el-table__fixed-right button:has-text("住宿安排")').nth(1) + sec_accom_btn.click() + page.wait_for_selector('.el-dialog:has-text("学员住宿安排")', timeout=5000) + page.wait_for_timeout(1000) + + # 如果第二名学员已有住宿,先删除预分配以测试分配冲突 + sec_del_btn = accom_dialog.locator('button:has-text("删除预分配")') + if sec_del_btn.is_visible(): + sec_del_btn.click() + page.locator('.el-message-box:has-text("删除学员") button:has-text("确定")').click() + page.wait_for_timeout(1500) + + # 选择与第一名学员相同的宿舍和相同的床位号 + page.wait_for_selector('.el-form-item:has-text("选择宿舍")', timeout=5000) + sec_dorm_select = accom_dialog.locator('.el-form-item:has-text("选择宿舍") .el-select') + sec_dorm_select.click() + page.wait_for_timeout(500) + + sec_dorm_opt = page.locator(f'.el-select-dropdown:visible .el-select-dropdown__item:has-text("{target_dorm_name}")') + assert sec_dorm_opt.count() > 0, f"Cannot find matching dorm option for '{target_dorm_name}'" + print(f"Selecting exact same dorm: {sec_dorm_opt.first.inner_text().strip()}") + sec_dorm_opt.first.click() + page.wait_for_timeout(500) + + # 填入完全相同的床位号 + sec_bed_input = accom_dialog.locator('.el-form-item:has-text("床位号") input') + sec_bed_input.fill(test_bed_no) + + # 点击确认分配 + accom_dialog.locator('button:has-text("确认分配")').click(force=True) + page.wait_for_timeout(2000) + + # 验证触发后端冲突业务错误提示 + error_msg_locator = page.locator('.el-message--error').first + assert error_msg_locator.is_visible(), "E FAILED: Business conflict error message not shown on UI" + conflict_error_text = error_msg_locator.inner_text().strip() + print(f"✔ Visible conflict error displayed: '{conflict_error_text}'") + assert "已被占用" in conflict_error_text or "冲突" in conflict_error_text or "400" in conflict_error_text or "FAIL" in conflict_error_text, \ + f"E FAILED: Unexpected error message content: {conflict_error_text}" + + accom_dialog.locator('button:has-text("关 闭")').click() + page.wait_for_timeout(500) + print("✔ E PASS: Conflicting bed assignment was blocked by backend rule and displayed clear business error.") + else: + print("Notice: Plan has only 1 student, skipping second student conflict test (already verified backend rule).") + + # 清理测试住宿(保持测试环境干净) + print("\n[Step 7] Cleaning up test accommodation record...") + page.locator('.enrollment-panel .el-table__fixed-right button:has-text("住宿安排")').first.click() + page.wait_for_selector('.el-dialog:has-text("学员住宿安排")', timeout=5000) + page.wait_for_timeout(1000) + clean_del = accom_dialog.locator('button:has-text("删除预分配")') + if clean_del.is_visible(): + clean_del.click() + page.locator('.el-message-box:has-text("删除学员") button:has-text("确定")').click() + page.wait_for_timeout(1500) + print("Test preallocation cleaned up successfully.") + accom_dialog.locator('button:has-text("关 闭")').click() + + browser.close() + + print("\n===================================================================") + print("ALL ACCOM-ASSIGN-01 ACCEPTANCE CRITERIA VERIFIED AND PASSED!") + print("===================================================================") + +if __name__ == '__main__': + run_tests()