|
@@ -12,9 +12,9 @@ async function setup(t) {
|
|
|
const db = new PGlite();
|
|
const db = new PGlite();
|
|
|
t.after(() => db.close());
|
|
t.after(() => db.close());
|
|
|
await db.exec(`
|
|
await db.exec(`
|
|
|
- CREATE TABLE "PracticeRecord" (id int, company text, yhid text, scid text, kcid text, xxcs text, jrscb text, "updatedAt" timestamptz);
|
|
|
|
|
- CREATE TABLE "CommonModel" (company text, "modelId" int, "generalId" int, "itemId" int, title text);
|
|
|
|
|
- CREATE TABLE "VocabularyWord" (company text, id int, sy text);
|
|
|
|
|
|
|
+ CREATE TABLE "PracticeRecord" (id double precision, company text, yhid text, scid text, kcid text, xxcs text, jrscb text, "updatedAt" timestamptz);
|
|
|
|
|
+ CREATE TABLE "CommonModel" (company text, "modelId" numeric, "generalId" numeric, "itemId" numeric, title text);
|
|
|
|
|
+ CREATE TABLE "VocabularyWord" (company text, id numeric, sy text);
|
|
|
INSERT INTO "CommonModel" VALUES
|
|
INSERT INTO "CommonModel" VALUES
|
|
|
('7pIbDBJmKx',52,101,1,'apple'), ('7pIbDBJmKx',52,102,2,'banana'),
|
|
('7pIbDBJmKx',52,101,1,'apple'), ('7pIbDBJmKx',52,102,2,'banana'),
|
|
|
('7pIbDBJmKx',52,103,3,'orange'), ('7pIbDBJmKx',53,901,11,'practice'),
|
|
('7pIbDBJmKx',52,103,3,'orange'), ('7pIbDBJmKx',53,901,11,'practice'),
|
|
@@ -29,11 +29,11 @@ async function setup(t) {
|
|
|
(16,'other','42','101','100','0','1','2026-10-14'),
|
|
(16,'other','42','101','100','0','1','2026-10-14'),
|
|
|
(17,'7pIbDBJmKx','42','999','100','0','1','2026-10-14');
|
|
(17,'7pIbDBJmKx','42','999','100','0','1','2026-10-14');
|
|
|
`);
|
|
`);
|
|
|
- let queries = 0;
|
|
|
|
|
|
|
+ let queries = 0;const calls=[];
|
|
|
const context = vm.createContext({
|
|
const context = vm.createContext({
|
|
|
Psql: {
|
|
Psql: {
|
|
|
- one: async (sql, values) => { queries++; return (await db.query(sql, values)).rows[0]; },
|
|
|
|
|
- query: async (sql, values) => { queries++; return (await db.query(sql, values)).rows; },
|
|
|
|
|
|
|
+ one: async (sql, values) => { queries++;calls.push({sql,values}); return (await db.query(sql, values)).rows[0]; },
|
|
|
|
|
+ query: async (sql, values) => { queries++;calls.push({sql,values}); return (await db.query(sql, values)).rows; },
|
|
|
},
|
|
},
|
|
|
});
|
|
});
|
|
|
// Execute the actual app scope without injecting helpers from the admin gateway.
|
|
// Execute the actual app scope without injecting helpers from the admin gateway.
|
|
@@ -42,7 +42,7 @@ async function setup(t) {
|
|
|
if (uid !== 42) throw new Error('无权访问该学员');
|
|
if (uid !== 42) throw new Error('无权访问该学员');
|
|
|
return uid;
|
|
return uid;
|
|
|
};
|
|
};
|
|
|
- return { read: input => context.newWordPage({ uid: 42, ...input }, { get: () => 1 }), queryCount: () => queries };
|
|
|
|
|
|
|
+ return { read: input => context.newWordPage({ uid: 42, ...input }, { get: () => 1 }), queryCount: () => queries, db, calls };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
test('生词本未学分页保留全量状态计数,并隔离其他学员和帐套', async t => {
|
|
test('生词本未学分页保留全量状态计数,并隔离其他学员和帐套', async t => {
|
|
@@ -79,3 +79,20 @@ test('生词本拒绝非法状态和越权访问,不执行查询', async t =>
|
|
|
await assert.rejects(read({ uid: 99 }), /无权访问/);
|
|
await assert.rejects(read({ uid: 99 }), /无权访问/);
|
|
|
assert.equal(queryCount(), 0);
|
|
assert.equal(queryCount(), 0);
|
|
|
});
|
|
});
|
|
|
|
|
+
|
|
|
|
|
+test('生词分页使用公司、模型和词条编号完整索引键,而非逐词扫描全部记录',async t=>{
|
|
|
|
|
+ const {read,db,calls}=await setup(t);
|
|
|
|
|
+ await db.exec('INSERT INTO "CommonModel" SELECT \'7pIbDBJmKx\',52,i,i,\'extra-word\' FROM generate_series(10000,20000) i;CREATE INDEX wordbook_word_lookup ON "CommonModel"(company,"modelId","generalId");CREATE INDEX wordbook_practice_lookup ON "PracticeRecord"(company,yhid);ANALYZE;SET enable_seqscan=off;SET enable_hashjoin=off;SET enable_mergejoin=off;');
|
|
|
|
|
+ await read({learningStatus:'pending'});
|
|
|
|
|
+ const {sql,values}=calls.find(call=>call.sql.startsWith('SELECT p.*'));
|
|
|
|
|
+ const result=await db.query('EXPLAIN (FORMAT JSON) '+sql,values),conditions=[];
|
|
|
|
|
+ const walk=node=>{if(node['Index Name']==='wordbook_word_lookup'&&node.Alias==='c')conditions.push(node['Index Cond']||'');for(const child of node.Plans||[])walk(child);};
|
|
|
|
|
+ walk(result.rows[0]['QUERY PLAN'][0].Plan);
|
|
|
|
|
+ assert.ok(conditions.some(condition=>condition.includes('modelId')&&condition.includes('generalId')),'词条查找必须使用索引中的模型和编号,避免随词库规模逐词扫描');
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+test('索引优化保留原文本编号匹配,不把带前导零或小数的历史键误配成单词',async t=>{
|
|
|
|
|
+ const {read,db}=await setup(t);
|
|
|
|
|
+ await db.exec(`INSERT INTO "PracticeRecord" VALUES (21,'7pIbDBJmKx','42','00101','100','0','1',now()),(22,'7pIbDBJmKx','42','101.0','100','0','1',now()),(23,'7pIbDBJmKx','42','invalid','100','0','1',now());`);
|
|
|
|
|
+ const result=await read({learningStatus:'pending'});assert.equal(result.page.itemCount,2);assert.deepEqual(result.rows.map(row=>row.Title),['apple','banana']);
|
|
|
|
|
+});
|