diff --git a/src/bundles/repl/src/__tests__/stringify_value.test.ts b/src/bundles/repl/src/__tests__/stringify_value.test.ts index 74b29bb146..8767ddda00 100644 --- a/src/bundles/repl/src/__tests__/stringify_value.test.ts +++ b/src/bundles/repl/src/__tests__/stringify_value.test.ts @@ -1,7 +1,7 @@ import { DataType, type TypedValue } from '@sourceacademy/conductor/types'; import { TestDataHandler, booleanValue, emptyListValue, numberValue, stringValue, voidValue } from '@sourceacademy/modules-testplugin'; import { describe, expect, test } from 'vitest'; -import { stringifyReplValue } from '../stringify_value'; +import { hasNothingToDisplay, stringifyReplValue } from '../stringify_value'; describe(stringifyReplValue, () => { test('VOID stringifies as "undefined"', async () => { @@ -103,3 +103,18 @@ describe(stringifyReplValue, () => { expect(result).toContain('...'); }); }); + +describe(hasNothingToDisplay, () => { + test('VOID and EMPTY_LIST (a Python None) have nothing to display', () => { + expect(hasNothingToDisplay(voidValue())).toBe(true); + expect(hasNothingToDisplay(emptyListValue())).toBe(true); + }); + + test.each([ + ['BOOLEAN', booleanValue(false)], + ['NUMBER', numberValue(0)], + ['CONST_STRING', stringValue('')] + ])('a %s value is displayed, even when falsy', (_, value) => { + expect(hasNothingToDisplay(value)).toBe(false); + }); +}); diff --git a/src/bundles/repl/src/index.ts b/src/bundles/repl/src/index.ts index 205c7e43e4..21772a39a2 100644 --- a/src/bundles/repl/src/index.ts +++ b/src/bundles/repl/src/index.ts @@ -54,7 +54,7 @@ import { type ReplSetProgramTextMessage } from './protocol'; import { processRichDisplayContent } from './rich_display'; -import { stringifyReplValue } from './stringify_value'; +import { hasNothingToDisplay, stringifyReplValue } from './stringify_value'; type ReplTabLoader = { tabs: string[]; @@ -368,10 +368,15 @@ export default class ReplModulePlugin extends BaseModulePlugin { } const result = step.value; - const content = result.type === DataType.CONST_STRING - ? result.value - : await stringifyReplValue(this.evaluator, result); - this.__displayOutput({ content, color: COLOR_RUN_CODE_RESULT, outputMethod: 'plaintext' }); + // An evaluator without a result (a Python function that returns None crosses the boundary + // as EMPTY_LIST, see #877) has nothing to echo; showing it as text would print a stray + // "null" after everything the evaluator displayed itself. + if (!hasNothingToDisplay(result)) { + const content = result.type === DataType.CONST_STRING + ? result.value + : await stringifyReplValue(this.evaluator, result); + this.__displayOutput({ content, color: COLOR_RUN_CODE_RESULT, outputMethod: 'plaintext' }); + } } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); this.__displayOutput({ content: message, color: COLOR_ERROR_MESSAGE, outputMethod: 'plaintext' }); diff --git a/src/bundles/repl/src/stringify_value.ts b/src/bundles/repl/src/stringify_value.ts index a54d3ec0bc..b31110a849 100644 --- a/src/bundles/repl/src/stringify_value.ts +++ b/src/bundles/repl/src/stringify_value.ts @@ -26,6 +26,17 @@ const MAX_NODES = 10_000; type Budget = { remaining: number }; +/** + * Whether an evaluator's result is "no value" and the Repl tab should show nothing for the run, + * like Python's REPL does for `None`. A Python `None` crosses the Conductor boundary as + * DataType.EMPTY_LIST (it has no type of its own - see issue #877), and a function without a + * result as DataType.VOID; rendering either as text would show a bare `null` or `undefined` after + * every run of an evaluator that, say, displays its own output. + */ +export function hasNothingToDisplay(value: TypedValue): boolean { + return value.type === DataType.VOID || value.type === DataType.EMPTY_LIST; +} + export async function stringifyReplValue(evaluator: IDataHandler, value: TypedValue): Promise { return stringifyReplValueDepth(evaluator, value, 0, { remaining: MAX_NODES }); }