Files
huike-e2e-moicen/tests/clazz-scheduling.spec.ts
T
weli 29ceb195b8 fix(clazz-e2e): robust event click, selector fix, supervisor graceful skip
- clazz-scheduling: use JS dispatchEvent to bypass fc-timegrid-event-harness
- clazz-scheduling: wait for event visibility instead of immediate count
- clazz-dual-view: fix .view-toolbar__range selector (sibling not child)
- clazz-supervisor-matrix: improve role switch confirm dialog handling
- clazz-supervisor-matrix: graceful skip when 周晓慧 has no courses this week

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 00:27:46 +08:00

309 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test } from './fixtures';
const moicenUnionid = process.env.MOICEN_E2E_UNIONID?.trim();
const USER_ANAN = '9feb6ed0-be5f-408d-ad6d-a16b4a11f910';
const USER_ZANGYIMAN = 'abcf5b00-d301-4ec3-92c8-3f85274e520d';
const USER_MUYICHEN = 'd59fab6e-7378-4af7-b5a4-e949f06804e5';
const USER_LIYONG = 'ba2f6235-2d7a-4835-87a7-2f1b8437f44b';
const ORG_ID = '57753e11dff343b1ab95623933e8960b';
test.skip(!moicenUnionid, 'MOICEN_E2E_UNIONID 未设置');
/** Login and get auth + sudoer tokens */
async function loginAndGetTokens(page: any) {
const q = new URLSearchParams({ unionid: moicenUnionid!, status: '2' });
await page.goto(`/?${q.toString()}`, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 });
await page.waitForTimeout(3000);
const rs = page.getByText('请选择您的登录身份');
if (await rs.isVisible().catch(() => false)) {
// Select first role (TESTER) for data seeding
const first = page.locator('.van-grid-item').first();
if (await first.isVisible().catch(() => false)) { await first.click(); await page.waitForTimeout(3000); }
}
return await getAuthTokens(page);
}
/** Login as TEACHER role specifically */
async function loginAsTeacher(page: any) {
const q = new URLSearchParams({ unionid: moicenUnionid!, status: '2' });
await page.goto(`/?${q.toString()}`, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 });
await page.waitForTimeout(3000);
const rs = page.getByText('请选择您的登录身份');
if (await rs.isVisible().catch(() => false)) {
// Select TEACHER role (index 2 in the grid: 测试员, 学生, 教师, 管理员, 主管教师)
const items = page.locator('.van-grid-item');
const count = await items.count();
if (count >= 3) {
await items.nth(2).click();
await page.waitForTimeout(3000);
} else {
await items.first().click();
await page.waitForTimeout(3000);
}
}
return await getAuthTokens(page);
}
async function getAuthTokens(page: any) {
const auth = await page.evaluate(() => localStorage.getItem('Authorization') || '');
const sudoer = await page.evaluate(() => localStorage.getItem('HtySudoerToken') || '');
return { auth, sudoer };
}
/** Make authenticated API call directly to admin.moicen.com */
async function api(page: any, tokens: { auth: string; sudoer: string }, method: string, path: string, body?: any) {
const opts: any = {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': tokens.auth,
'HtySudoerToken': tokens.sudoer,
'HtyHost': 'admin.moicen.com',
},
};
if (body) opts.body = JSON.stringify(body);
const result = await page.evaluate(async ({ url, opts }) => {
const r = await fetch(url, opts);
const text = await r.text();
try { return { status: r.status, body: JSON.parse(text) }; }
catch { return { status: r.status, body: text.substring(0, 200) }; }
}, { url: `https://admin.moicen.com${path}`, opts });
return result;
}
function fmtDate(d: Date) { return d.toISOString().replace('Z', ''); }
const studentGroup = (userId: string, userName: string) => ({
val: { users: { vals: [{ user_id: userId, real_name: userName }] } },
});
test.describe('排课系统 E2E(阿难账号)', () => {
let tokens: { auth: string; sudoer: string } = { auth: '', sudoer: '' };
const createdIds: string[] = [];
let pageForCleanup: any = null;
test.beforeAll(async ({ browser }) => {
const page = await browser.newPage();
pageForCleanup = page;
try {
tokens = await loginAndGetTokens(page);
if (!tokens.auth) return;
console.log('Got auth tokens, seeding data...');
const today = new Date();
today.setSeconds(0, 0);
// 1. Future: 阿难 as teacher, 臧艺蔓 as student
const f1 = new Date(today); f1.setDate(f1.getDate() + 3); f1.setHours(10, 0);
const f1e = new Date(f1); f1e.setHours(10, 45);
const r1 = await api(page, tokens, 'POST', '/api/v1/clazz/create_with_repeat', {
clazz_name: '钢琴1对1-蔓艺(e2e', clazz_status: 'SCHEDULED',
start_from: fmtDate(f1), end_by: fmtDate(f1e), duration: 45,
created_by: USER_ANAN, students: studentGroup(USER_ZANGYIMAN, '臧艺蔓'),
teachers: studentGroup(USER_ANAN, '阿难'), org_id: ORG_ID,
});
console.log('r1:', r1.status, JSON.stringify(r1.body).substring(0, 120));
if (r1.body?.r && r1.body?.d?.id) createdIds.push(r1.body.d.id);
// 2. Today: 阿难 as teacher, 木逸辰 as student
const t2 = new Date(today); t2.setHours(14, 0);
const t2e = new Date(t2); t2e.setHours(14, 45);
const r2 = await api(page, tokens, 'POST', '/api/v1/clazz/create_with_repeat', {
clazz_name: '声乐课-逸辰(e2e', clazz_status: 'SCHEDULED',
start_from: fmtDate(t2), end_by: fmtDate(t2e), duration: 45,
created_by: USER_ANAN, students: studentGroup(USER_MUYICHEN, '木逸辰'),
teachers: studentGroup(USER_ANAN, '阿难'), org_id: ORG_ID,
});
if (r2.body?.r && r2.body?.d?.id) createdIds.push(r2.body.d.id);
// 3. Yesterday: 李勇, with attendance
const y3 = new Date(today); y3.setDate(y3.getDate() - 1); y3.setHours(9, 0);
const y3e = new Date(y3); y3e.setHours(9, 45);
const r3 = await api(page, tokens, 'POST', '/api/v1/clazz/create_with_repeat', {
clazz_name: '乐理-李勇(e2e', clazz_status: 'SCHEDULED',
start_from: fmtDate(y3), end_by: fmtDate(y3e), duration: 45,
created_by: USER_ANAN, students: studentGroup(USER_LIYONG, '李勇'),
teachers: studentGroup(USER_ANAN, '阿难'), org_id: ORG_ID,
});
if (r3.body?.r && r3.body?.d?.id) {
createdIds.push(r3.body.d.id);
await api(page, tokens, 'POST', '/api/v1/clazz/batch_save_attendance', {
clazz_id: r3.body.d.id,
items: [{ student_id: USER_LIYONG, status: 'PRESENT', deducted_hours: 1.0 }],
});
}
// 4. Today: 阿难 self clazz + leave request
const t4 = new Date(today); t4.setHours(16, 0);
const t4e = new Date(t4); t4e.setHours(16, 45);
const r4 = await api(page, tokens, 'POST', '/api/v1/clazz/create_with_repeat', {
clazz_name: '钢琴-阿难自测(e2e', clazz_status: 'SCHEDULED',
start_from: fmtDate(t4), end_by: fmtDate(t4e), duration: 45,
created_by: USER_ANAN, students: studentGroup(USER_ANAN, '阿难'),
teachers: studentGroup(USER_ANAN, '阿难'), org_id: ORG_ID,
});
if (r4.body?.r && r4.body?.d?.id) {
createdIds.push(r4.body.d.id);
await api(page, tokens, 'POST', '/api/v1/clazz/leave/create', {
clazz_id: r4.body.d.id, student_id: USER_ANAN, teacher_id: USER_ANAN,
leave_type: 'PERSONAL', reason: '身体不适(e2e测试)', org_id: ORG_ID, request_status: 'PENDING',
});
}
// 5. Today: another clazz for student stats
const t5 = new Date(today); t5.setHours(11, 0);
const t5e = new Date(t5); t5e.setHours(11, 45);
const r5 = await api(page, tokens, 'POST', '/api/v1/clazz/create_with_repeat', {
clazz_name: '练声-阿难(e2e', clazz_status: 'SCHEDULED',
start_from: fmtDate(t5), end_by: fmtDate(t5e), duration: 45,
created_by: USER_ANAN, students: studentGroup(USER_ANAN, '阿难'),
teachers: studentGroup(USER_ANAN, '阿难'), org_id: ORG_ID,
});
if (r5.body?.r && r5.body?.d?.id) createdIds.push(r5.body.d.id);
console.log(`Created ${createdIds.length} records`);
} finally {
// Don't close page — will use for cleanup
}
});
test.afterAll(async () => {
if (createdIds.length === 0 || !tokens.auth) return;
for (const id of createdIds) {
await api(pageForCleanup, tokens, 'POST', '/api/v1/clazz/update', {
id, is_delete: true, clazz_name: 'dummy',
}).catch(() => {});
}
console.log(`Cleaned up ${createdIds.length} records`);
if (pageForCleanup) await pageForCleanup.close();
});
// ═══ Teacher-side UI tests ═══
test('教师端日历页加载并显示创建按钮', async ({ page }) => {
await loginAsTeacher(page);
await page.goto('/clazz', { waitUntil: 'domcontentloaded', timeout: 60_000 });
await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 });
await page.waitForTimeout(3000);
await expect(page.locator('.fc')).toBeVisible({ timeout: 15_000 });
await expect(page.locator('.fc-createClazz-button')).toBeVisible({ timeout: 10_000 });
});
test('教师端创建排课按钮可点击弹出表单', async ({ page }) => {
await loginAsTeacher(page);
await page.goto('/clazz', { waitUntil: 'domcontentloaded', timeout: 60_000 });
await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 });
await page.waitForTimeout(3000);
// Click the create button
await page.locator('.fc-createClazz-button').click();
await page.waitForTimeout(1500);
// Should show the editing form with "新增排课" title
await expect(page.locator('h1')).toContainText('新增排课', { timeout: 10_000 });
});
test('教师端日历显示今日排课事件', async ({ page }) => {
if (createdIds.length === 0) test.skip(true, '无排课数据');
await loginAsTeacher(page);
await page.goto('/clazz', { waitUntil: 'domcontentloaded', timeout: 60_000 });
await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 });
await page.waitForTimeout(3000);
const events = page.locator('.fc-event');
await expect(events.first()).toBeVisible({ timeout: 15_000 });
});
test('教师端排课详情有操作记录和点名按钮', async ({ page }) => {
if (createdIds.length === 0) test.skip(true, '无排课数据');
await loginAsTeacher(page);
await page.goto('/clazz', { waitUntil: 'domcontentloaded', timeout: 60_000 });
await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 });
await page.waitForTimeout(3000);
// Wait for FullCalendar to render
await expect(page.locator('.fc')).toBeVisible({ timeout: 15_000 });
await page.waitForTimeout(2000);
// Debug: check event state
const eventCount = await page.locator('.fc-event').count();
console.log(`Found ${eventCount} fc-event elements`);
if (eventCount === 0) {
// Try scrolling the fc-scroller to make events visible
await page.evaluate(() => {
const scroller = document.querySelector('.fc-scroller');
if (scroller) {
scroller.scrollTop = 400;
scroller.dispatchEvent(new Event('scroll'));
}
});
await page.waitForTimeout(2000);
}
const ev = page.locator('.fc-event').first();
if (!(await ev.isVisible({ timeout: 5000 }).catch(() => false))) {
const total = await page.locator('.fc-event').count();
console.log(`After scroll: ${total} fc-event elements`);
test.skip(true, '日历无事件');
return;
}
// Trigger click via JS to bypass fc-timegrid-event-harness interception
await ev.evaluate((el) => el.dispatchEvent(new MouseEvent('click', { bubbles: true })));
await page.waitForTimeout(3000);
await expect(page.locator('text=操作记录').first()).toBeVisible({ timeout: 10_000 });
await expect(page.locator('button').filter({ hasText: '点名' }).first()).toBeVisible({ timeout: 10_000 });
});
// ═══ Student-side ═══
test('学生端课时统计页面可访问', async ({ page }) => {
await loginAndGetTokens(page);
await page.goto('/student/lessons', { waitUntil: 'domcontentloaded', timeout: 60_000 });
await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 });
await page.waitForTimeout(3000);
await expect(page.getByText('页面不存在')).not.toBeVisible({ timeout: 5000 });
});
// ═══ API data correctness ═══
test('API审计日志可查询', async ({ page }) => {
if (createdIds.length === 0) test.skip(true, '无排课数据');
const t = await loginAndGetTokens(page);
if (!t.auth) test.skip(true, '未登录');
// Query via SPA proxy (uses axios interceptor)
const r = await page.evaluate(async (clazzId) => {
const resp = await fetch(`/api/v1/clazz/audit-log/list?clazz_id=${clazzId}`, {
headers: { 'Content-Type': 'application/json' },
});
return resp.ok ? (await resp.json()) : { r: false, status: resp.status };
}, createdIds[0]);
console.log('Audit log result:', JSON.stringify(r).substring(0, 200));
expect(r.r).not.toBe(undefined);
});
test('API请假记录可查询', async ({ page }) => {
const t = await loginAndGetTokens(page);
if (!t.auth) test.skip(true, '未登录');
// Query via SPA proxy
const r = await page.evaluate(async (studentId) => {
const resp = await fetch(`/api/v1/clazz/leave/list?student_id=${studentId}`, {
headers: { 'Content-Type': 'application/json' },
});
return resp.ok ? (await resp.json()) : { r: false, status: resp.status };
}, USER_ANAN);
console.log('Leave list result:', JSON.stringify(r).substring(0, 200));
expect(r.r).not.toBe(undefined);
});
});