Zum Inhalt springen
aviral gupta

// B5.5 · ca. 51 Min. · Einstieg

Projekt: eine getestete To-do-CLI

In diesem Projekt schreiben Sie eine To-do-CLI, die zeilenweise getippte Befehle liest, die Liste in einer Klasse mit Events hält und mit node:test belegt, dass sie funktioniert.

Lektion 5 von 5 in B5 Ereignisse, Eingaben und erste Tests

Ende des Moduls

Danach können Sie

  • Eine getippte Zeile in Befehl und Argument zerlegen und das Argument prüfen, bevor Sie es verwenden
  • TodoList als EventEmitter schreiben, der added und done auslöst, und die Ausgabe den Listenern überlassen
  • Die Befehle in einer readline-Schleife ausführen und die Teile und die ganze Sitzung mit node:test testen
  1. Aufwärmen · Aufgabe 1 von 7

    Aufwärmen aus B5.1: Was gibt dieses Programm aus?

    import {EventEmitter} from 'node:events';
    
    const list = new EventEmitter();
    list.on('added', (text) => console.log('added', text));
    console.log('before');
    list.emit('added', 'tea');
    console.log('after');
  2. Vorhersagen · Aufgabe 2 von 7

    Sagen Sie es voraus, bevor Sie weiterlesen. Ein erster Versuch, eine getippte Zeile in Befehl und Text zu zerlegen. Was gibt er aus?

    const line = 'add buy milk';
    const [command, arg] = line.trim().split(' ');
    console.log(arg);
  3. Üben · Aufgabe 3 von 7

    Ergänzen Sie die Klasse, die TodoList erweitert, damit sie on und emit hat.

    export class TodoList extends ____ {
    export class TodoList extends {
  4. Üben · Aufgabe 4 von 7

    Die fertige CLI liest diese Zeilen nacheinander, mit einer leeren Liste am Anfang. Ordnen Sie jeder getippten Zeile zu, was sie nach dem Prompt ausgibt.

  5. Üben · Aufgabe 5 von 7

    Hier werden die beiden 'done'-Listener in umgekehrter Reihenfolge registriert. todo.js enthält die fertige TodoList. Was gibt das Programm aus?

    import {TodoList} from './todo.js';
    
    const list = new TodoList();
    list.on('done', () => {
      if (list.items.every((item) => item.done)) console.log('all done!');
    });
    list.on('done', (item, n) => console.log('done ' + n + ': ' + item.text));
    list.add('tea');
    list.done('1');
  6. Denksport · Aufgabe 6 von 7

    Knobelaufgabe. Diese Schleife registriert ihren Listener in der Schleife. Die Eingabe ist add a, add b und add c, je eine Zeile. Wie viele Zeilen, die mit added beginnen, gibt sie aus?

    import {createInterface} from 'node:readline/promises';
    import {TodoList, parseCommand} from './todo.js';
    
    const list = new TodoList();
    const rl = createInterface({input: process.stdin});
    for await (const line of rl) {
      const {command, arg} = parseCommand(line);
      list.on('added', (item, n) => console.log('added ' + n + ': ' + item.text));
      if (command === 'add') list.add(arg);
    }
  7. Anwenden · Aufgabe 7 von 7

    Mini-Aufgabe. Legen Sie die Schritte 1 bis 3 in todo.js und die Sitzung in main.js ab, mit "type": "module" und einem Skript "test": "node --test" in package.json. Schreiben Sie todo.test.js mit mindestens drei Tests: einem für parseCommand, einem für findTodo mit einer zu großen Nummer und einem, der die done-Events sammelt. Führen Sie npm test aus. Schreiben Sie dann die Befehle einer Sitzung in session.txt und führen Sie node main.js < session.txt aus.

    Prüfen Sie Ihr Ergebnis anhand dieser Liste

Selbst programmieren

Lesen Sie das ausgearbeitete Beispiel und lösen Sie dann die Übungen. Ihr Code läuft in Ihrem Browser oder auf Ihrem Computer und wird nie hochgeladen.

Ausgearbeitetes Beispiel

Dieselbe Form, eine andere Aufgabe: eine Punktetafel

Bevor Sie die To-do-CLI bauen, sehen Sie ihre Form in einem anderen Programm. Scoreboard erweitert EventEmitter und löst 'scored' für jeden Punkt aus und 'lead', wenn jemand die Führung übernimmt; die Listener geben aus. Eine readline-Schleife liest Befehle, fängt Fehler ab und hält bei quit an. Die Befehle kommen über eine Pipe und werden nicht angezeigt: Jede Antwort steht in derselben Zeile wie ihr Prompt.

main.js

import {EventEmitter} from 'node:events';
import {createInterface} from 'node:readline/promises';

// The same shape as the to-do CLI, for another job: keeping score in a game.
class Scoreboard extends EventEmitter {
  constructor() {
    super();
    this.points = new Map(); // name -> points
    this.leader = '';
  }

  point(name) {
    if (name === '') throw new Error('point needs a name, such as point Ada');
    const total = (this.points.get(name) ?? 0) + 1;
    this.points.set(name, total);
    this.emit('scored', name, total);
    if (name !== this.leader && total > (this.points.get(this.leader) ?? 0)) {
      this.leader = name;
      this.emit('lead', name);
    }
  }
}

const board = new Scoreboard();
board.on('scored', (name, total) => console.log(name + ' has ' + total));
board.on('lead', (name) => console.log(name + ' takes the lead'));

const rl = createInterface({input: process.stdin, output: process.stdout});
let open = true;
rl.on('close', () => (open = false));
rl.setPrompt('score> ');
rl.prompt();
for await (const line of rl) {
  const [command = '', name = ''] = line.trim().split(/\s+/);
  if (command === 'quit') break;
  try {
    if (command === 'point') board.point(name);
    else if (command === 'show') console.log([...board.points].map(([who, n]) => who + ' ' + n).join(', '));
    else if (command !== '') console.log('unknown command: ' + command);
  } catch (error) {
    console.log(error.message);
  }
  if (open) rl.prompt();
}
console.log('final leader: ' + (board.leader || 'nobody'));

input.txt

point Ada
point Lin
point Lin
point
show
jump
quit

Ausführen mit

node main.js

Ausgabe

score> Ada has 1
Ada takes the lead
score> Lin has 1
score> Lin has 2
Lin takes the lead
score> point needs a name, such as point Ada
score> Ada 1, Lin 2
score> unknown command: jump
score> final leader: Lin
  • Ein Punkt kann zwei Zeilen ausgeben: Lins zweiter Punkt löste 'scored' aus, dann 'lead'.
  • point ohne Namen warf einen Error; die Schleife fing ihn ab, gab seine Meldung aus und lief weiter.
  • quit beendete die Schleife mit break; die Zeile nach der Schleife gab den Führenden aus.
  • Scoreboard gibt selbst nichts aus, deshalb kann ein Test seine Events zählen, ohne den Bildschirm zu lesen.

Übungen

Übung 1 von 5

Schritt 1: eine getippte Zeile zerlegen

Schreiben Sie parseCommand(line). Entfernen Sie die äußeren Leerzeichen. Der Befehl ist der Text bis zum ersten Leerzeichen, kleingeschrieben; arg ist alles danach, getrimmt, mit seinen inneren Leerzeichen und Großbuchstaben. Eine Zeile ohne Leerzeichen ist ganz Befehl, und arg ist ''. Eine leere Zeile ergibt {command: '', arg: ''}. So ergibt ' Add Call Ada ' das Objekt {command: 'add', arg: 'Call Ada'}.

Tab rückt ein, Umschalt+Tab rückt aus. Um den Editor mit der Tastatur zu verlassen, drücken Sie Esc und dann Tab.

Beim ersten Ausführen lädt Ihr Browser den JavaScript-Runner herunter (bis zu 0.1 MB) und speichert ihn im Cache. Ihr Code läuft in der eigenen Engine Ihres Browsers und bleibt auf Ihrem Gerät.

Hinweise
  1. Hinweis 1

    Beginnen Sie mit const text = line.trim(); dann const space = text.indexOf(' '); das ist -1, wenn es kein Leerzeichen gibt.

  2. Hinweis 2

    Kein Leerzeichen: return {command: text.toLowerCase(), arg: ''}.

  3. Hinweis 3

    Sonst ist text.slice(0, space) der Befehl und text.slice(space + 1).trim() das arg; schreiben Sie nur den Befehl klein.

Eine Lösung zeigen

Ein möglicher Lösungsweg. Ihrer kann anders aussehen und trotzdem alle Prüfungen bestehen.

// Splits a typed line into the command word and the rest:
// 'add buy milk' gives {command: 'add', arg: 'buy milk'}.
export function parseCommand(line) {
  const text = line.trim();
  const space = text.indexOf(' ');
  if (space === -1) return {command: text.toLowerCase(), arg: ''};
  return {command: text.slice(0, space).toLowerCase(), arg: text.slice(space + 1).trim()};
}

console.log(parseCommand('add buy milk'));
console.log(parseCommand('  DONE 2 '));
console.log(parseCommand('list'));
Auf dem eigenen Computer ausführen

Installieren Sie Node.js 24 LTS oder neuer. Speichern Sie diese Dateien in einem Ordner, öffnen Sie dort ein Terminal und führen Sie die Befehle unten aus.

main.js

// Splits a typed line into the command word and the rest:
// 'add buy milk' gives {command: 'add', arg: 'buy milk'}.
export function parseCommand(line) {
  return {command: line, arg: ''};
}

console.log(parseCommand('add buy milk'));
console.log(parseCommand('  DONE 2 '));
console.log(parseCommand('list'));

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {parseCommand} from './main.js';

const check = (line, expected) => {
  const got = parseCommand(line);
  assert.deepEqual(got, expected, 'parseCommand(' + JSON.stringify(line) + ') ergab ' + JSON.stringify(got));
};

test('ein Befehl und sein Text', () => {
  check('add buy milk', {command: 'add', arg: 'buy milk'});
  check('done 2', {command: 'done', arg: '2'});
});

test('äußere Leerzeichen fallen weg, innere bleiben', () => {
  check('  add  buy  milk  ', {command: 'add', arg: 'buy  milk'});
});

test('der Befehl wird kleingeschrieben, der Text nicht', () => {
  check('Add Call Ada', {command: 'add', arg: 'Call Ada'});
  check('DONE 1', {command: 'done', arg: '1'});
});

test('ein einzelnes Wort hat ein leeres arg', () => {
  check('list', {command: 'list', arg: ''});
  check(' QUIT ', {command: 'quit', arg: ''});
});

test('eine leere Zeile ergibt einen leeren Befehl', () => {
  check('   ', {command: '', arg: ''});
});

package.json

{
  "type": "module"
}

package.json sagt Node.js, dass die .js-Dateien Module sind; lassen Sie die Datei im Ordner.

Programm ausführen:

node main.js

Prüfungen ausführen (learnrun.js muss im selben Ordner liegen):

node --test
learnrun.js herunterladen

Übung 2 von 5

Schritt 2: eine Nummer prüfen, die Liste zeigen

Schreiben Sie findTodo(items, arg): Es gibt die Nummer zurück, die arg nennt, wenn arg nur aus Ziffern besteht und zwischen 1 und items.length liegt; sonst wirft es einen RangeError mit der Meldung no to-do "5", das arg in doppelten Anführungszeichen. Schreiben Sie formatTodos(items): eine Zeile pro To-do, 1. [ ] buy milk oder 2. [x] call Ada für ein erledigtes, mit Zeilenumbrüchen verbunden; nothing to do yet für eine leere Liste.

Tab rückt ein, Umschalt+Tab rückt aus. Um den Editor mit der Tastatur zu verlassen, drücken Sie Esc und dann Tab.

Beim ersten Ausführen lädt Ihr Browser den JavaScript-Runner herunter (bis zu 0.1 MB) und speichert ihn im Cache. Ihr Code läuft in der eigenen Engine Ihres Browsers und bleibt auf Ihrem Gerät.

Hinweise
  1. Hinweis 1

    Prüfen Sie zuerst die Form: /^[0-9]+$/.test(arg) ist falsch für '', 'two', '1.5' und '-1'. Number('') ist 0, und Number allein nähme '1.5' an.

  2. Hinweis 2

    Vergleichen Sie dann n = Number(arg) mit 1 und items.length, und werfen Sie new RangeError('no to-do "' + arg + '"').

  3. Hinweis 3

    Bilden Sie in formatTodos jeden Eintrag mit seinem Index ab: (i + 1) + '. [' + (item.done ? 'x' : ' ') + '] ' + item.text, dann join('\n').

Eine Lösung zeigen

Ein möglicher Lösungsweg. Ihrer kann anders aussehen und trotzdem alle Prüfungen bestehen.

// The number of an existing to-do, from the text after done: '2' gives 2.
export function findTodo(items, arg) {
  const n = Number(arg);
  if (!/^[0-9]+$/.test(arg) || n < 1 || n > items.length) throw new RangeError('no to-do "' + arg + '"');
  return n;
}

// The list as numbered lines, with [x] for a finished to-do.
export function formatTodos(items) {
  if (items.length === 0) return 'nothing to do yet';
  return items.map((item, i) => (i + 1) + '. [' + (item.done ? 'x' : ' ') + '] ' + item.text).join('\n');
}

const items = [{text: 'buy milk', done: true}, {text: 'call Ada', done: false}];
console.log(formatTodos(items));
console.log(findTodo(items, '2'));
Auf dem eigenen Computer ausführen

Installieren Sie Node.js 24 LTS oder neuer. Speichern Sie diese Dateien in einem Ordner, öffnen Sie dort ein Terminal und führen Sie die Befehle unten aus.

main.js

// The number of an existing to-do, from the text after done: '2' gives 2.
export function findTodo(items, arg) {
  return Number(arg);
}

// The list as numbered lines, with [x] for a finished to-do.
export function formatTodos(items) {
  return items.map((item) => item.text).join('\n');
}

const items = [{text: 'buy milk', done: true}, {text: 'call Ada', done: false}];
console.log(formatTodos(items));
console.log(findTodo(items, '2'));

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {findTodo, formatTodos} from './main.js';

const items = [{text: 'buy milk', done: true}, {text: 'call Ada', done: false}];

test('findTodo gibt die Nummer eines vorhandenen To-dos zurück', () => {
  assert.equal(findTodo(items, '1'), 1, "findTodo(items, '1') sollte die Zahl 1 sein");
  assert.equal(findTodo(items, '2'), 2, "findTodo(items, '2') sollte die Zahl 2 sein");
});

test('findTodo wirft für jeden anderen Text einen RangeError', () => {
  for (const arg of ['0', '3', 'two', '', '1.5', '-1']) {
    assert.throws(() => findTodo(items, arg), {name: 'RangeError', message: 'no to-do "' + arg + '"'}, 'findTodo(items, ' + JSON.stringify(arg) + ') sollte RangeError: no to-do "' + arg + '" werfen');
  }
});

test('formatTodos nummeriert die To-dos und markiert die erledigten', () => {
  const got = formatTodos(items);
  assert.equal(got, '1. [x] buy milk\n2. [ ] call Ada', 'formatTodos ergab ' + JSON.stringify(got));
});

test('eine leere Liste sagt es', () => {
  assert.equal(formatTodos([]), 'nothing to do yet', 'formatTodos([]) ergab ' + JSON.stringify(formatTodos([])));
});

package.json

{
  "type": "module"
}

package.json sagt Node.js, dass die .js-Dateien Module sind; lassen Sie die Datei im Ordner.

Programm ausführen:

node main.js

Prüfungen ausführen (learnrun.js muss im selben Ordner liegen):

node --test
learnrun.js herunterladen

Übung 3 von 5

Schritt 3: eine Liste, die Events auslöst

Vervollständigen Sie TodoList. add(text) wirft bei leerem Text einen Error mit der Meldung add needs a text, such as add buy milk; sonst hängt es {text, done: false} an und löst 'added' mit dem Eintrag und seiner Nummer aus. done(arg) ermittelt die Nummer mit findTodo, das bei einer falschen wirft, markiert den Eintrag und löst 'done' mit dem Eintrag und seiner Nummer aus, aber nichts bei einem schon erledigten To-do. Geben Sie in der Klasse nichts aus.

Diese Übung braucht Node.js auf Ihrem Computer (die Browser-Version kann sie nicht ausführen). Dateien und Befehle stehen unten.

Hinweise
  1. Hinweis 1

    In add: if (text === '') throw new Error('add needs a text, such as add buy milk'); dann den Eintrag anhängen und this.emit('added', item, this.items.length).

  2. Hinweis 2

    In done: const n = findTodo(this.items, arg); wirft bei einer falschen Nummer selbst, bevor sich etwas ändert. Der Eintrag ist this.items[n - 1].

  3. Hinweis 3

    Ist item.done schon true, kehren Sie vor dem emit zurück. Sonst setzen Sie es und rufen this.emit('done', item, n) auf.

Eine Lösung zeigen

Ein möglicher Lösungsweg. Ihrer kann anders aussehen und trotzdem alle Prüfungen bestehen.

import {EventEmitter} from 'node:events';

// Splits a typed line into the command word and the rest:
// 'add buy milk' gives {command: 'add', arg: 'buy milk'}.
export function parseCommand(line) {
  const text = line.trim();
  const space = text.indexOf(' ');
  if (space === -1) return {command: text.toLowerCase(), arg: ''};
  return {command: text.slice(0, space).toLowerCase(), arg: text.slice(space + 1).trim()};
}

// The number of an existing to-do, from the text after done: '2' gives 2.
export function findTodo(items, arg) {
  const n = Number(arg);
  if (!/^[0-9]+$/.test(arg) || n < 1 || n > items.length) throw new RangeError('no to-do "' + arg + '"');
  return n;
}

// The list as numbered lines, with [x] for a finished to-do.
export function formatTodos(items) {
  if (items.length === 0) return 'nothing to do yet';
  return items.map((item, i) => (i + 1) + '. [' + (item.done ? 'x' : ' ') + '] ' + item.text).join('\n');
}

// The list itself. It prints nothing: it reports every change as an event.
export class TodoList extends EventEmitter {
  constructor() {
    super();
    this.items = [];
  }

  // Adds {text, done: false}, then emits 'added' with the item and its number.
  add(text) {
    if (text === '') throw new Error('add needs a text, such as add buy milk');
    const item = {text, done: false};
    this.items.push(item);
    this.emit('added', item, this.items.length);
  }

  // Marks to-do number arg as done, then emits 'done' with the item and its number;
  // a to-do that is done already gives no second event.
  done(arg) {
    const n = findTodo(this.items, arg);
    const item = this.items[n - 1];
    if (item.done) return;
    item.done = true;
    this.emit('done', item, n);
  }
}

const list = new TodoList();
list.on('added', (item, n) => console.log('added ' + n + ': ' + item.text));
list.on('done', (item, n) => console.log('done ' + n + ': ' + item.text));
list.add('buy milk');
list.done('1');
Auf dem eigenen Computer ausführen

Installieren Sie Node.js 24 LTS oder neuer. Speichern Sie diese Dateien in einem Ordner, öffnen Sie dort ein Terminal und führen Sie die Befehle unten aus.

main.js

import {EventEmitter} from 'node:events';

// Splits a typed line into the command word and the rest:
// 'add buy milk' gives {command: 'add', arg: 'buy milk'}.
export function parseCommand(line) {
  const text = line.trim();
  const space = text.indexOf(' ');
  if (space === -1) return {command: text.toLowerCase(), arg: ''};
  return {command: text.slice(0, space).toLowerCase(), arg: text.slice(space + 1).trim()};
}

// The number of an existing to-do, from the text after done: '2' gives 2.
export function findTodo(items, arg) {
  const n = Number(arg);
  if (!/^[0-9]+$/.test(arg) || n < 1 || n > items.length) throw new RangeError('no to-do "' + arg + '"');
  return n;
}

// The list as numbered lines, with [x] for a finished to-do.
export function formatTodos(items) {
  if (items.length === 0) return 'nothing to do yet';
  return items.map((item, i) => (i + 1) + '. [' + (item.done ? 'x' : ' ') + '] ' + item.text).join('\n');
}

// The list itself. It prints nothing: it reports every change as an event.
export class TodoList extends EventEmitter {
  constructor() {
    super();
    this.items = [];
  }

  // Adds {text, done: false}, then emits 'added' with the item and its number.
  add(text) {
    this.items.push({text, done: false});
  }

  // Marks to-do number arg as done, then emits 'done' with the item and its number;
  // a to-do that is done already gives no second event.
  done(arg) {
    this.items[Number(arg) - 1].done = true;
  }
}

const list = new TodoList();
list.on('added', (item, n) => console.log('added ' + n + ': ' + item.text));
list.on('done', (item, n) => console.log('done ' + n + ': ' + item.text));
list.add('buy milk');
list.done('1');

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {TodoList} from './main.js';

// Collects every event of a list as [event, number, text, done].
const record = (list) => {
  const events = [];
  list.on('added', (item, n) => events.push(['added', n, item.text, item.done]));
  list.on('done', (item, n) => events.push(['done', n, item.text, item.done]));
  return events;
};

test('add speichert das To-do und löst added mit Eintrag und Nummer aus', () => {
  const list = new TodoList();
  const events = record(list);
  list.add('tea');
  list.add('milk');
  assert.deepEqual(list.items, [{text: 'tea', done: false}, {text: 'milk', done: false}], 'items ist ' + JSON.stringify(list.items));
  assert.deepEqual(events, [['added', 1, 'tea', false], ['added', 2, 'milk', false]], 'die Events waren ' + JSON.stringify(events));
});

test('add mit leerem Text wirft und ändert nichts', () => {
  const list = new TodoList();
  const events = record(list);
  assert.throws(() => list.add(''), {message: 'add needs a text, such as add buy milk'}, "add('') sollte werfen: add needs a text, such as add buy milk");
  assert.equal(list.items.length + events.length, 0, "add('') sollte weder ein To-do speichern noch ein Event auslösen");
});

test('done markiert das To-do und löst done mit Eintrag und Nummer aus', () => {
  const list = new TodoList();
  const events = record(list);
  list.add('tea');
  list.add('milk');
  list.done('2');
  assert.deepEqual(events.slice(2), [['done', 2, 'milk', true]], 'nach done 2 waren die Events ' + JSON.stringify(events.slice(2)));
  assert.equal(list.items[0].done, false, 'done 2 sollte tea unerledigt lassen');
});

test('ein zweites done für dasselbe To-do löst nichts aus', () => {
  const list = new TodoList();
  const events = record(list);
  list.add('tea');
  list.done('1');
  list.done('1');
  assert.equal(events.filter((e) => e[0] === 'done').length, 1, 'zweimal done 1 sollte done einmal auslösen');
});

test('done mit falscher Nummer wirft einen RangeError und ändert nichts', () => {
  const list = new TodoList();
  list.add('tea');
  for (const arg of ['0', '2', 'x', '']) {
    assert.throws(() => list.done(arg), {name: 'RangeError', message: 'no to-do "' + arg + '"'}, 'done(' + JSON.stringify(arg) + ') sollte RangeError: no to-do "' + arg + '" werfen');
  }
  assert.equal(list.items[0].done, false, 'tea sollte noch unerledigt sein');
});

package.json

{
  "type": "module"
}

package.json sagt Node.js, dass die .js-Dateien Module sind; lassen Sie die Datei im Ordner.

Programm ausführen:

node main.js

Prüfungen ausführen (learnrun.js muss im selben Ordner liegen):

node --test
learnrun.js herunterladen

Übung 4 von 5

Schritt 4: die Sitzung

main.js führt die Sitzung; todo.js enthält die Schritte 1 bis 3. Registrieren Sie die Listener: added 1: tea, done 1: tea, dann all done!, sobald alle To-dos erledigt sind. In der Schleife: quit beendet sie mit break; add, done und list laufen (list gibt formatTodos aus); ein Fehler gibt seine Meldung aus; ein unbekanntes Wort gibt unknown command: fly aus; eine leere Zeile tut nichts. Geben Sie nach der Schleife bye: 1 of 2 done aus. Probieren Sie printf 'add tea\nquit\n' | node main.js.

Diese Übung braucht Node.js auf Ihrem Computer (die Browser-Version kann sie nicht ausführen). Dateien und Befehle stehen unten.

Hinweise
  1. Hinweis 1

    Vor der Schleife: list.on('added', (item, n) => console.log('added ' + n + ': ' + item.text)); dasselbe für 'done', dann ein zweiter 'done'-Listener mit list.items.every((item) => item.done).

  2. Hinweis 2

    In der Schleife: if (command === 'quit') break; dann ein try-Block mit if/else if für add, done, list und command !== '', und catch (error) { console.log(error.message); }.

  3. Hinweis 3

    Zählen Sie nach der Schleife die erledigten To-dos mit list.items.filter((item) => item.done).length.

Eine Lösung zeigen

Ein möglicher Lösungsweg. Ihrer kann anders aussehen und trotzdem alle Prüfungen bestehen.

import {createInterface} from 'node:readline/promises';
import {TodoList, parseCommand, formatTodos} from './todo.js';

const list = new TodoList();
list.on('added', (item, n) => console.log('added ' + n + ': ' + item.text));
list.on('done', (item, n) => console.log('done ' + n + ': ' + item.text));
list.on('done', () => {
  if (list.items.every((item) => item.done)) console.log('all done!');
});

const rl = createInterface({input: process.stdin, output: process.stdout});
let open = true; // false once the input has ended: then no more prompts
rl.on('close', () => (open = false));
rl.setPrompt('todo> ');
rl.prompt();
for await (const line of rl) {
  const {command, arg} = parseCommand(line);
  if (command === 'quit') break;
  try {
    if (command === 'add') list.add(arg);
    else if (command === 'done') list.done(arg);
    else if (command === 'list') console.log(formatTodos(list.items));
    else if (command !== '') console.log('unknown command: ' + command);
  } catch (error) {
    console.log(error.message);
  }
  if (open) rl.prompt();
}
const finished = list.items.filter((item) => item.done).length;
console.log('bye: ' + finished + ' of ' + list.items.length + ' done');
Auf dem eigenen Computer ausführen

Installieren Sie Node.js 24 LTS oder neuer. Speichern Sie diese Dateien in einem Ordner, öffnen Sie dort ein Terminal und führen Sie die Befehle unten aus.

main.js

import {createInterface} from 'node:readline/promises';
import {TodoList, parseCommand, formatTodos} from './todo.js';

const list = new TodoList();
// Listen for 'added' and 'done' here, and print what happened.

const rl = createInterface({input: process.stdin, output: process.stdout});
let open = true; // false once the input has ended: then no more prompts
rl.on('close', () => (open = false));
rl.setPrompt('todo> ');
rl.prompt();
for await (const line of rl) {
  const {command, arg} = parseCommand(line);
  // Run the command here: quit, add, done, list, or an unknown one.
  if (open) rl.prompt();
}
// Print the bye line here.

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {runMain} from './learnrun.js';

const session = async (stdin, expected) => {
  const printed = await runMain({stdin});
  assert.equal(printed, expected, 'für die Eingabe ' + JSON.stringify(stdin) + ' gab das Programm aus:\n' + printed);
};

test('add und list geben die nummerierten To-dos aus', async () => {
  await session('add buy milk\nadd call Ada\nlist\nquit\n', 'todo> added 1: buy milk\ntodo> added 2: call Ada\ntodo> 1. [ ] buy milk\n2. [ ] call Ada\ntodo> bye: 0 of 2 done\n');
});

test('done gibt das To-do aus, und nach dem letzten all done!', async () => {
  await session('add tea\nadd milk\ndone 2\ndone 1\nquit\n', 'todo> added 1: tea\ntodo> added 2: milk\ntodo> done 2: milk\ntodo> done 1: tea\nall done!\ntodo> bye: 2 of 2 done\n');
});

test('Fehler geben eine Meldung aus, und die Sitzung geht weiter', async () => {
  await session('done 1\nadd\nfly away\n\nadd tea\nquit\n', 'todo> no to-do "1"\ntodo> add needs a text, such as add buy milk\ntodo> unknown command: fly\ntodo> todo> added 1: tea\ntodo> bye: 0 of 1 done\n');
});

test('quit beendet das Lesen, ebenso das Ende der Eingabe', async () => {
  await session('add tea\nquit\nadd milk\n', 'todo> added 1: tea\ntodo> bye: 0 of 1 done\n');
  await session('add tea\ndone 1', 'todo> added 1: tea\ntodo> done 1: tea\nall done!\nbye: 1 of 1 done\n');
});

todo.js

import {EventEmitter} from 'node:events';

// Splits a typed line into the command word and the rest:
// 'add buy milk' gives {command: 'add', arg: 'buy milk'}.
export function parseCommand(line) {
  const text = line.trim();
  const space = text.indexOf(' ');
  if (space === -1) return {command: text.toLowerCase(), arg: ''};
  return {command: text.slice(0, space).toLowerCase(), arg: text.slice(space + 1).trim()};
}

// The number of an existing to-do, from the text after done: '2' gives 2.
export function findTodo(items, arg) {
  const n = Number(arg);
  if (!/^[0-9]+$/.test(arg) || n < 1 || n > items.length) throw new RangeError('no to-do "' + arg + '"');
  return n;
}

// The list as numbered lines, with [x] for a finished to-do.
export function formatTodos(items) {
  if (items.length === 0) return 'nothing to do yet';
  return items.map((item, i) => (i + 1) + '. [' + (item.done ? 'x' : ' ') + '] ' + item.text).join('\n');
}

// The list itself. It prints nothing: it reports every change as an event.
export class TodoList extends EventEmitter {
  constructor() {
    super();
    this.items = [];
  }

  // Adds {text, done: false}, then emits 'added' with the item and its number.
  add(text) {
    if (text === '') throw new Error('add needs a text, such as add buy milk');
    const item = {text, done: false};
    this.items.push(item);
    this.emit('added', item, this.items.length);
  }

  // Marks to-do number arg as done, then emits 'done' with the item and its number;
  // a to-do that is done already gives no second event.
  done(arg) {
    const n = findTodo(this.items, arg);
    const item = this.items[n - 1];
    if (item.done) return;
    item.done = true;
    this.emit('done', item, n);
  }
}

package.json

{
  "type": "module"
}

package.json sagt Node.js, dass die .js-Dateien Module sind; lassen Sie die Datei im Ordner.

Programm ausführen:

node main.js

Prüfungen ausführen (learnrun.js muss im selben Ordner liegen):

node --test
learnrun.js herunterladen

Übung 5 von 5

Schritt 5: Prüfungen, die eine kaputte Liste erkennen

Schreiben Sie die Zusicherungen eines Tests als Funktion, wie in B5.3: checkTodoList(List) bekommt eine TodoList-Klasse, erzeugt eine Liste, sammelt ihre Events und muss einen AssertionError werfen, wenn die Klasse eine Regel bricht. Die Regeln: add('tea'), add('milk') lösen added mit den Nummern 1 und 2 aus; zweimal done('2') löst done einmal aus, mit 2 und milk; done('5') wirft einen RangeError. Die Tests prüfen Ihre Funktion mit der echten Klasse und mit kaputten.

Diese Übung braucht Node.js auf Ihrem Computer (die Browser-Version kann sie nicht ausführen). Dateien und Befehle stehen unten.

Hinweise
  1. Hinweis 1

    Erst sammeln, dann handeln: const events = []; list.on('added', (item, n) => events.push('added ' + n + ' ' + item.text)); und dasselbe für 'done'.

  2. Hinweis 2

    Nach den beiden add: assert.deepEqual(events, ['added 1 tea', 'added 2 milk'], '…'). Nach zweimal done('2') muss events.slice(2) ['done 2 milk'] sein.

  3. Hinweis 3

    Zuletzt: assert.throws(() => list.done('5'), RangeError, '…'). Übergeben Sie eine Funktion, damit throws den Fehler abfangen kann.

Eine Lösung zeigen

Ein möglicher Lösungsweg. Ihrer kann anders aussehen und trotzdem alle Prüfungen bestehen.

import assert from 'node:assert/strict';
import {TodoList} from './todo.js';

// Throws an AssertionError when the TodoList class List breaks one of its rules.
export function checkTodoList(List) {
  const list = new List();
  const events = [];
  list.on('added', (item, n) => events.push('added ' + n + ' ' + item.text));
  list.on('done', (item, n) => events.push('done ' + n + ' ' + item.text));

  list.add('tea');
  list.add('milk');
  assert.deepEqual(events, ['added 1 tea', 'added 2 milk'], 'add should emit added with the number and the item');
  list.done('2');
  list.done('2');
  assert.deepEqual(events.slice(2), ['done 2 milk'], 'done should emit done once, with the number and the item');
  assert.throws(() => list.done('5'), RangeError, "done('5') should throw a RangeError");
}

checkTodoList(TodoList);
console.log('the real TodoList passed');
Auf dem eigenen Computer ausführen

Installieren Sie Node.js 24 LTS oder neuer. Speichern Sie diese Dateien in einem Ordner, öffnen Sie dort ein Terminal und führen Sie die Befehle unten aus.

main.js

import assert from 'node:assert/strict';
import {TodoList} from './todo.js';

// Throws an AssertionError when the TodoList class List breaks one of its rules.
export function checkTodoList(List) {
  const list = new List();
  list.add('tea');
  assert.equal(list.items.length, 1, 'add should store the to-do');
  // Add the rules: the added events, one done event, a RangeError for a wrong number.
}

checkTodoList(TodoList);
console.log('the real TodoList passed');

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {TodoList, findTodo} from './todo.js';
import {checkTodoList} from './main.js';

// Broken lists, one bug each.
class SilentAdd extends TodoList {
  add(text) {
    this.items.push({text, done: false});
  }
}
class FromZero extends TodoList {
  add(text) {
    const item = {text, done: false};
    this.items.push(item);
    this.emit('added', item, this.items.length - 1);
  }
}
class DoneTwice extends TodoList {
  done(arg) {
    const n = findTodo(this.items, arg);
    this.items[n - 1].done = true;
    this.emit('done', this.items[n - 1], n);
  }
}
class NoRangeError extends TodoList {
  done(arg) {
    try {
      super.done(arg);
    } catch {}
  }
}
const catches = (List) => {
  try {
    checkTodoList(List);
  } catch (error) {
    return error.name;
  }
  return 'nothing';
};

test('Ihre Prüfung akzeptiert die echte TodoList', () => {
  checkTodoList(TodoList);
});

test('Ihre Prüfung erkennt ein add, das nichts auslöst', () => {
  assert.equal(catches(SilentAdd), 'AssertionError', 'checkTodoList(SilentAdd) sollte einen AssertionError werfen');
});

test('Ihre Prüfung erkennt Nummern, die bei 0 beginnen', () => {
  assert.equal(catches(FromZero), 'AssertionError', 'checkTodoList(FromZero) sollte einen AssertionError werfen');
});

test('Ihre Prüfung erkennt ein doppelt ausgelöstes done', () => {
  assert.equal(catches(DoneTwice), 'AssertionError', 'checkTodoList(DoneTwice) sollte einen AssertionError werfen');
});

test('Ihre Prüfung erkennt eine falsche Nummer, die nicht wirft', () => {
  assert.equal(catches(NoRangeError), 'AssertionError', 'checkTodoList(NoRangeError) sollte einen AssertionError werfen');
});

todo.js

import {EventEmitter} from 'node:events';

// Splits a typed line into the command word and the rest:
// 'add buy milk' gives {command: 'add', arg: 'buy milk'}.
export function parseCommand(line) {
  const text = line.trim();
  const space = text.indexOf(' ');
  if (space === -1) return {command: text.toLowerCase(), arg: ''};
  return {command: text.slice(0, space).toLowerCase(), arg: text.slice(space + 1).trim()};
}

// The number of an existing to-do, from the text after done: '2' gives 2.
export function findTodo(items, arg) {
  const n = Number(arg);
  if (!/^[0-9]+$/.test(arg) || n < 1 || n > items.length) throw new RangeError('no to-do "' + arg + '"');
  return n;
}

// The list as numbered lines, with [x] for a finished to-do.
export function formatTodos(items) {
  if (items.length === 0) return 'nothing to do yet';
  return items.map((item, i) => (i + 1) + '. [' + (item.done ? 'x' : ' ') + '] ' + item.text).join('\n');
}

// The list itself. It prints nothing: it reports every change as an event.
export class TodoList extends EventEmitter {
  constructor() {
    super();
    this.items = [];
  }

  // Adds {text, done: false}, then emits 'added' with the item and its number.
  add(text) {
    if (text === '') throw new Error('add needs a text, such as add buy milk');
    const item = {text, done: false};
    this.items.push(item);
    this.emit('added', item, this.items.length);
  }

  // Marks to-do number arg as done, then emits 'done' with the item and its number;
  // a to-do that is done already gives no second event.
  done(arg) {
    const n = findTodo(this.items, arg);
    const item = this.items[n - 1];
    if (item.done) return;
    item.done = true;
    this.emit('done', item, n);
  }
}

package.json

{
  "type": "module"
}

package.json sagt Node.js, dass die .js-Dateien Module sind; lassen Sie die Datei im Ordner.

Programm ausführen:

node main.js

Prüfungen ausführen (learnrun.js muss im selben Ordner liegen):

node --test
learnrun.js herunterladen

Häufige Fehler

Die Zeilen mit for statt for await lesen

import {createInterface} from 'node:readline/promises';

const rl = createInterface({input: process.stdin, output: process.stdout});
for (const line of rl) {
  console.log('you typed', line);
}

Was Node.js ausgibt

TypeError: rl is not iterable

Warum, und die Lösung

Eine readline-Schnittstelle liefert ihre Zeilen, sobald sie ankommen, deshalb lässt sie sich nur mit for await lesen, das auf jede Zeile wartet. Ein einfaches for … of braucht ein Array oder ein anderes synchrones Iterable und bricht sofort ab. Schreiben Sie for await (const line of rl), auf oberster Ebene eines Moduls oder in einer async-Funktion.

Die getippte Nummer als Index verwenden

const items = [{text: 'buy milk', done: false}];

// 'done 1' gives arg '1'
function markDone(arg) {
  const item = items[arg];
  item.done = true;
  return item;
}

console.log(markDone('1'));

Was Node.js ausgibt

TypeError: Cannot set properties of undefined (setting 'done')

Warum, und die Lösung

Menschen zählen ab 1, Arrays ab 0. done 1 ergibt den Text '1', und items['1'] ist der zweite Eintrag, hier keiner, also ist item undefined. Machen Sie aus dem Text zuerst eine geprüfte Nummer und ziehen Sie dann 1 ab: const n = findTodo(items, arg); const item = items[n - 1];. Bei zwei To-dos würde der Fehler gar nicht abstürzen, sondern still das falsche markieren.

'error' auslösen, ohne dass jemand zuhört

import {EventEmitter} from 'node:events';

class TodoList extends EventEmitter {
  constructor() {
    super();
    this.items = [];
  }

  done(arg) {
    const n = Number(arg);
    if (!(n >= 1 && n <= this.items.length)) {
      this.emit('error', new RangeError('no to-do "' + arg + '"'));
      return;
    }
    this.items[n - 1].done = true;
  }
}

const list = new TodoList();
list.done('5');
console.log('the session goes on');

Was Node.js ausgibt

RangeError: no to-do "5"

Warum, und die Lösung

Ein 'error'-Event ist besonders (B5.1): Ohne 'error'-Listener wirft emit den Fehler, und da ihn hier niemand abfängt, endet das Programm. Registrieren Sie entweder list.on('error', (error) => console.log(error.message)) vor der Schleife, oder werfen Sie den RangeError aus done, wie die CLI es tut, damit der try/catch in der Schleife ihn wie jeden anderen Fehler behandelt.

JavaScript im Browser: die eigene Engine Ihres Browsers in einem abgeschotteten Worker. Syntaxfehler findet acorn 8.18.0, MIT. Lizenz und Quellcode

Abschlussquiz

5 Fragen, ohne Hinweise. Ab 80 % ist die Lektion abgeschlossen.

Erledigen Sie zuerst alle Aufgaben oben, um das Abschlussquiz freizuschalten.

Problem melden

Etwas ist falsch oder unklar? Beschreiben Sie es kurz, dann wird es geprüft und korrigiert.

#

Mindestens 20 Zeichen.

Nur, wenn Sie eine Antwort wünschen.

Kernideen

Zerlegen, prüfen, dann handeln

Die CLI hat drei Teile. parseCommand macht aus einer getippten Zeile {command, arg}: 'add buy milk' ergibt {command: 'add', arg: 'buy milk'}. Es trennt nur am ersten Leerzeichen, damit der Text seine inneren Leerzeichen behält, und schreibt den Befehl klein, den Text nicht. findTodo prüft das Argument von done: nur Ziffern, von 1 bis zur Anzahl der To-dos, sonst ein RangeError mit einer lesbaren Meldung. Das sind reine Funktionen: keine Eingabe, keine Ausgabe, leicht zu testen, und sie laufen auch im Browser. Die Klasse TodoList steht mit ihnen in todo.js; main.js verbindet sie nur mit dem Terminal, sodass ein Test todo.js importieren kann, ohne eine Sitzung zu starten.

Die Liste meldet, Listener geben aus

TodoList erweitert EventEmitter und ruft zuerst super() auf. add(text) hängt {text, done: false} an und löst 'added' mit dem Eintrag und seiner Nummer aus; done(arg) markiert den Eintrag und löst 'done' aus, aber nur beim ersten Mal. Die Klasse gibt nichts aus. main.js registriert die Listener: einer gibt added 1: buy milk aus, ein zweiter 'done'-Listener gibt all done! aus, sobald alle To-dos erledigt sind. Listener laufen in der Reihenfolge ihrer Registrierung, bevor add oder done zurückkehrt. Registrieren Sie jeden Listener einmal, vor der Schleife: Ein Listener in der Schleife kommt mit jeder Zeile erneut hinzu.

Eine Schleife, die Befehle liest, und Tests

rl.setPrompt('todo> ') und rl.prompt() schreiben den Prompt; for await liest jede Zeile; quit beendet die Schleife mit break, was die Schnittstelle schließt. Ein Fehler aus add oder done wird in der Schleife abgefangen und ausgegeben, sodass ein falscher Befehl die Sitzung nicht beendet. Endet die Eingabe ohne quit, endet auch die Schleife. Gemessen: Hat die letzte Zeile kein Zeilenende, ist die Schnittstelle beim Verarbeiten schon geschlossen, und rl.prompt() würde werfen; deshalb setzt ein 'close'-Listener ein Flag zurück. Tests prüfen die reinen Funktionen direkt, die Events über ein Array und die ganze Sitzung über runMain({stdin}) oder eine Pipe.

Quellen

Zuletzt geprüft am 4. Oktober 2026