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';
function isalpha(char: number) {
return RegExp(/^\p{L}/, 'u').test(String.fromCharCode(char));
}
/// A simple function for formatting strings in a more expressive manner.
/// 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.
@ -25,8 +29,8 @@ export function Format(pattern: string, ...args: Array<StringLike>) {
let foundSpecifierEnd = false;
// Make sure the specifier is not cut off (the last character of the string)
if (i + 3 >= pat.length) {
throw new Error(`Error in format pattern "${pat}": Cutoff/invalid format specifier`);
if (i + 3 > pat.length) {
throw new Error(`Error in format pattern "${pat}": Cutoff/invalid format specifier`);
}
// Try and find the specifier end ('}').
@ -40,13 +44,14 @@ export function Format(pattern: string, ...args: Array<StringLike>) {
case '{':
throw new Error(`Error in format pattern "${pat}": Cannot start a format specifier in an existing replacement`);
break;
case ' ':
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:
if (isalpha(pat.charCodeAt(j))) throw new Error(`Error in format pattern "${pat}": Malformed format specifier`);
break;
}

View File

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

View File

@ -63,6 +63,8 @@ export default class CollabVMClient {
// public events
private publicEmitter: Emitter<CollabVMClientEvents>;
private unsubscribeCallbacks: Array<Unsubscribe> = [];
constructor(url: string) {
// Save the URL
this.url = url;
@ -434,6 +436,13 @@ export default class CollabVMClient {
// Close the connection
close() {
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();
}
@ -588,6 +597,8 @@ export default class CollabVMClient {
}
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', () => {
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');