coach-student-page.test.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. import { after, before, beforeEach, test } from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import { readFileSync } from 'node:fs';
  4. import vm from 'node:vm';
  5. import { PGlite } from '@electric-sql/pglite';
  6. const db = new PGlite();
  7. const source = readFileSync(new URL('../deploy-admin-functions.mjs', import.meta.url), 'utf8');
  8. const gateway = vm.createContext({});
  9. vm.runInContext(source.match(/const appGatewayCode = String.raw`([\s\S]*?)\n`;/)[1].replace(/const DEFAULT_COMPANY_ID = '[^']+';/, "const DEFAULT_COMPANY_ID = 'co';"), gateway);
  10. gateway.Psql = {
  11. one: async (sql, args) => (await db.query(sql, args)).rows[0],
  12. oneOrNone: async (sql, args) => (await db.query(sql, args)).rows[0] ?? null,
  13. query: async (sql, args) => (await db.query(sql, args)).rows,
  14. };
  15. gateway.appOverviewToday = () => '2026-10-30';
  16. const coach = { get: key => ({legacyUserId:300, legacyGroupId:3})[key] };
  17. const page = (input = {}) => gateway.coachStudentPage({ scope: 'current-scheduled', ...input }, coach);
  18. const ids = result => Array.from(result.rows, row => row.UserID);
  19. before(async () => {
  20. await db.exec(`
  21. CREATE TABLE "_User"("objectId" text,"company" text,"legacyUserId" int,"legacyGroupId" int,"legacyUserData" jsonb,
  22. "isDisabled" boolean,"isDeleted" boolean,"username" text,"email" text,"mobile" text,"phone" text,"avatar" text,
  23. "nickname" text,"nickName" text,"realName" text,"createdAt" timestamptz);
  24. CREATE TABLE "CourseAppointment"("company" text,"id" int,"szyh" text,"pl" text,"dszt" text,"bxrq" text,"yysj" text,"isDeleted" boolean);
  25. CREATE TABLE "CommonModel"("company" text,"modelId" int,"itemId" int,"status" int,"updatedAt" timestamptz,"isDeleted" boolean);
  26. CREATE TABLE "DailyStudyRecord"("company" text,"id" int,"userId" numeric,"pl" text,"isDeleted" boolean);
  27. CREATE TABLE "MemoryPracticeRecord"("company" text,"id" numeric,"yhid" numeric,"plid" numeric,"fxzt" text,"kywrq" text,"kywsj" text,"isDeleted" boolean,"rewardDuplicate" boolean);
  28. `);
  29. });
  30. beforeEach(async () => {
  31. gateway.appOverviewToday = () => '2026-10-30';
  32. await db.exec('TRUNCATE "_User","CourseAppointment","CommonModel","DailyStudyRecord","MemoryPracticeRecord"');
  33. });
  34. after(() => db.close());
  35. async function user(id, { disabled = false, deleted = false, group = 1, company = 'co' } = {}) {
  36. await db.query('INSERT INTO "_User"("objectId","company","legacyUserId","legacyGroupId","isDisabled","isDeleted","username","legacyUserData") VALUES($1,$2,$3,$4,$5,$6,$7,$8)',
  37. ['u' + id, company, id, group, disabled, deleted, 'student-' + id, { GroupID: 1 }]);
  38. }
  39. async function appointment(id, student, { date = '2026-10-31', yysj = '', status = '0', commonStatus = 99, teacher = '300', company = 'co', deleted = false, commonDeleted = false } = {}) {
  40. await db.query('INSERT INTO "CourseAppointment" VALUES($1,$2,$3,$4,$5,$6,$7,$8)', [company, id, String(student), teacher, status, date, yysj, deleted]);
  41. await db.query('INSERT INTO "CommonModel" VALUES($1,54,$2,$3,$4,$5)', [company, id, commonStatus, '2026-10-30T00:00:00Z', commonDeleted]);
  42. }
  43. async function review(id, student, { date = '2026-11-01', time = '', status = '0', commonStatus = 99, teacher = 300, company = 'co', deleted = false, commonDeleted = false, duplicate = false } = {}) {
  44. await db.query('INSERT INTO "MemoryPracticeRecord" VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)',
  45. [company, id, student, teacher, status, date, time, deleted, duplicate]);
  46. await db.query('INSERT INTO "CommonModel" VALUES($1,60,$2,$3,$4,$5)',
  47. [company, id, commonStatus, '2026-10-30T00:00:00Z', commonDeleted]);
  48. }
  49. test('生词本名单、读写授权使用同一归属,排除取消、过期和其他老师的课', async () => {
  50. for (let id = 1; id <= 6; id++) await user(id, {disabled: id === 6});
  51. await appointment(1, 1, {status:'11'});
  52. await appointment(2, 2, {date:'2026-10-29', status:'30'});
  53. await review(2, 2);
  54. await appointment(3, 3, {commonStatus:-2});
  55. await appointment(4, 4, {teacher:'301'});
  56. await appointment(5, 5, {date:'2026-10-29', status:'30'});
  57. await appointment(6, 6);
  58. const roster = await page({scope:'wordbook', selectedStudentId:2});
  59. assert.deepEqual(Array.from(roster.rows, row => row.userId), [2, 1]);
  60. assert.deepEqual(Object.keys(roster.rows[0]), ['userId', 'honeyName']);
  61. assert.equal(await gateway.coachWordbookTarget(coach, 1), 1);
  62. assert.equal(await gateway.coachWordbookTarget(coach, 2), 2);
  63. for (const id of [3, 4, 5, 6]) {
  64. await assert.rejects(gateway.coachWordbookTarget(coach, id), /不在你的生词本带练名单/);
  65. await assert.rejects(gateway.newWordPage({uid:id}, coach), /不在你的生词本带练名单/);
  66. }
  67. const student = { get: key => ({legacyUserId:1, legacyGroupId:1})[key] };
  68. await assert.rejects(gateway.coachStudentPage({scope:'wordbook'}, student), /仅陪练老师/);
  69. await assert.rejects(gateway.coachWordbookTarget(student, 1), /仅陪练老师/);
  70. });
  71. test('没有未结束复习时仅列出正常账号且仍有今天或未来排课的学员,先筛选再分页', async () => {
  72. for (let id = 1; id <= 13; id++) await user(id, { disabled: id === 2, deleted: id === 3, group: id === 4 ? 3 : 1 });
  73. await appointment(1, 1);
  74. await appointment(2, 2);
  75. await appointment(3, 3);
  76. await appointment(4, 4);
  77. await appointment(5, 5, { date: '2026-10-29' });
  78. await appointment(6, 6, { status: '30' });
  79. await appointment(7, 7, { commonStatus: -2 });
  80. await appointment(8, 8, { teacher: '301' });
  81. await appointment(9, 9, { company: 'other' });
  82. await appointment(10, 10, { date: '2026-10-30', status: '10' });
  83. await appointment(11, 11, { deleted: true });
  84. await appointment(12, 12, { commonDeleted: true });
  85. await appointment(13, 13, { status: '-1' });
  86. await appointment(14, 999); // No matching account.
  87. await appointment(15, 1); // Multiple lessons must not duplicate a student.
  88. const first = await page({ psize: 1 });
  89. const second = await page({ psize: 1, cpage: 2 });
  90. assert.equal(first.page.itemCount, 2);
  91. assert.equal(first.page.pageCount, 2);
  92. assert.deepEqual([...ids(first), ...ids(second)].sort((a, b) => a - b), [1, 10]);
  93. });
  94. test('日期兼容紧凑日期、斜线日期及预约时间回退,空日期不进入列表', async () => {
  95. for (let id = 1; id <= 5; id++) await user(id);
  96. await appointment(1, 1, { date: '20261030' });
  97. await appointment(2, 2, { date: '', yysj: '2026-10-30 09:00:00' });
  98. await appointment(3, 3, { date: '2026/10/31' });
  99. await appointment(4, 4, { date: '', yysj: '' });
  100. await appointment(5, 5, { date: '20261029' });
  101. assert.deepEqual(ids(await page()).sort((a, b) => a - b), [1, 2, 3]);
  102. });
  103. test('无后续排课时按未结束复习保留正常学员,过滤无效账号、任务及其他老师记录', async () => {
  104. for (let id = 1; id <= 12; id++) {
  105. await user(id, { disabled: id === 2, deleted: id === 3, group: id === 4 ? 3 : 1 });
  106. await appointment(id, id, { date: '2026-10-29', status: '30' });
  107. await review(id, id, { company: id === 5 ? 'other' : 'co', teacher: id === 6 ? 301 : 300,
  108. deleted: id === 7, commonStatus: id === 8 ? -2 : 99, commonDeleted: id === 9,
  109. duplicate: id === 10, status: id === 11 ? '1' : '0', date: id === 12 ? '2026-10-29' : '2026-11-01' });
  110. }
  111. await user(20); await appointment(20, 20); // Upcoming lessons remain visible before any review is generated.
  112. const first = await page({ selectedStudentId: 1, psize: 1 });
  113. const second = await page({ selectedStudentId: 1, psize: 1, cpage: 2 });
  114. assert.equal(first.page.itemCount, 2);
  115. assert.deepEqual(ids(first), [1]);
  116. assert.deepEqual(ids(second), [20]);
  117. });
  118. test('最后复习当天仍显示,复习期结束后移出,历史授课记录不会永久保留学员', async () => {
  119. await user(1); await appointment(1, 1, { date: '2026-10-01', status: '30' });
  120. await db.query('INSERT INTO "DailyStudyRecord" VALUES($1,$2,$3,$4,$5)', ['co', 1, 1, '300', false]);
  121. await db.query('INSERT INTO "CommonModel" VALUES($1,56,$2,99,$3,false)', ['co', 1, '2026-10-01T00:00:00Z']);
  122. await review(1, 1, { date: '2026-10-30' });
  123. await review(2, 1, { date: '2026-11-01' });
  124. assert.deepEqual(ids(await page()), [1]);
  125. gateway.appOverviewToday = () => '2026-10-31';
  126. assert.deepEqual(ids(await page()), [1]);
  127. gateway.appOverviewToday = () => '2026-11-01';
  128. assert.deepEqual(ids(await page()), [1]);
  129. gateway.appOverviewToday = () => '2026-11-02';
  130. assert.deepEqual(ids(await page({ selectedStudentId: 1 })), []);
  131. });
  132. test('复习全部完成后移出,另一节课仍有未结束复习时继续显示', async () => {
  133. await user(1); await appointment(1, 1, { date: '2026-10-01', status: '30' });
  134. await review(1, 1, { date: '2026-11-05' });
  135. await review(2, 1, { date: '2026-11-20', status: '1' });
  136. assert.deepEqual(ids(await page()), [1]);
  137. await db.query('UPDATE "MemoryPracticeRecord" SET fxzt=$1 WHERE id=$2', ['1', 1]);
  138. assert.deepEqual(ids(await page()), []);
  139. await review(3, 1, { date: '2026-11-21' });
  140. assert.deepEqual(ids(await page()), [1]);
  141. });
  142. test('复习日期兼容紧凑日期、斜线日期和计划时间回退,无日期任务不能保留学员', async () => {
  143. for (let id = 1; id <= 4; id++) { await user(id); await appointment(id, id, { date: '2026-10-01', status: '30' }); }
  144. await review(1, 1, { date: '20261030' });
  145. await review(2, 2, { date: '2026/10/31' });
  146. await review(3, 3, { date: '', time: '2026-10-30 20:00' });
  147. await review(4, 4, { date: '', time: '' });
  148. assert.deepEqual(ids(await page()).sort((a,b) => a-b), [1,2,3]);
  149. });
  150. test('恢复有效学员时将其放在首屏,停用或无排课的旧选择不能绕过筛选', async () => {
  151. for (let id = 1; id <= 4; id++) { await user(id, { disabled: id === 4 }); await appointment(id, id); }
  152. await user(5);
  153. const selected = await page({ selectedStudentId: 3, psize: 2 });
  154. assert.equal(ids(selected)[0], 3);
  155. const next = await page({ selectedStudentId: 3, psize: 2, cpage: 2 });
  156. assert.equal(new Set([...ids(selected), ...ids(next)]).size, 3);
  157. for (const selectedStudentId of [4, 5]) {
  158. const result = await page({ selectedStudentId });
  159. assert.equal(result.page.itemCount, 3);
  160. assert.ok(!ids(result).includes(selectedStudentId));
  161. }
  162. });
  163. test('筛选固定使用登录老师和当前帐套,请求参数不能指定其他老师', async () => {
  164. await user(1); await user(2); await user(3, { company: 'other' });
  165. await appointment(1, 1); await appointment(2, 2, { teacher: '301' }); await appointment(3, 3);
  166. assert.deepEqual(ids(await page({ uid: 301, coachId: 301, company: 'other' })), [1]);
  167. });
  168. test('未请求新筛选的我的学员接口仍可列出历史学员', async () => {
  169. await user(1); await user(2, { disabled: true });
  170. await appointment(1, 1, { date: '2020-01-01', status: '30' });
  171. await appointment(2, 2);
  172. assert.deepEqual(ids(await page()), []);
  173. assert.deepEqual(ids(await page({ scope: '' })).sort((a, b) => a - b), [1, 2]);
  174. });