format fixes and unittest additions, translated document title

We also actually use our format utilities to set the title.
This commit is contained in:
modeco80 2024-03-16 18:57:19 -04:00
parent 0cef7194ce
commit 999bdd0809
4 changed files with 62 additions and 44 deletions

View File

@ -1,5 +1,9 @@
import { StringLike } from './StringLike'; import { StringLike } from './StringLike';
function isalpha(char: number) {
return RegExp(/^\p{L}/, 'u').test(String.fromCharCode(char));
}
/// A simple function for formatting strings in a more expressive manner. /// A simple function for formatting strings in a more expressive manner.
/// While JavaScript *does* have string interpolation, it's not a total replacement /// While JavaScript *does* have string interpolation, it's not a total replacement
/// for just formatting strings, and a method like this is better for data independent formatting. /// for just formatting strings, and a method like this is better for data independent formatting.
@ -25,8 +29,8 @@ export function Format(pattern: string, ...args: Array<StringLike>) {
let foundSpecifierEnd = false; let foundSpecifierEnd = false;
// Make sure the specifier is not cut off (the last character of the string) // Make sure the specifier is not cut off (the last character of the string)
if (i + 3 >= pat.length) { if (i + 3 > pat.length) {
throw new Error(`Error in format pattern "${pat}": Cutoff/invalid format specifier`); throw new Error(`Error in format pattern "${pat}": Cutoff/invalid format specifier`);
} }
// Try and find the specifier end ('}'). // Try and find the specifier end ('}').
@ -40,13 +44,14 @@ export function Format(pattern: string, ...args: Array<StringLike>) {
case '{': case '{':
throw new Error(`Error in format pattern "${pat}": Cannot start a format specifier in an existing replacement`); throw new Error(`Error in format pattern "${pat}": Cannot start a format specifier in an existing replacement`);
break;
case ' ': case ' ':
throw new Error(`Error in format pattern "${pat}": Whitespace inside format specifier`); throw new Error(`Error in format pattern "${pat}": Whitespace inside format specifier`);
break;
case '-':
throw new Error(`Error in format pattern "${pat}": Malformed format specifier`);
default: default:
if (isalpha(pat.charCodeAt(j))) throw new Error(`Error in format pattern "${pat}": Malformed format specifier`);
break; break;
} }

View File

@ -12,6 +12,7 @@ import * as bootstrap from 'bootstrap';
import MuteState from './protocol/MuteState.js'; import MuteState from './protocol/MuteState.js';
import { Unsubscribe } from 'nanoevents'; import { Unsubscribe } from 'nanoevents';
import { I18nStringKey, TheI18n } from './i18n.js'; import { I18nStringKey, TheI18n } from './i18n.js';
import { Format } from './format.js';
// Elements // Elements
const w = window as any; const w = window as any;
@ -318,46 +319,39 @@ async function openVM(vm: VM): Promise<void> {
location.hash = vm.id; location.hash = vm.id;
// Create the client // Create the client
VM = new CollabVMClient(vm.url); VM = new CollabVMClient(vm.url);
// Register event listeners // Register event listeners
// An array of nanoevent unsubscribe callbacks. These are called when the VM is closed to cleanup nanoevent state. VM!.on('chat', (username, message) => chatMessage(username, message));
let unsubscribeCallbacks: Unsubscribe[] = []; VM!.on('adduser', (user) => addUser(user));
VM!.on('remuser', (user) => remUser(user));
VM!.on('rename', (oldname, newname, selfrename) => userRenamed(oldname, newname, selfrename));
unsubscribeCallbacks.push(VM!.on('chat', (username, message) => chatMessage(username, message))); VM!.on('renamestatus', (status) => {
unsubscribeCallbacks.push(VM!.on('adduser', (user) => addUser(user))); // TODO: i18n these
unsubscribeCallbacks.push(VM!.on('remuser', (user) => remUser(user))); switch (status) {
unsubscribeCallbacks.push(VM!.on('rename', (oldname, newname, selfrename) => userRenamed(oldname, newname, selfrename))); case 'taken':
unsubscribeCallbacks.push( alert(TheI18n.GetString(I18nStringKey.kError_UsernameTaken));
VM!.on('renamestatus', (status) => { break;
// TODO: i18n these case 'invalid':
switch (status) { alert(TheI18n.GetString(I18nStringKey.kError_UsernameInvalid));
case 'taken': break;
alert(TheI18n.GetString(I18nStringKey.kError_UsernameTaken)); case 'blacklisted':
break; alert(TheI18n.GetString(I18nStringKey.kError_UsernameBlacklisted));
case 'invalid': break;
alert(TheI18n.GetString(I18nStringKey.kError_UsernameInvalid)); }
break; });
case 'blacklisted':
alert(TheI18n.GetString(I18nStringKey.kError_UsernameBlacklisted));
break;
}
})
);
unsubscribeCallbacks.push(VM!.on('turn', (status) => turnUpdate(status)));
unsubscribeCallbacks.push(VM!.on('vote', (status: VoteStatus) => voteUpdate(status)));
unsubscribeCallbacks.push(VM!.on('voteend', () => voteEnd()));
unsubscribeCallbacks.push(VM!.on('votecd', (voteCooldown) => window.alert(TheI18n.GetString(I18nStringKey.kVM_VoteCooldownTimer, voteCooldown))));
unsubscribeCallbacks.push(VM!.on('login', (rank: Rank, perms: Permissions) => onLogin(rank, perms)));
unsubscribeCallbacks.push(
VM!.on('close', () => {
if (!expectedClose) alert(TheI18n.GetString(I18nStringKey.kError_UnexpectedDisconnection));
// Call all the unsubscribe callbacks. VM!.on('turn', (status) => turnUpdate(status));
for (let l of unsubscribeCallbacks) l(); VM!.on('vote', (status: VoteStatus) => voteUpdate(status));
unsubscribeCallbacks = []; VM!.on('voteend', () => voteEnd());
closeVM(); VM!.on('votecd', (voteCooldown) => window.alert(TheI18n.GetString(I18nStringKey.kVM_VoteCooldownTimer, voteCooldown)));
}) VM!.on('login', (rank: Rank, perms: Permissions) => onLogin(rank, perms));
);
VM!.on('close', () => {
if (!expectedClose) alert(TheI18n.GetString(I18nStringKey.kError_UnexpectedDisconnection));
closeVM();
});
// Wait for the client to open // Wait for the client to open
await VM!.WaitForOpen(); await VM!.WaitForOpen();
@ -374,7 +368,7 @@ async function openVM(vm: VM): Promise<void> {
throw new Error('Failed to connect to node'); throw new Error('Failed to connect to node');
} }
// Set the title // Set the title
document.title = vm.id + ' - CollabVM'; document.title = Format("{0} - {1}", vm.id, TheI18n.GetString(I18nStringKey.kGeneric_CollabVM));
// Append canvas // Append canvas
elements.vmDisplay.appendChild(VM!.canvas); elements.vmDisplay.appendChild(VM!.canvas);
// Switch to the VM view // Switch to the VM view
@ -389,7 +383,7 @@ function closeVM() {
// Close the VM // Close the VM
VM.close(); VM.close();
VM = null; VM = null;
document.title = 'CollabVM'; document.title = TheI18n.GetString(I18nStringKey.kGeneric_CollabVM);
turn = -1; turn = -1;
// Remove the canvas // Remove the canvas
elements.vmDisplay.innerHTML = ''; elements.vmDisplay.innerHTML = '';
@ -844,6 +838,8 @@ document.addEventListener('DOMContentLoaded', async () => {
// Initalize the i18n system // Initalize the i18n system
await TheI18n.Init(); await TheI18n.Init();
document.title = TheI18n.GetString(I18nStringKey.kGeneric_CollabVM);
// Load all VMs // Load all VMs
await loadList(); await loadList();

View File

@ -63,6 +63,8 @@ export default class CollabVMClient {
// public events // public events
private publicEmitter: Emitter<CollabVMClientEvents>; private publicEmitter: Emitter<CollabVMClientEvents>;
private unsubscribeCallbacks: Array<Unsubscribe> = [];
constructor(url: string) { constructor(url: string) {
// Save the URL // Save the URL
this.url = url; this.url = url;
@ -434,6 +436,13 @@ export default class CollabVMClient {
// Close the connection // Close the connection
close() { close() {
this.connectedToVM = false; this.connectedToVM = false;
// call all unsubscribe callbacks explicitly
for(let cb of this.unsubscribeCallbacks) {
cb();
}
this.unsubscribeCallbacks = [];
if (this.socket.readyState === WebSocket.OPEN) this.socket.close(); if (this.socket.readyState === WebSocket.OPEN) this.socket.close();
} }
@ -588,6 +597,8 @@ export default class CollabVMClient {
} }
on<E extends keyof CollabVMClientEvents>(event: E, callback: CollabVMClientEvents[E]): Unsubscribe { on<E extends keyof CollabVMClientEvents>(event: E, callback: CollabVMClientEvents[E]): Unsubscribe {
return this.publicEmitter.on(event, callback); let unsub = this.publicEmitter.on(event, callback);
this.unsubscribeCallbacks.push(unsub);
return unsub;
} }
} }

View File

@ -13,6 +13,12 @@ test('a cut off format specifier throws', () => {
}); });
test('a malformed format specifier throws', () => { test('a malformed format specifier throws', () => {
expect(() => Format('a{-0}', 1)).toThrow('Malformed format specifier');
expect(() => Format('a{0-}', 1)).toThrow('Malformed format specifier');
expect(() => Format('a{0ab}', 1)).toThrow('Malformed format specifier');
expect(() => Format('a{ab0ab}', 1)).toThrow('Malformed format specifier');
// Whitespace is not permitted inside a format specifier
expect(() => Format('a{0 }', 1)).toThrow('Whitespace inside format specifier'); expect(() => Format('a{0 }', 1)).toThrow('Whitespace inside format specifier');
expect(() => Format('a{ 0}', 1)).toThrow('Whitespace inside format specifier'); expect(() => Format('a{ 0}', 1)).toThrow('Whitespace inside format specifier');
expect(() => Format('a{ 0 }', 1)).toThrow('Whitespace inside format specifier'); expect(() => Format('a{ 0 }', 1)).toThrow('Whitespace inside format specifier');