|
| 1 | +"""Behavioral guard for model-parameter deletion; requires Node and frontend npm deps.""" |
| 2 | + |
| 3 | +import shutil |
| 4 | +import subprocess |
| 5 | +import unittest |
| 6 | +from pathlib import Path |
| 7 | + |
| 8 | +PROJECT_ROOT = Path(__file__).resolve().parents[2] |
| 9 | + |
| 10 | +SCRIPT = r""" |
| 11 | +const assert = require('node:assert/strict'); |
| 12 | +const fs = require('node:fs'); |
| 13 | +const path = require('node:path'); |
| 14 | +const frontend = path.join(process.cwd(), 'frontend'); |
| 15 | +const ts = require(path.join(frontend, 'node_modules/typescript')); |
| 16 | +const { ref, reactive, computed } = require(path.join(frontend, 'node_modules/vue')); |
| 17 | +const component = fs.readFileSync(path.join(frontend, 'src/views/system/model/ModelForm.vue'), 'utf8'); |
| 18 | +const script = component.match(/<script[^>]*>([\s\S]*?)<\/script>/)[1]; |
| 19 | +const ast = ts.createSourceFile('ModelForm.ts', script, ts.ScriptTarget.Latest, true); |
| 20 | +const names = new Set([ |
| 21 | + 'modelForm', 'modelRef', 'advancedSetting', 'tempConfigMap', 'curId', |
| 22 | + 'currentSupplier', 'modelList', 'initForm', 'handleParamsDel', 'supplierChang', |
| 23 | + 'formatAdvancedSetting', 'baseModelChange', 'getSupplierDomain', 'getModelDefaultArgs', |
| 24 | + 'submitModel', |
| 25 | +]); |
| 26 | +const selected = ast.statements.filter(statement => ts.isVariableStatement(statement) && |
| 27 | + statement.declarationList.declarations.some(declaration => names.has(declaration.name.getText(ast)))); |
| 28 | +assert.equal(selected.length, names.size, 'all tested component declarations must be extracted'); |
| 29 | +const js = ts.transpileModule(selected.map(statement => statement.getText(ast)).join('\n'), { |
| 30 | + compilerOptions: { target: ts.ScriptTarget.ES2020 }, |
| 31 | +}).outputText; |
| 32 | +const suppliers = [ |
| 33 | + {id: 15, type: 'openai', model_config: {0: {api_domain: 'http://example.test/v1', |
| 34 | + common_args: [{key: 'temperature', val: 0.6}], model_options: []}}}, |
| 35 | + {id: 16, type: 'openai', model_config: {0: {api_domain: 'http://other.test/v1', |
| 36 | + common_args: [{key: 'temperature', val: 0.3}], model_options: []}}}, |
| 37 | +]; |
| 38 | +let emitted; |
| 39 | +const getSupplier = id => suppliers.find(supplier => supplier.id === id); |
| 40 | +const api = new Function('ref', 'reactive', 'computed', 'get_supplier', 'base_model_options', 'emits', |
| 41 | + js + '\nreturn {modelForm, modelRef, advancedSetting, initForm, handleParamsDel, supplierChang, baseModelChange, submitModel};' |
| 42 | +)(ref, reactive, computed, getSupplier, id => getSupplier(id).model_config[0].model_options, |
| 43 | + (event, payload) => {assert.equal(event, 'submit'); emitted = payload;}); |
| 44 | +api.modelRef.value = {clearValidate() {}, validate(callback) {callback(true);}}; |
| 45 | +const original = {supplier: 15, base_model: 'gpt-4.1', model_type: 0, config_list: [ |
| 46 | + {id: 1, name: 'Temperature', key: 'temperature', val: 0.8}, |
| 47 | + {id: 2, name: 'Token limit', key: 'max_tokens', val: 4096}, |
| 48 | +]}; |
| 49 | +const untouched = JSON.stringify(original); |
| 50 | +const select = (supplierId, model) => { |
| 51 | + if (supplierId !== api.modelForm.supplier) api.supplierChang(getSupplier(supplierId)); |
| 52 | + api.modelForm.base_model = model; |
| 53 | + api.baseModelChange(model); |
| 54 | +}; |
| 55 | +const submit = () => {api.submitModel(); return emitted.config_list;}; |
| 56 | +api.initForm(original); |
| 57 | +select(15, 'gpt-4o-mini'); |
| 58 | +select(16, 'gpt-4.1'); |
| 59 | +select(15, 'gpt-4.1'); |
| 60 | +assert.deepEqual(submit(), [ |
| 61 | + {key: 'temperature', name: 'Temperature', val: 0.8}, |
| 62 | + {key: 'max_tokens', name: 'Token limit', val: 4096}, |
| 63 | +], 'switching without deleting preserves model configuration'); |
| 64 | +const deleteAll = process.argv[1] === 'all'; |
| 65 | +const deleted = deleteAll ? [...api.advancedSetting.value] : [api.advancedSetting.value[1]]; |
| 66 | +for (const item of deleted) api.handleParamsDel(item); |
| 67 | +const expected = deleteAll ? [] : [{key: 'temperature', name: 'Temperature', val: 0.8}]; |
| 68 | +assert.deepEqual(submit(), expected, 'deletion updates the emitted payload immediately'); |
| 69 | +select(15, 'gpt-4o-mini'); |
| 70 | +assert.deepEqual(submit(), [{key: 'temperature', name: 'temperature', val: 0.6}], 'other model unchanged'); |
| 71 | +select(16, 'gpt-4.1'); |
| 72 | +assert.deepEqual(submit(), [{key: 'temperature', name: 'temperature', val: 0.3}], 'other supplier unchanged'); |
| 73 | +select(15, 'gpt-4.1'); |
| 74 | +assert.deepEqual(submit(), expected, 'deleted parameters must stay deleted after switching back'); |
| 75 | +assert.equal(JSON.stringify(original), untouched, 'deleting must not mutate loaded input configuration'); |
| 76 | +""" |
| 77 | + |
| 78 | + |
| 79 | +class ModelParameterCacheTestCase(unittest.TestCase): |
| 80 | + def check_deletion(self, mode: str) -> None: |
| 81 | + if not shutil.which("node"): |
| 82 | + self.skipTest("Node is required for the frontend behavior guard") |
| 83 | + if not ( |
| 84 | + PROJECT_ROOT / "frontend/node_modules/typescript/package.json" |
| 85 | + ).exists(): |
| 86 | + self.skipTest("Run npm install in frontend before this behavior guard") |
| 87 | + result = subprocess.run( |
| 88 | + ["node", "-e", SCRIPT, mode], |
| 89 | + cwd=PROJECT_ROOT, |
| 90 | + capture_output=True, |
| 91 | + text=True, |
| 92 | + timeout=30, |
| 93 | + ) |
| 94 | + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) |
| 95 | + |
| 96 | + def test_deleted_parameter_stays_deleted_after_switching_models(self) -> None: |
| 97 | + self.check_deletion("one") |
| 98 | + |
| 99 | + def test_deleting_all_parameters_stays_empty_after_switching_models(self) -> None: |
| 100 | + self.check_deletion("all") |
| 101 | + |
| 102 | + |
| 103 | +if __name__ == "__main__": |
| 104 | + unittest.main() |
0 commit comments