diff --git a/public/input-test.js b/public/input-test.js index 5386a5f8..cf2a72a8 100644 --- a/public/input-test.js +++ b/public/input-test.js @@ -53,6 +53,15 @@ 'exponent' ); + // Demonstrate toolbar buttons for the chemistry/scientific-notation commands + // added in this branch (\sci, \positiveion, \negativeion). \ion takes + // [sign]{charge}, so the sign-bound shorthands are the natural one-click buttons. + mathField.options.addToolbarButtons([ + { id: 'sci', latex: '\\sci', tooltip: 'scientific notation', icon: 'E' }, + { id: 'positiveion', latex: '\\positiveion', tooltip: 'positive ion', icon: '\\text{ }^{\\text{ }+}' }, + { id: 'negativeion', latex: '\\negativeion', tooltip: 'negative ion', icon: '\\text{ }^{\\text{ }-}' } + ]); + const optionsContainer = document.querySelector('.options-container'); for (const option in cfg) { diff --git a/public/unit-test.html b/public/unit-test.html index e6f47337..6fc76ff7 100644 --- a/public/unit-test.html +++ b/public/unit-test.html @@ -29,166 +29,170 @@

Unit Tests

while (mock.firstChild) mock.firstChild.remove(); }); - if (post_xunit_to) { - let xunit = ''; - Mocha.process.stdout.write = (line) => (xunit += line); - const runner = mocha.run(); - - // the following is based on - // https://github.com/saucelabs-sample-scripts/JavaScript/blob/ - // 4946c5cf0ab7325dce5562881dba7c28e30989e5/reporting_mocha.js - const failedTests = []; - runner.on('fail', (test, err) => { - const flattenTitles = (test) => { - const titles = []; - while (test.parent.title) { - titles.push(test.parent.title); - test = test.parent; - } - return titles.reverse(); - }; + // Run after window load so the test bundle (mathquill.test.js, included at + // the end of the body) has registered its suites before mocha.run(). + window.addEventListener('load', () => { + if (post_xunit_to) { + let xunit = ''; + Mocha.process.stdout.write = (line) => (xunit += line); + const runner = mocha.run(); + + // the following is based on + // https://github.com/saucelabs-sample-scripts/JavaScript/blob/ + // 4946c5cf0ab7325dce5562881dba7c28e30989e5/reporting_mocha.js + const failedTests = []; + runner.on('fail', (test, err) => { + const flattenTitles = (test) => { + const titles = []; + while (test.parent.title) { + titles.push(test.parent.title); + test = test.parent; + } + return titles.reverse(); + }; - failedTests.push({ - name: test.title, - result: false, - message: err.message, - stack: err.stack, - titles: flattenTitles(test) + failedTests.push({ + name: test.title, + result: false, + message: err.message, + stack: err.stack, + titles: flattenTitles(test) + }); }); - }); - runner.on('end', () => { - fetch(post_xunit_to, { method: 'post', body: xunit }).then(() => { - window.mochaResults = runner.stats; - window.mochaResults.reports = failedTests; - }); - }); - } else { - const json = location.search.indexOf('json') >= 0; - const listTests = location.search.indexOf('listTests') >= 0; - const suiteMap = {}; - const runner = mocha.run(); - - runner.on('suite', (suite) => { - const title = xmlEscape(suite.fullTitle()); - suiteMap[title] = { - assertions: [] - }; - if (listTests) { - suiteMap[title].assertions.push({ - elapsedTime: 0, - timestamp: 0, - result: true, - message: 'okay' - }); - } - }); - - runner.on('pass', (test) => { - if (!listTests) { - const title = getTestSuiteTitle(test); - const elapsedTime = test.duration / 1000; - const timestamp = Date.now(); - suiteMap[title].assertions.push({ - elapsedTime: elapsedTime, - timestamp: timestamp, - result: true, - message: xmlEscape(test.title) + runner.on('end', () => { + fetch(post_xunit_to, { method: 'post', body: xunit }).then(() => { + window.mochaResults = runner.stats; + window.mochaResults.reports = failedTests; }); - } - }); - - runner.on('fail', (test, err) => { - if (!listTests) { - const title = getTestSuiteTitle(test); - const elapsedTime = test.duration / 1000; - const timestamp = Date.now(); - suiteMap[title].assertions.push({ - elapsedTime: elapsedTime, - timestamp: timestamp, - result: false, - message: xmlEscape(err.message), - stacktrace: xmlEscape(err.stack), - expected: true, - actual: false - }); - } - }); - - runner.on('end', () => { - const moduleResults = []; - for (const suiteTitle in suiteMap) { - if (suiteMap.hasOwnProperty(suiteTitle)) { - const suiteResults = suiteMap[suiteTitle]; - let duration = 0; - for (const assertion of suiteResults.assertions) duration += assertion.elapsedTime; - moduleResults.push({ - name: suiteTitle, - assertions: suiteResults.assertions, - time: duration + }); + } else { + const json = location.search.indexOf('json') >= 0; + const listTests = location.search.indexOf('listTests') >= 0; + const suiteMap = {}; + const runner = mocha.run(); + + runner.on('suite', (suite) => { + const title = xmlEscape(suite.fullTitle()); + suiteMap[title] = { + assertions: [] + }; + if (listTests) { + suiteMap[title].assertions.push({ + elapsedTime: 0, + timestamp: 0, + result: true, + message: 'okay' + }); + } + }); + + runner.on('pass', (test) => { + if (!listTests) { + const title = getTestSuiteTitle(test); + const elapsedTime = test.duration / 1000; + const timestamp = Date.now(); + suiteMap[title].assertions.push({ + elapsedTime: elapsedTime, + timestamp: timestamp, + result: true, + message: xmlEscape(test.title) }); } - } - const testResults = { - modules: { mathquill: moduleResults }, - passes: runner.stats.passes, - failures: runner.stats.failures, - skips: 0 + }); + + runner.on('fail', (test, err) => { + if (!listTests) { + const title = getTestSuiteTitle(test); + const elapsedTime = test.duration / 1000; + const timestamp = Date.now(); + suiteMap[title].assertions.push({ + elapsedTime: elapsedTime, + timestamp: timestamp, + result: false, + message: xmlEscape(err.message), + stacktrace: xmlEscape(err.stack), + expected: true, + actual: false + }); + } + }); + + runner.on('end', () => { + const moduleResults = []; + for (const suiteTitle in suiteMap) { + if (suiteMap.hasOwnProperty(suiteTitle)) { + const suiteResults = suiteMap[suiteTitle]; + let duration = 0; + for (const assertion of suiteResults.assertions) duration += assertion.elapsedTime; + moduleResults.push({ + name: suiteTitle, + assertions: suiteResults.assertions, + time: duration + }); + } + } + const testResults = { + modules: { mathquill: moduleResults }, + passes: runner.stats.passes, + failures: runner.stats.failures, + skips: 0 + }; + if (json) window.testResultsString = JSON.stringify(testResults, null, 2); + else window.testResultsString = outputXML(testResults); + }); + + const getTestSuiteTitle = (test) => xmlEscape(test.parent.fullTitle()); + + // must escape a few symbols in xml attributes: + // http://stackoverflow.com/questions/866706/ + // which-characters-are-invalid-unless-encoded-in-an-xml-attribute + const xmlEscape = (string) => { + if (typeof string !== 'string') return ''; + string = string || ''; + string = string.replace(/&/g, '&'); + string = string.replace(/"/g, '"'); + string = string.replace(/ xmlEscape(test.parent.fullTitle()); - - // must escape a few symbols in xml attributes: - // http://stackoverflow.com/questions/866706/ - // which-characters-are-invalid-unless-encoded-in-an-xml-attribute - const xmlEscape = (string) => { - if (typeof string !== 'string') return ''; - string = string || ''; - string = string.replace(/&/g, '&'); - string = string.replace(/"/g, '"'); - string = string.replace(/ { - const xml = []; - xml.push(''); - xml.push(''); - - for (const moduleName in results.modules) { - const module = results.modules[moduleName]; - for (const test of module) { - xml.push(``); - - for (const assertion of test.assertions) { - const assertionMessage = assertion.message || 'no-assertion-message'; - const assertionTime = assertion.elapsedTime; - - xml.push(``); - - if (assertion.result === false) { - xml.push(``); - xml.push(`Expected: ${assertion.expected}\n`); - xml.push(`Actual: ${assertion.actual}\n`); - xml.push(`Stacktrace: ${assertion.stacktrace}`); - xml.push(''); - } else if (assertion.result === undefined) { - xml.push(''); + + const outputXML = (results) => { + const xml = []; + xml.push(''); + xml.push(''); + + for (const moduleName in results.modules) { + const module = results.modules[moduleName]; + for (const test of module) { + xml.push(``); + + for (const assertion of test.assertions) { + const assertionMessage = assertion.message || 'no-assertion-message'; + const assertionTime = assertion.elapsedTime; + + xml.push(``); + + if (assertion.result === false) { + xml.push(``); + xml.push(`Expected: ${assertion.expected}\n`); + xml.push(`Actual: ${assertion.actual}\n`); + xml.push(`Stacktrace: ${assertion.stacktrace}`); + xml.push(''); + } else if (assertion.result === undefined) { + xml.push(''); + } + + xml.push(''); } - xml.push(''); + xml.push(''); } - - xml.push(''); } - } - return xml.join('\n'); - }; - } + return xml.join('\n'); + }; + } + }); diff --git a/src/commands/math/commands.ts b/src/commands/math/commands.ts index 72fbf038..9f2a8b4b 100644 --- a/src/commands/math/commands.ts +++ b/src/commands/math/commands.ts @@ -248,6 +248,139 @@ LatexCmds.superscript = } }; +// Ion notation: a superscript-only command carrying an editable charge +// block followed by a fixed sign — `\ion[+]{2}` renders as the "2+" of a +// 2+ cation. `\positiveion` / `\negativeion` are sign-bound shorthands. +// Restored for @openwebwork/mathquill; the chemistry answer-entry toolbars +// (chemQuillChemistry.pl) drive these commands. +class Ion extends SupSub { + sign: '+' | '-'; + + constructor(sign?: string) { + super(); + this.sign = sign === '-' ? '-' : '+'; + this.supsub = 'sup'; + this.htmlTemplate = + '' + + '' + + '&0' + + `${this.sign}` + + ''; + } + + latex() { + return `\\ion[${this.sign}]{${this.sup?.latex() || '1'}}`; + } + + text() { + return `^(${this.sign}${this.sup?.text() || '1'})`; + } + + mathspeak() { + const charge = (this.sup ? getCtrlSeqsFromBlock(this.sup) : '') || '1'; + return `charge ${charge} ${this.sign === '-' ? 'negative' : 'positive'}`; + } + + parser() { + return latexMathParser.optBlock + .then((optBlock: MathBlock) => { + return latexMathParser.block.map((block: MathBlock) => { + const ion = new Ion(optBlock.text() === '-' ? '-' : '+'); + ion.blocks = [block]; + block.adopt(ion); + return ion; + }); + }) + .or(super.parser()); + } + + finalizeTree() { + this.upInto = this.sup = this.ends.right; + if (this.sup) this.sup.downOutOf = insLeftOfMeUnlessAtEnd; + super.finalizeTree(); + } +} + +LatexCmds.ion = Ion; + +LatexCmds.positiveion = class extends Ion { + constructor() { + super('+'); + } +}; + +LatexCmds.negativeion = class extends Ion { + constructor() { + super('-'); + } +}; + +// Scientific notation: a superscript-only command rendering a fixed `×10` +// followed by an editable exponent block — `\sci{8}` renders as the "×10⁸" +// of 3.5×10⁸. Restored for @openwebwork/mathquill; the scientific-notation +// answer-entry toolbar (chemQuillMath.pl) drives this command. text() +// serializes the explicit operations (`*10^8`), not E-notation, so the +// multiplication and power that scientific notation denotes survive into +// the answer string. +class ScientificNotation extends SupSub { + constructor() { + super(); + this.supsub = 'sup'; + this.htmlTemplate = + '×' + + '10' + + '' + + '&0' + + ''; + } + + latex() { + return `\\sci{${this.sup?.latex() || '0'}}`; + } + + text() { + return `*10^${this.sup?.text() || '0'}`; + } + + mathspeak() { + const raw = (this.sup ? getCtrlSeqsFromBlock(this.sup) : '') || '0'; + const intMatch = /^([+-]?)(\d+)$/.exec(raw); + if (intMatch) { + const sign = intMatch[1] === '-' ? 'negative ' : ''; + const digits = intMatch[2]; + let suffix = 'th'; + if (!/^(?:11|12|13)$/.test(digits.slice(-2))) { + const last = digits.slice(-1); + if (last === '1') suffix = 'st'; + else if (last === '2') suffix = 'nd'; + else if (last === '3') suffix = 'rd'; + } + return `times ten to the ${sign}${digits}${suffix} power`; + } + // Non-integer exponent — speak the block contents generically. + return `times ten to the ${this.sup?.mathspeak().trim() || '0'} power`; + } + + parser() { + return latexMathParser.block + .map((block: MathBlock) => { + const sci = new ScientificNotation(); + sci.blocks = [block]; + block.adopt(sci); + return sci; + }) + .or(super.parser()); + } + + finalizeTree() { + this.upInto = this.sup = this.ends.right; + if (this.sup) this.sup.downOutOf = insLeftOfMeUnlessAtEnd; + super.finalizeTree(); + } +} + +LatexCmds.sci = ScientificNotation; + class SummationNotation extends UpperLowerLimitCommand { constructor(ch: string, html: string, ariaLabel?: string) { super( diff --git a/src/css/toolbar.scss b/src/css/toolbar.scss index 3e73c406..ae1625b5 100644 --- a/src/css/toolbar.scss +++ b/src/css/toolbar.scss @@ -63,10 +63,24 @@ transform: translateY(0); } + // Schematic icons built from \text{ } placeholders keep the supsub tiny. + // But an icon whose super/subscript carries a real glyph (e.g. the ion + // charge \text{ }^{+}) must let that glyph show rather than be clipped to + // the placeholder size — so size the supsub to its content. .mq-supsub { - height: 6px; - width: 6px; + min-width: 6px; margin-left: 2px; } + + // Real (non-placeholder) glyphs inside an icon's super/subscript — the + // ion +/- charge, etc. Render them small enough to sit in the button but + // large enough to read. + .mq-sup, + .mq-sub { + & > .mq-binary-operator, + & > .mq-unary-operator { + font-size: 0.7em; + } + } } } diff --git a/test/aria.test.ts b/test/aria.test.ts index 3ae1e191..e7ba1cc2 100644 --- a/test/aria.test.ts +++ b/test/aria.test.ts @@ -304,4 +304,31 @@ suite('aria', function () { staticMath.latex('2+2'); assert.equal(staticMath.__controller.mathspeakSpan?.textContent, 'Static Label: 2 plus 2'); }); + + test('mathspeak for ion and scientific notation commands', function () { + const staticSpan = document.createElement('span'); + document.getElementById('mock')?.append(staticSpan); + // Pin supSubsRequireOperand off so the commands' mathspeak is tested in + // isolation: with it on, a standalone (operand-less) SupSub-derived + // command unwraps to just its block content, which is a separate concern. + const staticMath = MQ.StaticMath(staticSpan, { supSubsRequireOperand: false }); + const speak = (latex: string) => { + staticMath.latex(latex); + return staticMath.__controller.mathspeakSpan?.textContent; + }; + + // Ion charges speak as "charge positive|negative" rather than the + // inherited SupSub "Superscript, ..., Baseline". + assert.equal(speak('\\ion[+]{2}'), 'charge 2 positive'); + assert.equal(speak('\\ion[-]{3}'), 'charge 3 negative'); + assert.equal(speak('\\positiveion{1}'), 'charge 1 positive'); + assert.equal(speak('\\negativeion{2}'), 'charge 2 negative'); + + // Scientific notation speaks the full "times ten to the power". + assert.equal(speak('\\sci{8}'), 'times ten to the 8th power'); + assert.equal(speak('\\sci{-3}'), 'times ten to the negative 3rd power'); + assert.equal(speak('\\sci{23}'), 'times ten to the 23rd power'); + assert.equal(speak('\\sci{11}'), 'times ten to the 11th power'); + assert.equal(speak('\\sci{1}'), 'times ten to the 1st power'); + }); }); diff --git a/test/latex.test.ts b/test/latex.test.ts index 682ffeb2..af6eb06b 100644 --- a/test/latex.test.ts +++ b/test/latex.test.ts @@ -67,6 +67,30 @@ suite('latex', function () { assertParsesLatex('x ^2', 'x^2'); }); + test('ion charges', function () { + // \ion[sign]{charge} — superscript-only with a fixed sign after the block. + assertParsesLatex('\\ion[+]{2}'); + assertParsesLatex('\\ion[-]{3}'); + // Missing optional sign defaults to '+'. + assertParsesLatex('\\ion{2}', '\\ion[+]{2}'); + // Empty charge block defaults to 1 (e.g. Na+). + assertParsesLatex('\\ion[+]{}', '\\ion[+]{1}'); + // \positiveion / \negativeion are sign-bound shorthands; both + // canonicalize to \ion[sign]{...}. + assertParsesLatex('\\positiveion{2}', '\\ion[+]{2}'); + assertParsesLatex('\\negativeion{3}', '\\ion[-]{3}'); + }); + + test('scientific notation', function () { + // \sci{exp} — fixed ×10 followed by a superscript-only exponent block. + assertParsesLatex('\\sci{8}'); + assertParsesLatex('\\sci{-3}'); + // Multi-character exponent block. + assertParsesLatex('\\sci{23}'); + // Empty exponent block defaults to 0. + assertParsesLatex('\\sci{}', '\\sci{0}'); + }); + test('inner groups', function () { assertParsesLatex('a{bc}d', 'abcd'); assertParsesLatex('{bc}d', 'bcd');