platform/lms-adm-fe release promotion snapshot
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const ts = require('typescript');
|
||||
const React = require('react');
|
||||
|
||||
const source = ts.createSourceFile('SettingsPage.tsx', fs.readFileSync(require.resolve('../src/pages/settings/SettingsPage.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
const constants = source.statements.filter(node => ts.isVariableStatement(node) && ['AI_MODEL_DEFAULTS', 'AI_MODEL_OPTIONS', 'CUSTOM_AI_MODEL'].includes(node.declarationList.declarations[0].name.getText(source)));
|
||||
function compile(code, globals = {}) {
|
||||
const exports = {};
|
||||
vm.runInNewContext(ts.transpileModule(code, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText, { exports, require, ...globals });
|
||||
return exports;
|
||||
}
|
||||
const catalogs = constants.map(node => `export ${node.getText(source)}`).join('\n');
|
||||
const { AI_MODEL_OPTIONS, AI_MODEL_DEFAULTS, CUSTOM_AI_MODEL } = compile(catalogs);
|
||||
function find(node, predicate) {
|
||||
if (predicate(node)) return node;
|
||||
return ts.forEachChild(node, child => find(child, predicate));
|
||||
}
|
||||
const section = find(source, node => ts.isJsxElement(node) && node.openingElement.tagName.getText(source) === 'section' && node.openingElement.attributes.getText(source).includes('border-t border-[#e2e8f0] pt-5') && node.getText(source).includes('AI 공급자'));
|
||||
function controls(provider, model, custom = false) {
|
||||
const state = { aiProvider: provider, aiModel: model, aiCustomModel: custom };
|
||||
const context = {
|
||||
aiSettingLoading: false, saving: false, aiEndpointUrl: 'https://example.test', aiApiKey: '', aiHasApiKey: false, aiConfiguredProvider: null,
|
||||
AI_PROVIDER_LABELS: { OPENAI: 'OpenAI', ANTHROPIC: 'Claude', GEMINI: 'Gemini', QWEN: 'Qwen' },
|
||||
DEFAULT_QWEN_ENDPOINT: 'https://example.test', KeyRound: () => null,
|
||||
setAiProvider(value) { state.aiProvider = value; }, setAiModel(value) { state.aiModel = value; },
|
||||
setAiCustomModel(value) { state.aiCustomModel = value; }, setAiApiKey() {}, setAiEndpointUrl() {},
|
||||
};
|
||||
const render = () => compile(`${catalogs}\nexport const tree = (${section.getText(source)});`, { ...context, ...state }).tree;
|
||||
return { state, render };
|
||||
}
|
||||
function elements(root, predicate) {
|
||||
if (!React.isValidElement(root)) return [];
|
||||
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))];
|
||||
}
|
||||
|
||||
for (const provider of Object.keys(AI_MODEL_OPTIONS)) {
|
||||
test(`${provider} offers current models and a custom model field without changing defaults`, () => {
|
||||
const h = controls(provider, AI_MODEL_DEFAULTS[provider]);
|
||||
let tree = h.render();
|
||||
const select = elements(tree, node => node.type === 'select')[0];
|
||||
assert.equal(select.props.value, AI_MODEL_DEFAULTS[provider]);
|
||||
assert.equal(elements(select, node => node.type === 'option' && node.props.value === CUSTOM_AI_MODEL).length, 1);
|
||||
assert.equal(elements(tree, node => node.type === 'input' && node.props.type === 'text').length, 0);
|
||||
select.props.onChange({ target: { value: CUSTOM_AI_MODEL } });
|
||||
assert.equal(h.state.aiCustomModel, true);
|
||||
assert.equal(h.state.aiModel, '');
|
||||
tree = h.render();
|
||||
const input = elements(tree, node => node.type === 'input' && node.props.type === 'text')[0];
|
||||
assert.equal(input.props.maxLength, 120);
|
||||
input.props.onChange({ target: { value: 'custom-model-2026' } });
|
||||
assert.equal(h.state.aiModel, 'custom-model-2026');
|
||||
assert.equal(elements(h.render(), node => node.type === 'select')[0].props.value, CUSTOM_AI_MODEL);
|
||||
elements(h.render(), node => node.type === 'select')[0].props.onChange({ target: { value: AI_MODEL_OPTIONS[provider][0].value } });
|
||||
assert.equal(h.state.aiCustomModel, false);
|
||||
assert.equal(h.state.aiModel, AI_MODEL_OPTIONS[provider][0].value);
|
||||
assert.equal(new Set(AI_MODEL_OPTIONS[provider].map(option => option.value)).size, AI_MODEL_OPTIONS[provider].length);
|
||||
});
|
||||
}
|
||||
|
||||
test('saved unknown model IDs remain visible in custom mode', () => {
|
||||
const model = 'provider/private-model:revision-10';
|
||||
const h = controls('QWEN', model, true);
|
||||
const tree = h.render();
|
||||
assert.equal(elements(tree, node => node.type === 'select')[0].props.value, CUSTOM_AI_MODEL);
|
||||
assert.equal(elements(tree, node => node.type === 'input' && node.props.type === 'text')[0].props.value, model);
|
||||
assert.equal(AI_MODEL_OPTIONS.QWEN.some(option => option.value === model), false);
|
||||
});
|
||||
|
||||
test('the selected provider button preserves a custom draft', () => {
|
||||
const h = controls('OPENAI', 'custom-gpt', true);
|
||||
const providers = elements(h.render(), node => node.props.role === 'radio');
|
||||
providers[0].props.onClick();
|
||||
assert.equal(h.state.aiModel, 'custom-gpt');
|
||||
assert.equal(h.state.aiCustomModel, true);
|
||||
providers[1].props.onClick();
|
||||
assert.equal(h.state.aiProvider, 'ANTHROPIC');
|
||||
assert.equal(h.state.aiModel, AI_MODEL_DEFAULTS.ANTHROPIC);
|
||||
assert.equal(h.state.aiCustomModel, false);
|
||||
});
|
||||
|
||||
test('official current API IDs are present', () => {
|
||||
for (const [provider, ids] of Object.entries({
|
||||
OPENAI: ['gpt-6.1-sol', 'gpt-6-astra', 'gpt-6-luna'],
|
||||
ANTHROPIC: ['claude-sonnet-5-5', 'claude-opus-5-5', 'claude-haiku-5-5', 'claude-fable-5-1'],
|
||||
GEMINI: ['gemini-4-argon', 'gemini-3.8-flash', 'gemini-3.5-flash-lite'],
|
||||
QWEN: ['qwen3.8-max', 'qwen3.8-flash'],
|
||||
})) for (const id of ids) assert.ok(AI_MODEL_OPTIONS[provider].some(option => option.value === id), id);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const ts = require('typescript');
|
||||
const React = require('react');
|
||||
|
||||
const read = path => fs.readFileSync(require.resolve(`../src/${path}`), 'utf8');
|
||||
function compile(source, globals = {}) {
|
||||
const exports = {};
|
||||
const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText;
|
||||
vm.runInNewContext(code, { exports, require, ...globals });
|
||||
return exports;
|
||||
}
|
||||
function elements(root, predicate) {
|
||||
if (!React.isValidElement(root)) return [];
|
||||
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))];
|
||||
}
|
||||
const source = ts.createSourceFile('SettingsPage.tsx', read('pages/settings/SettingsPage.tsx'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
const page = source.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'SettingsPage');
|
||||
const CourseAiSettings = () => null;
|
||||
const { SettingsPage } = compile(page.getText(source), { CourseAiSettings, PageHeader: () => null, Bot: () => null });
|
||||
const props = {
|
||||
session: { accessToken: 'test-token', user: { id: 'teacher' } },
|
||||
selectedCourseId: 'course-a',
|
||||
courses: [{ id: 'course-a', title: 'A' }, { id: 'course-b', title: 'B' }],
|
||||
onSelectCourse() {},
|
||||
};
|
||||
|
||||
test('settings remounts the credential draft when course or session changes', () => {
|
||||
const form = input => elements(SettingsPage(input), node => node.type === CourseAiSettings)[0];
|
||||
const initial = form(props);
|
||||
assert.equal(initial.props.selectedCourseId, 'course-a');
|
||||
assert.notEqual(initial.key, form({ ...props, selectedCourseId: 'course-b' }).key);
|
||||
assert.notEqual(initial.key, form({ ...props, session: { ...props.session, accessToken: 'new-token' } }).key);
|
||||
});
|
||||
|
||||
test('settings offers the global course selection and no credential form without a course', () => {
|
||||
const selected = [];
|
||||
const tree = SettingsPage({ ...props, onSelectCourse: id => selected.push(id) });
|
||||
const header = React.Children.toArray(tree.props.children)[0];
|
||||
const select = elements(header.props.action, node => node.type === 'select')[0];
|
||||
assert.equal(select.props.value, 'course-a');
|
||||
select.props.onChange({ target: { value: 'course-b' } });
|
||||
assert.deepEqual(selected, ['course-b']);
|
||||
assert.equal(elements(SettingsPage({ ...props, selectedCourseId: '', courses: [] }), node => node.type === CourseAiSettings).length, 0);
|
||||
});
|
||||
|
||||
test('curriculum no longer loads AI settings or offers an agent tab', () => {
|
||||
const curriculum = read('pages/curriculum/CurriculumPage.tsx');
|
||||
assert.doesNotMatch(curriculum, /getCourseAiSetting|updateCourseAiSetting|handleSaveAiSetting|setActiveTab\('ai'\)/);
|
||||
assert.match(read('App.tsx'), /<SettingsPage session=\{session\} selectedCourseId=\{selectedCourseId\} courses=\{courses\} onSelectCourse=\{onSelectCourse\}/);
|
||||
});
|
||||
|
||||
test('analytics is hidden from the menu without deleting its existing route', () => {
|
||||
const routes = compile(read('routes.ts').replace('import.meta.env.BASE_URL', JSON.stringify('/lms-adm/')));
|
||||
assert.equal(routes.navItems.some(item => item.id === 'analytics'), false);
|
||||
assert.equal(routes.navItems.some(item => item.id === 'settings'), true);
|
||||
assert.equal(routes.isAppRoute('analytics'), true);
|
||||
assert.equal(routes.getRouteFromPathname('/lms-adm/analytics'), 'analytics');
|
||||
});
|
||||
Reference in New Issue
Block a user