feat(editor): F4.6e the e-mail program and e-mail server on a computer's desktop
The oracle MailWindowRun uses Java's e-mail windows as a person does and answers their dialogs. The core sets and saves the program's account, deletes a mail, checks a form as Java's window does, and gives the server its domain, new and removed accounts and the window's own line; the room sends both programs' views and takes their actions; the editor has both windows. Better than Java: a name field for a new account, a user name the server has is refused, deleting hits the folder shown, a new domain goes into the accounts file at once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
// The e-mail program's and the e-mail server's windows against Java's
|
||||
// (tools/java-oracle MailWindowRun) on mail-lan's network: the same server
|
||||
// switched, its domain set, accounts made and removed; the same account set
|
||||
// in the program, mails written, sent, fetched, answered and deleted — the
|
||||
// same rows, previews, files and lines after each. What Java's window checks
|
||||
// before it acts (a form's addresses, a user name), the editor's panel checks
|
||||
// with the core's functions; here they are compared with Java's dialogs.
|
||||
// Named where the windows are better than Java's:
|
||||
// - a user name the server has already is refused (Java's window says it
|
||||
// made it),
|
||||
// - deleting deletes the mail chosen in the folder shown (Java's deleted a
|
||||
// mail of the other folder, where one was chosen before),
|
||||
// - a new domain is written into the accounts file at once (Java writes it
|
||||
// with the next change of the accounts).
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { accountOfForm, mailFormProblem, NetworkRuntimeEngine, splitName, VirtualFileSystem, withSnapshot, type AccountForm, type MailForm } from '@mdg-filius/core';
|
||||
import { inboxRow, replyForm, sentRow } from '@mdg-filius/protocol';
|
||||
import { snapshotOf, type Scenario } from '../../core/test/scenario.js';
|
||||
|
||||
interface JavaStep {
|
||||
readonly step: string;
|
||||
readonly [field: string]: unknown;
|
||||
}
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const java = JSON.parse(readFileSync(join(here, 'mailWindow.java.json'), 'utf8')) as { readonly steps: readonly JavaStep[] };
|
||||
const scenario = JSON.parse(readFileSync(join(here, '../../core/test/scenarios/mail-lan.scenario.json'), 'utf8')) as Scenario;
|
||||
const CLIENT = '00000000-0000-0000-0000-000000000201';
|
||||
const SERVER = '00000000-0000-0000-0000-000000000203';
|
||||
const step = (name: string) => java.steps.find((each) => each.step === name)!;
|
||||
|
||||
/** Java's window's placeholders for a new account's names (emailserver_msg21, msg20). */
|
||||
const PLACEHOLDER = { firstName: 'Vorname', lastName: 'Nachname' };
|
||||
|
||||
function setUp() {
|
||||
const engine = new NetworkRuntimeEngine(scenario.seed);
|
||||
const serverFiles = new VirtualFileSystem();
|
||||
const clientFiles = new VirtualFileSystem();
|
||||
const snapshot = withSnapshot(snapshotOf(scenario), {
|
||||
emailServersByNodeId: new Map([[SERVER, { domain: 'filius.de', accounts: [], isActive: false }]]),
|
||||
emailClientsByNodeId: new Map([[CLIENT, { account: null, inbox: [], sent: [], drafts: [], addressBook: [], nextMessageID: 1 }]]),
|
||||
fileSystemsByNodeId: new Map([
|
||||
[SERVER, serverFiles],
|
||||
[CLIENT, clientFiles],
|
||||
]),
|
||||
});
|
||||
engine.handle({ type: 'start', snapshot, seed: scenario.seed, initialTimeMilliseconds: 0 });
|
||||
const run = (milliseconds: number) => engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + milliseconds });
|
||||
return { engine, server: engine.emailServer(SERVER)!, client: engine.emailClient(CLIENT)!, serverFiles, clientFiles, run };
|
||||
}
|
||||
|
||||
/** The server's window as Java's shows it: button, domain field, rows of address and number of mails. */
|
||||
function serverWindow(context: ReturnType<typeof setUp>) {
|
||||
const { server } = context;
|
||||
return {
|
||||
button: server.isActive ? 'Anhalten' : 'Starten',
|
||||
domain: server.domain,
|
||||
domainEnabled: !server.isActive,
|
||||
accounts: server.state.accounts.map((account) => [`${account.username}@${server.domain}`, String(account.mailbox.length)]),
|
||||
accountsFile: context.serverFiles.textFile('/mailserver/konten.txt'),
|
||||
};
|
||||
}
|
||||
|
||||
const javaServerWindow = (each: JavaStep) => ({ button: each.button, domain: each.domain, domainEnabled: each.domainEnabled, accounts: each.accounts, accountsFile: each.accountsFile });
|
||||
|
||||
/** What Java's server window checks before it makes an account, as the editor's panel checks it (emailserver_msg25, msg24). */
|
||||
function serverFormProblem(username: string, password: string): 'missing' | 'whiteSpace' | undefined {
|
||||
if (username === '' || password === '') return 'missing';
|
||||
return /[ \t\n\v\f\r]/.test(username) ? 'whiteSpace' : undefined;
|
||||
}
|
||||
|
||||
const ACCOUNT_FORM: AccountForm = {
|
||||
name: 'Anna Maria Muster',
|
||||
email: 'anna@schule.de',
|
||||
pop3Host: '192.168.0.30',
|
||||
pop3Port: '110',
|
||||
smtpHost: '192.168.0.30',
|
||||
smtpPort: '25',
|
||||
username: 'anna',
|
||||
password: 'geheim',
|
||||
};
|
||||
|
||||
const MAIL_FORMS: Record<string, MailForm> = {
|
||||
invalid: { to: 'bob@schule.de, kein', cc: '', bcc: '', subject: 'Falsch', text: 'x' },
|
||||
empty: { to: '', cc: '', bcc: '', subject: 'Leer', text: 'x' },
|
||||
hello: { to: 'bob@schule.de', cc: '', bcc: '', subject: 'Hallo', text: 'Zeile 1\nZeile 2' },
|
||||
self: { to: 'anna@schule.de', cc: '', bcc: '', subject: 'Selbst', text: 'Erste Zeile\nZweite Zeile' },
|
||||
};
|
||||
|
||||
describe("the e-mail windows, against Java's", () => {
|
||||
it("switch the server, set its domain and make its accounts as Java's window does", () => {
|
||||
const context = setUp();
|
||||
const { server } = context;
|
||||
expect(serverWindow(context)).toEqual(javaServerWindow(step('server:open')));
|
||||
expect(server.setDomain('ungültig domain!')).toBe(false);
|
||||
expect(serverWindow(context)).toEqual(javaServerWindow(step('server:domain ungültig domain!')));
|
||||
expect(server.setDomain('schule.de')).toBe(true);
|
||||
expect(serverWindow(context)).toEqual({ ...javaServerWindow(step('server:domain schule.de')), accountsFile: '' });
|
||||
server.setActive(true, true);
|
||||
expect(serverWindow(context)).toEqual(javaServerWindow(step('server:start')));
|
||||
expect(server.log.all.map((line) => line.kind)).toEqual(['switched']);
|
||||
const dialogs: Record<string, string> = {
|
||||
missing: 'Geben Sie einen Benutzernamen und ein Passwort ein!',
|
||||
whiteSpace: 'Der Benutzername darf keine Leerzeichen enthalten!',
|
||||
};
|
||||
for (const [username, password] of [['anna', 'geheim'], ['', 'x'], ['carl os', 'x'], ['ANNA', 'anders'], ['bob', ''], ['bob', 'pw']] as const) {
|
||||
const recorded = step(`server:add ${username}/${password}`);
|
||||
const problem = serverFormProblem(username, password);
|
||||
if (problem !== undefined) {
|
||||
expect(recorded.dialogs, username).toEqual([dialogs[problem]]);
|
||||
} else {
|
||||
const made = server.addAccount({ username, password, ...PLACEHOLDER });
|
||||
// Java's window says it made "ANNA"; the server has "anna" already and takes nothing.
|
||||
expect(recorded.dialogs).toEqual([`Das Benutzerkonto ${username} wurde angelegt.`]);
|
||||
expect(made, username).toBe(username !== 'ANNA');
|
||||
}
|
||||
expect(serverWindow(context), username).toEqual(javaServerWindow(recorded));
|
||||
}
|
||||
});
|
||||
|
||||
it("set the program's account, write and send its mails, fetch, answer and delete them as Java's window does", () => {
|
||||
const context = setUp();
|
||||
const { server, client, clientFiles, run } = context;
|
||||
server.setDomain('schule.de');
|
||||
server.setActive(true, true);
|
||||
server.addAccount({ username: 'anna', password: 'geheim', ...PLACEHOLDER });
|
||||
server.addAccount({ username: 'bob', password: 'pw', ...PLACEHOLDER });
|
||||
expect(client.account).toBeNull();
|
||||
|
||||
for (const [username, port] of [['an na', '110'], ['anna', '110x'], ['anna', '110']] as const) {
|
||||
const recorded = step(`client:account ${username} ${port}`);
|
||||
const account = accountOfForm({ ...ACCOUNT_FORM, username, pop3Port: port });
|
||||
if (account === undefined) expect(recorded.dialogs).toEqual(['Die Angaben sind entweder fehlerhaft oder unvollständig!']);
|
||||
else client.setAccount(account);
|
||||
expect(clientFiles.contains('/konten.txt') ? clientFiles.textFile('/konten.txt') : null, username + port).toBe(recorded.accountFile);
|
||||
}
|
||||
// Opened again, the dialog shows the account: first and last name with a space between.
|
||||
const shown = client.account!;
|
||||
expect([`${shown.firstName} ${shown.lastName}`, shown.email, shown.pop3Host, String(shown.pop3Port), shown.smtpHost, String(shown.smtpPort), shown.username, shown.password]).toEqual(
|
||||
step('client:account shown').fields,
|
||||
);
|
||||
|
||||
const problems: Record<string, string> = {
|
||||
invalidAddress: 'Mindestens eine eingegebene E-Mail-Adresse ist nicht korrekt!',
|
||||
noRecipient: 'Sie müssen mindestens eine Empfänger-Adresse eingeben!',
|
||||
};
|
||||
for (const form of Object.values(MAIL_FORMS)) {
|
||||
const name = form.subject;
|
||||
const recorded = step(`client:send ${name}`);
|
||||
const problem = mailFormProblem(form, true);
|
||||
if (problem !== undefined) {
|
||||
expect(recorded.dialogs, name).toEqual([problems[problem]]);
|
||||
// The compose window stays open with what was typed.
|
||||
expect([recorded.composing, recorded.composeFields, recorded.composeText, recorded.composeSender]).toEqual([true, [form.to, form.cc, form.bcc, form.subject], form.text, 'anna']);
|
||||
} else {
|
||||
expect(client.send(form)).toBeUndefined();
|
||||
run(20_000);
|
||||
// Sent: the compose window closes, the window shows the sent mails.
|
||||
expect([recorded.composing, recorded.tab]).toEqual([false, 1]);
|
||||
}
|
||||
expect(client.sent.map((entry) => sentRow(entry.mail)), name).toEqual(recorded.sent);
|
||||
}
|
||||
expect(serverWindow(context)).toEqual(javaServerWindow(step('server:after mails')));
|
||||
|
||||
client.fetch();
|
||||
run(20_000);
|
||||
const fetched = step('client:fetch');
|
||||
expect([client.inbox.map((entry) => inboxRow(entry.mail)), fetched.tab]).toEqual([fetched.inbox, 0]);
|
||||
expect(client.inbox[0]!.mail.text).toBe(step('client:choose inbox 0').preview);
|
||||
const answer = replyForm(client.inbox[0]!.mail, { re: 'RE:', wrote: 'schrieb:' });
|
||||
const answered = step('client:answer');
|
||||
expect([[answer.to, '', '', answer.subject], answer.text, 'anna']).toEqual([answered.composeFields, answered.composeText, answered.composeSender]);
|
||||
expect(client.sent[0]!.mail.text).toBe(step('client:choose sent 0').preview);
|
||||
expect(serverWindow(context)).toEqual(javaServerWindow(step('server:after fetch')));
|
||||
|
||||
// The mail chosen in the inbox is deleted (Java deleted the sent mail chosen before).
|
||||
client.removeMail('inbox', 0);
|
||||
const deleted = step('client:delete inbox 0');
|
||||
expect(client.inbox).toEqual([]);
|
||||
expect(deleted.inbox).toEqual(fetched.inbox);
|
||||
expect(client.sent.map((entry) => sentRow(entry.mail))).toEqual((fetched.sent as unknown[]).slice());
|
||||
expect(deleted.sent).toEqual((fetched.sent as unknown[]).slice(1));
|
||||
// A row it has not: nothing.
|
||||
client.removeMail('sent', 5);
|
||||
client.removeMail('sent', -1);
|
||||
expect(client.sent).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("remove an account, stop the server and set its domain while it is off, as Java's window does", () => {
|
||||
const context = setUp();
|
||||
const { server } = context;
|
||||
server.setDomain('schule.de');
|
||||
server.setActive(true, true);
|
||||
server.addAccount({ username: 'anna', password: 'geheim', ...PLACEHOLDER });
|
||||
server.addAccount({ username: 'bob', password: 'pw', ...PLACEHOLDER });
|
||||
expect(step('server:delete row 1').dialogs).toEqual(["Wollen Sie das Konto 'bob' wirklich entfernen?"]);
|
||||
expect(server.removeAccount('bob')).toBe(true);
|
||||
expect(server.removeAccount('bob')).toBe(false);
|
||||
expect(server.removeAccount('ANNA')).toBe(false);
|
||||
expect(serverWindow(context)).toEqual(javaServerWindow(step('server:delete row 1')));
|
||||
server.setActive(false, true);
|
||||
expect(serverWindow(context)).toEqual(javaServerWindow(step('server:stop')));
|
||||
expect(server.log.all.map((line) => line.kind)).toEqual(['switched', 'switched']);
|
||||
expect(server.log.all.map((line) => line.kind === 'switched' && line.active)).toEqual([true, false]);
|
||||
expect(server.setDomain('neu.de')).toBe(true);
|
||||
// The new domain goes into the file at once (Java's keeps the old one until the accounts change).
|
||||
const recorded = step('server:domain neu.de');
|
||||
expect(serverWindow(context)).toEqual({ ...javaServerWindow(recorded), accountsFile: 'anna;neu.de;geheim;Nachname;Vorname;\n' });
|
||||
expect(recorded.accountsFile).toBe('anna;schule.de;geheim;Nachname;Vorname;\n');
|
||||
expect(server.setDomain('')).toBe(false);
|
||||
expect(server.domain).toBe('neu.de');
|
||||
});
|
||||
|
||||
it("split a name as Java's account dialog does", () => {
|
||||
expect(splitName('Anna Maria Muster')).toEqual({ firstName: 'Anna Maria', lastName: 'Muster' });
|
||||
expect(splitName('Anna')).toEqual({ firstName: 'Anna', lastName: '' });
|
||||
expect(splitName(' Anna ')).toEqual({ firstName: '', lastName: 'Anna' });
|
||||
expect(splitName('Anna ')).toEqual({ firstName: 'Anna', lastName: '' });
|
||||
expect(splitName(' ')).toEqual({ firstName: '', lastName: '' });
|
||||
expect(accountOfForm({ ...ACCOUNT_FORM, username: '', smtpPort: '65535', pop3Port: '1' })).toMatchObject({ username: '', smtpPort: 65535, pop3Port: 1 });
|
||||
for (const port of ['0', '65536', '', '080', ' 25']) expect(accountOfForm({ ...ACCOUNT_FORM, smtpPort: port }), port).toBeUndefined();
|
||||
expect(accountOfForm({ ...ACCOUNT_FORM, username: 'a.b_c-d9' })).toBeDefined();
|
||||
expect(accountOfForm({ ...ACCOUNT_FORM, username: 'anna@x' })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('deletes a sent mail, sends with the account a mail was written with, and keeps a new domain in the snapshot', () => {
|
||||
const context = setUp();
|
||||
const { engine, server, client, run } = context;
|
||||
server.setDomain('schule.de');
|
||||
expect(engine.state.topologySnapshot.emailServersByNodeId.get(SERVER)!.domain).toBe('schule.de');
|
||||
server.setActive(true, true);
|
||||
server.addAccount({ username: 'anna', password: 'geheim', ...PLACEHOLDER });
|
||||
server.addAccount({ username: 'bob', password: 'pw', ...PLACEHOLDER });
|
||||
client.setAccount(accountOfForm(ACCOUNT_FORM)!);
|
||||
expect(client.send({ ...MAIL_FORMS.hello!, to: 'bob@schule.de, anna@schule.de' })).toBeUndefined();
|
||||
// Changed before the mail goes to its second recipient: it still goes with the account it was written with.
|
||||
client.setAccount(accountOfForm({ ...ACCOUNT_FORM, smtpHost: 'niemand.de' })!);
|
||||
run(40_000);
|
||||
expect(client.sent.map((entry) => entry.mail.subject)).toEqual(['Hallo']);
|
||||
expect(server.state.accounts.map((account) => account.mailbox.length)).toEqual([1, 1]);
|
||||
client.setAccount(accountOfForm(ACCOUNT_FORM)!);
|
||||
expect(client.send(MAIL_FORMS.self!)).toBeUndefined();
|
||||
run(20_000);
|
||||
client.removeMail('sent', 0);
|
||||
expect(client.sent.map((entry) => entry.mail.subject)).toEqual(['Selbst']);
|
||||
expect(engine.state.topologySnapshot.emailClientsByNodeId.get(CLIENT)!.sent.map((message) => message.subject)).toEqual(['Selbst']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user