diff --git a/.gitignore b/.gitignore index 0512a5bd..0f971aa8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /data/projects/*.json /data/projects/*.tmp +/data/projects/*.lock /data/e2e-password.txt /data/e2e-admin-password.txt /data/sessions/ @@ -19,4 +20,4 @@ /wiki/ /index.html /play.html -/pasted-*.png +pasted-*.png diff --git a/README.md b/README.md index 6e1429b3..ded69073 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1580 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1586 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/api/index.php b/api/index.php index d3ffeb94..a57830b2 100644 --- a/api/index.php +++ b/api/index.php @@ -51,15 +51,15 @@ declare(strict_types=1); * * GET ?r=textures → [{file,name,width,height,bytes,updatedAt}] * POST ?r=textures&name=x.png raw image bytes → {file,name,width,height,bytes} - * DELETE ?r=textures/{file} → 204 + * DELETE ?r=textures/{file} → 204 (administrators only, 403 for everybody else) * * GET ?r=fonts → [{file,name,bytes,updatedAt}] * POST ?r=fonts&name=x.ttf raw font bytes → {file,name,bytes} - * DELETE ?r=fonts/{file} → 204 + * DELETE ?r=fonts/{file} → 204 (administrators only) * * GET ?r=sounds → [{file,name,bytes,updatedAt}] * POST ?r=sounds&name=x.wav raw sound bytes → {file,name,bytes} - * DELETE ?r=sounds/{file} → 204 + * DELETE ?r=sounds/{file} → 204 (administrators only) * * POST ?r=pixels raw cel bytes → {file,bytes} (pixel art, #130) * @@ -74,7 +74,9 @@ declare(strict_types=1); */ require __DIR__ . '/lib/AccountRules.php'; +require __DIR__ . '/lib/AtomicFile.php'; require __DIR__ . '/lib/Auth.php'; +require __DIR__ . '/lib/HashedFileStore.php'; // before the font, sound and texture stores built on it require __DIR__ . '/lib/FontStore.php'; require __DIR__ . '/lib/OriginalName.php'; require __DIR__ . '/lib/PixelStore.php'; @@ -101,6 +103,11 @@ const MAX_FONT_BYTES = 4 * 1024 * 1024; const MAX_SOUND_BYTES = 16 * 1024 * 1024; set_error_handler(static function (int $severity, string $message, string $file, int $line): bool { + // PHP calls the handler for a call silenced with `@` as well, only with less reported: that + // warning was expected and is answered where it happens (an unreadable image is a 422). + if ((error_reporting() & $severity) === 0) { + return false; + } throw new ErrorException($message, 0, $severity, $file, $line); }); @@ -467,23 +474,38 @@ function routeUsers(array $parts, string $method, Auth $auth, UserStore $users): Response::error('Method not allowed', 405); } -/** @param list $parts */ -function routeTextures(array $parts, string $method): never -{ - $store = new TextureStore(dirname(__DIR__) . '/data/textures'); - +/** + * Images, fonts and sounds: shared between the accounts and stored under the hash of their bytes. + * Everybody signed in uploads and lists them. Deleting one is for administrators only, because a + * project of another account may be wearing it – the editor itself never deletes a file. + * + * @param list $parts + * @param class-string $invalid what the store throws for bytes it cannot take + */ +function routeFiles( + array $parts, + string $method, + Auth $auth, + HashedFileStore $store, + int $maxBytes, + string $noun, + string $invalid, +): never { if (count($parts) === 1) { if ($method === 'GET') { Response::json($store->list()); } if ($method === 'POST') { $bytes = Response::rawBody(); - if (strlen($bytes) > MAX_TEXTURE_BYTES) { - Response::error('Image must be at most ' . (MAX_TEXTURE_BYTES / 1024 / 1024) . ' MB', 413); + if (strlen($bytes) > $maxBytes) { + Response::error("{$noun} must be at most " . ($maxBytes / 1024 / 1024) . ' MB', 413); } try { Response::json($store->store($bytes, (string) ($_GET['name'] ?? '')), 201); - } catch (InvalidImageException $e) { + } catch (RuntimeException $e) { + if (!$e instanceof $invalid) { + throw $e; + } Response::error($e->getMessage(), 422); } } @@ -491,74 +513,9 @@ function routeTextures(array $parts, string $method): never } if ($method === 'DELETE') { + $auth->requireAdmin(); if (!$store->delete($parts[1])) { - Response::error('Texture not found', 404); - } - Response::noContent(); - } - - Response::error('Method not allowed', 405); -} - -/** @param list $parts */ -function routeFonts(array $parts, string $method): never -{ - $store = new FontStore(dirname(__DIR__) . '/data/fonts'); - - if (count($parts) === 1) { - if ($method === 'GET') { - Response::json($store->list()); - } - if ($method === 'POST') { - $bytes = Response::rawBody(); - if (strlen($bytes) > MAX_FONT_BYTES) { - Response::error('Font must be at most ' . (MAX_FONT_BYTES / 1024 / 1024) . ' MB', 413); - } - try { - Response::json($store->store($bytes, (string) ($_GET['name'] ?? '')), 201); - } catch (InvalidFontException $e) { - Response::error($e->getMessage(), 422); - } - } - Response::error('Method not allowed', 405); - } - - if ($method === 'DELETE') { - if (!$store->delete($parts[1])) { - Response::error('Font not found', 404); - } - Response::noContent(); - } - - Response::error('Method not allowed', 405); -} - -/** @param list $parts */ -function routeSounds(array $parts, string $method): never -{ - $store = new SoundStore(dirname(__DIR__) . '/data/sounds'); - - if (count($parts) === 1) { - if ($method === 'GET') { - Response::json($store->list()); - } - if ($method === 'POST') { - $bytes = Response::rawBody(); - if (strlen($bytes) > MAX_SOUND_BYTES) { - Response::error('Sound must be at most ' . (MAX_SOUND_BYTES / 1024 / 1024) . ' MB', 413); - } - try { - Response::json($store->store($bytes, (string) ($_GET['name'] ?? '')), 201); - } catch (InvalidSoundException $e) { - Response::error($e->getMessage(), 422); - } - } - Response::error('Method not allowed', 405); - } - - if ($method === 'DELETE') { - if (!$store->delete($parts[1])) { - Response::error('Sound not found', 404); + Response::error("{$noun} not found", 404); } Response::noContent(); } @@ -642,12 +599,37 @@ try { // Images, fonts, sounds, cels and plugins are shared between accounts; being signed in is still the // price of admission, so an uploaded file cannot be dropped in by a stranger. $user = $auth->require(); + $data = dirname(__DIR__) . '/data'; match ($parts[0]) { 'projects' => routeProjects($parts, $method, $user, $auth->role($user) === 'admin'), - 'textures' => routeTextures($parts, $method), - 'fonts' => routeFonts($parts, $method), - 'sounds' => routeSounds($parts, $method), + 'textures' => routeFiles( + $parts, + $method, + $auth, + new TextureStore("{$data}/textures"), + MAX_TEXTURE_BYTES, + 'Image', + InvalidImageException::class, + ), + 'fonts' => routeFiles( + $parts, + $method, + $auth, + new FontStore("{$data}/fonts"), + MAX_FONT_BYTES, + 'Font', + InvalidFontException::class, + ), + 'sounds' => routeFiles( + $parts, + $method, + $auth, + new SoundStore("{$data}/sounds"), + MAX_SOUND_BYTES, + 'Sound', + InvalidSoundException::class, + ), 'pixels' => routePixels($parts, $method), 'plugins' => routePlugins($parts, $method), default => Response::error('Not found', 404), diff --git a/api/lib/AtomicFile.php b/api/lib/AtomicFile.php new file mode 100644 index 00000000..477eff0e --- /dev/null +++ b/api/lib/AtomicFile.php @@ -0,0 +1,27 @@ + 'woff2', ]; - public function __construct(private readonly string $dir) - { - if (!is_dir($this->dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) { - throw new RuntimeException("Cannot create font directory {$this->dir}"); - } - } - - /** A stored file name, as it appears in a project document. */ public static function isValidName(string $name): bool { return (bool) preg_match('/^[a-f0-9]{64}\.(ttf|otf|woff|woff2)$/', $name); @@ -63,64 +53,7 @@ final class FontStore throw new InvalidFontException('Font file is truncated'); } - $file = hash('sha256', $bytes) . '.' . $extension; - $path = $this->dir . '/' . $file; - if (!is_file($path)) { - $this->writeFile($path, $bytes); - } - OriginalName::write($this->dir, $file, $originalName); - - return ['file' => $file, 'name' => OriginalName::read($this->dir, $file), 'bytes' => strlen($bytes)]; - } - - /** - * Every stored file, newest first – what the font picker offers. Unlike an image there is - * nothing to read out of the header here: the picker shows each face by rendering with it. - * - * @return list - */ - public function list(): array - { - $items = []; - foreach (glob($this->dir . '/*.*') ?: [] as $path) { - $file = basename($path); - // Sidecars share the directory and are skipped: `{hash}.ttf.json` is not a valid name. - if (!self::isValidName($file)) { - continue; - } - $items[] = [ - 'file' => $file, - 'name' => OriginalName::read($this->dir, $file), - 'bytes' => (int) filesize($path), - 'updatedAt' => gmdate('c', (int) filemtime($path)), - ]; - } - usort($items, static fn(array $a, array $b): int => strcmp($b['updatedAt'], $a['updatedAt'])); - return $items; - } - - public function delete(string $file): bool - { - if (!self::isValidName($file)) { - return false; - } - $path = $this->dir . '/' . $file; - if (!is_file($path) || !unlink($path)) { - return false; - } - OriginalName::delete($this->dir, $file); - return true; - } - - /** Temp file plus rename, so a reader never sees a half-written font (see TextureStore). */ - private function writeFile(string $path, string $bytes): void - { - $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; - if (file_put_contents($tmp, $bytes, LOCK_EX) === false || !rename($tmp, $path)) { - @unlink($tmp); - throw new RuntimeException('Cannot write font file'); - } - @chmod($path, 0664); + return $this->keep($bytes, $extension, $originalName); } } diff --git a/api/lib/HashedFileStore.php b/api/lib/HashedFileStore.php new file mode 100644 index 00000000..27b10c86 --- /dev/null +++ b/api/lib/HashedFileStore.php @@ -0,0 +1,94 @@ +dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) { + throw new RuntimeException("Cannot create directory {$this->dir}"); + } + } + + /** A stored file name, as it appears in a project document. */ + abstract public static function isValidName(string $name): bool; + + /** + * Every stored file, newest first – what the picker of its kind offers. + * + * @return list> + */ + public function list(): array + { + $items = []; + foreach (glob($this->dir . '/*.*') ?: [] as $path) { + $file = basename($path); + // Sidecars share the directory and are skipped: `{hash}.png.json` is not a valid name. + if (!static::isValidName($file)) { + continue; + } + $items[] = [ + 'file' => $file, + 'name' => OriginalName::read($this->dir, $file), + ...$this->details($path), + 'bytes' => (int) filesize($path), + 'updatedAt' => gmdate('c', (int) filemtime($path)), + ]; + } + usort($items, static fn(array $a, array $b): int => strcmp($b['updatedAt'], $a['updatedAt'])); + return $items; + } + + public function delete(string $file): bool + { + if (!static::isValidName($file)) { + return false; + } + $path = $this->dir . '/' . $file; + if (!is_file($path) || !unlink($path)) { + return false; + } + OriginalName::delete($this->dir, $file); + return true; + } + + /** + * Keeps bytes that passed the store's checks under their hash, with `$originalName` beside them + * as a caption (see OriginalName), and returns the record. Bytes stored before are not written + * again; a new name renames them. + * + * @param array $details what the record says between the name and the size + * @return array + */ + protected function keep(string $bytes, string $extension, string $originalName, array $details = []): array + { + $file = hash('sha256', $bytes) . '.' . $extension; + $path = $this->dir . '/' . $file; + if (!is_file($path)) { + AtomicFile::write($path, $bytes, AtomicFile::SERVED, 'stored file'); + } + OriginalName::write($this->dir, $file, $originalName); + + return ['file' => $file, 'name' => OriginalName::read($this->dir, $file), ...$details, 'bytes' => strlen($bytes)]; + } + + /** + * What a record of a stored file says between its name and its size. An image reads its pixel + * size here; a font or a sound has nothing to read without decoding it. + * + * @return array + */ + protected function details(string $path): array + { + return []; + } +} diff --git a/api/lib/OriginalName.php b/api/lib/OriginalName.php index b4f52ebb..b59616aa 100644 --- a/api/lib/OriginalName.php +++ b/api/lib/OriginalName.php @@ -70,16 +70,15 @@ final class OriginalName if ($clean === '') { return; } - $path = $dir . '/' . $file . '.json'; - $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; $json = json_encode(['name' => $clean], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - if ($json === false || file_put_contents($tmp, $json, LOCK_EX) === false || !rename($tmp, $path)) { - if (is_file($tmp)) { - unlink($tmp); - } - return; // A nameless file is a small loss; failing the upload over it would be worse. + if ($json === false) { + return; + } + try { + AtomicFile::write($dir . '/' . $file . '.json', $json, AtomicFile::SERVED, 'name file'); + } catch (RuntimeException) { + // A nameless file is a small loss; failing the upload over it would be worse. } - chmod($path, 0664); } /** Removing a file that has no sidecar is the normal case, not a failure. */ diff --git a/api/lib/PixelStore.php b/api/lib/PixelStore.php index 6f15465e..26bd087b 100644 --- a/api/lib/PixelStore.php +++ b/api/lib/PixelStore.php @@ -54,14 +54,7 @@ final class PixelStore $file = hash('sha256', $bytes) . '.px'; $path = $this->dir . '/' . $file; if (!is_file($path)) { - $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; - if (file_put_contents($tmp, $bytes, LOCK_EX) === false || !rename($tmp, $path)) { - if (is_file($tmp)) { - unlink($tmp); - } - throw new RuntimeException('Cannot write pixel file'); - } - chmod($path, 0664); + AtomicFile::write($path, $bytes, AtomicFile::SERVED, 'pixel file'); } return ['file' => $file, 'bytes' => $length]; } diff --git a/api/lib/ReleaseStore.php b/api/lib/ReleaseStore.php index b26f305f..17824dc1 100644 --- a/api/lib/ReleaseStore.php +++ b/api/lib/ReleaseStore.php @@ -146,15 +146,6 @@ final class ReleaseStore if ($json === false) { throw new RuntimeException('Cannot encode release'); } - $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; - if (file_put_contents($tmp, $json, LOCK_EX) === false) { - @unlink($tmp); - throw new RuntimeException('Cannot write release file'); - } - chmod($tmp, 0640); - if (!rename($tmp, $path)) { - @unlink($tmp); - throw new RuntimeException('Cannot write release file'); - } + AtomicFile::write($path, $json, AtomicFile::HIDDEN, 'release file'); } } diff --git a/api/lib/SessionStore.php b/api/lib/SessionStore.php index 811dc7cc..8ea937b1 100644 --- a/api/lib/SessionStore.php +++ b/api/lib/SessionStore.php @@ -110,9 +110,11 @@ final class SessionStore throw new RuntimeException('Invalid session token'); } $json = json_encode($data, JSON_UNESCAPED_SLASHES); - if ($json === false || file_put_contents($path, $json, LOCK_EX) === false) { - throw new RuntimeException('Cannot write session file'); + if ($json === false) { + throw new RuntimeException('Cannot encode session'); } - @chmod($path, 0640); + // Replaced rather than rewritten in place: a request reading it meanwhile would find it + // empty, and an empty session is a signed-out one. + AtomicFile::write($path, $json, AtomicFile::HIDDEN, 'session file'); } } diff --git a/api/lib/SnapshotStore.php b/api/lib/SnapshotStore.php index 3e1906c1..79d1f980 100644 --- a/api/lib/SnapshotStore.php +++ b/api/lib/SnapshotStore.php @@ -59,12 +59,7 @@ final class SnapshotStore if ($json === false) { throw new RuntimeException('Cannot encode snapshot'); } - $path = $this->path($projectId, $id); - $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; - if (file_put_contents($tmp, $json, LOCK_EX) === false || !rename($tmp, $path)) { - @unlink($tmp); - throw new RuntimeException('Cannot write snapshot file'); - } + AtomicFile::write($this->path($projectId, $id), $json, AtomicFile::HIDDEN, 'snapshot file'); return self::summary($snapshot); } diff --git a/api/lib/SoundStore.php b/api/lib/SoundStore.php index 4c27bf38..16195470 100644 --- a/api/lib/SoundStore.php +++ b/api/lib/SoundStore.php @@ -2,15 +2,13 @@ declare(strict_types=1); /** - * Content-addressed sound store: `data/sounds/{sha256}.{ext}` – the font store's twin (see - * FontStore), for the same reasons: one file per sound however often it is uploaded, a URL that - * can be cached forever, and deleting a sound asset stays undoable because nothing here is - * removed when the last project stops referencing it. + * Content-addressed sound store: `data/sounds/{sha256}.{ext}` (see HashedFileStore). * * A sound is stored byte for byte. There is no re-encoding step as there is for images: the - * browser decodes what was picked, and nothing here could encode audio anyway. + * browser decodes what was picked, and nothing here could encode audio anyway. The picker plays a + * sound instead of showing it, and its length is not read, since only a decoder knows it. */ -final class SoundStore +final class SoundStore extends HashedFileStore { /** * Accepted formats, keyed by the bytes they start with. MP3 is the exception with no single @@ -22,14 +20,6 @@ final class SoundStore 'ID3' => 'mp3', ]; - public function __construct(private readonly string $dir) - { - if (!is_dir($this->dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) { - throw new RuntimeException("Cannot create sound directory {$this->dir}"); - } - } - - /** A stored file name, as it appears in a project document. */ public static function isValidName(string $name): bool { return (bool) preg_match('/^[a-f0-9]{64}\.(mp3|ogg|wav|m4a)$/', $name); @@ -55,53 +45,7 @@ final class SoundStore throw new InvalidSoundException('Not a sound file – use MP3, OGG, WAV or M4A'); } - $file = hash('sha256', $bytes) . '.' . $extension; - $path = $this->dir . '/' . $file; - if (!is_file($path)) { - $this->writeFile($path, $bytes); - } - OriginalName::write($this->dir, $file, $originalName); - - return ['file' => $file, 'name' => OriginalName::read($this->dir, $file), 'bytes' => strlen($bytes)]; - } - - /** - * Every stored file, newest first – what the sound picker offers. A sound has nothing to show, - * so the picker plays it instead; length is not read here, since only a decoder knows it. - * - * @return list - */ - public function list(): array - { - $items = []; - foreach (glob($this->dir . '/*.*') ?: [] as $path) { - $file = basename($path); - // Sidecars share the directory and are skipped: `{hash}.mp3.json` is not a valid name. - if (!self::isValidName($file)) { - continue; - } - $items[] = [ - 'file' => $file, - 'name' => OriginalName::read($this->dir, $file), - 'bytes' => (int) filesize($path), - 'updatedAt' => gmdate('c', (int) filemtime($path)), - ]; - } - usort($items, static fn(array $a, array $b): int => strcmp($b['updatedAt'], $a['updatedAt'])); - return $items; - } - - public function delete(string $file): bool - { - if (!self::isValidName($file)) { - return false; - } - $path = $this->dir . '/' . $file; - if (!is_file($path) || !unlink($path)) { - return false; - } - OriginalName::delete($this->dir, $file); - return true; + return $this->keep($bytes, $extension, $originalName); } /** The format these bytes are, `''` when they are none we take. */ @@ -138,17 +82,6 @@ final class SoundStore $version = ($second >> 3) & 0b11; return $first === 0xFF && ($second & 0xE0) === 0xE0 && $version !== 0b01 ? 'mp3' : ''; } - - /** Temp file plus rename, so a reader never sees a half-written sound (see TextureStore). */ - private function writeFile(string $path, string $bytes): void - { - $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; - if (file_put_contents($tmp, $bytes, LOCK_EX) === false || !rename($tmp, $path)) { - @unlink($tmp); - throw new RuntimeException('Cannot write sound file'); - } - @chmod($path, 0664); - } } /** Thrown by SoundStore::store when the uploaded bytes are not a usable sound. */ diff --git a/api/lib/Store.php b/api/lib/Store.php index ae344c4d..52a505b0 100644 --- a/api/lib/Store.php +++ b/api/lib/Store.php @@ -123,11 +123,14 @@ final class Store public function delete(string $id): bool { - if ($this->load($id) === null) { - return false; - } $path = $this->path($id); - return file_exists($path) && unlink($path); + // Under the save lock, or a save running at the same time writes the project back. + $deleted = $this->locked($path, fn(): bool => $this->load($id) !== null && unlink($path)); + if ($deleted) { + // Whoever still waits for the lock finds no project and writes nothing. + @unlink($path . '.lock'); + } + return $deleted; } /** @@ -154,6 +157,9 @@ final class Store } /** + * The lock file stays when the work is done. Removing it would let two saves in at once: one + * still waiting holds the removed file, while the next one creates a new file and locks that. + * * @template T * @param callable():T $work * @return T @@ -169,7 +175,6 @@ final class Store } finally { flock($lock, LOCK_UN); fclose($lock); - @unlink($path . '.lock'); } } @@ -221,10 +226,6 @@ final class Store if ($json === false) { throw new RuntimeException('Cannot encode project'); } - $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; - if (file_put_contents($tmp, $json, LOCK_EX) === false || !rename($tmp, $path)) { - @unlink($tmp); - throw new RuntimeException('Cannot write project file'); - } + AtomicFile::write($path, $json, AtomicFile::HIDDEN, 'project file'); } } diff --git a/api/lib/TextureStore.php b/api/lib/TextureStore.php index 9e5e7e0b..a909a3d3 100644 --- a/api/lib/TextureStore.php +++ b/api/lib/TextureStore.php @@ -2,19 +2,13 @@ declare(strict_types=1); /** - * Content-addressed image store: `data/textures/{sha256}.{ext}`. + * Content-addressed image store: `data/textures/{sha256}.{ext}` (see HashedFileStore). * - * The name of a file is the hash of its bytes, which buys four things at once: - * uploading the same image twice costs one file, a stored file can be cached forever - * (its URL cannot ever mean different bytes), projects reference a short string instead - * of embedding megabytes of base64, and deleting a texture asset is undoable for free - * because nothing here is removed when it stops being referenced. - * - * The flip side is that files outlive the projects that used them: nothing collects the - * unreferenced ones. They stay pickable instead – `GET ?r=textures` is what the editor's - * image picker offers, so a picture one project dropped is still there for the next. + * The flip side of hashed names is that files outlive the projects that used them: nothing + * collects the unreferenced ones. They stay pickable instead – `GET ?r=textures` is what the + * editor's image picker offers, so a picture one project dropped is still there for the next. */ -final class TextureStore +final class TextureStore extends HashedFileStore { /** Accepted formats. SVG is deliberately absent: it can carry script. */ private const EXTENSIONS = [ @@ -38,14 +32,6 @@ final class TextureStore */ private const MAX_DIMENSION = 8192; - public function __construct(private readonly string $dir) - { - if (!is_dir($this->dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) { - throw new RuntimeException("Cannot create texture directory {$this->dir}"); - } - } - - /** A stored file name, as it appears in a project document. */ public static function isValidName(string $name): bool { return (bool) preg_match('/^[a-f0-9]{64}\.(png|jpg|webp|gif)$/', $name); @@ -87,80 +73,20 @@ final class TextureStore throw new InvalidImageException('Image must be between 1 and ' . self::MAX_DIMENSION . ' pixels per side'); } - $file = hash('sha256', $bytes) . '.' . $extension; - $path = $this->dir . '/' . $file; - if (!is_file($path)) { - $this->writeFile($path, $bytes); - } - OriginalName::write($this->dir, $file, $originalName); - - return [ - 'file' => $file, - 'name' => OriginalName::read($this->dir, $file), - 'width' => $width, - 'height' => $height, - 'bytes' => strlen($bytes), - ]; + return $this->keep($bytes, $extension, $originalName, ['width' => $width, 'height' => $height]); } /** - * Every stored file, newest first – what the image picker offers. + * The pixel size, read from each header rather than left to the client: a texture asset records + * the size, so a picker that only knew the file name could not create one without decoding the + * picture first. An unreadable file reports 0 × 0 and the picker skips it. * - * The pixel size is read from each header rather than left to the client: a texture asset - * records the size, so a picker that only knew the file name could not create one without - * decoding the picture first. An unreadable file reports 0 × 0 and the picker skips it. - * - * @return list + * @return array{width:int,height:int} */ - public function list(): array + protected function details(string $path): array { - $items = []; - foreach (glob($this->dir . '/*.*') ?: [] as $path) { - $file = basename($path); - // Sidecars live in the same directory and are skipped here: `{hash}.png.json` is not - // a valid stored name. - if (!self::isValidName($file)) { - continue; - } - $size = @getimagesize($path); - $items[] = [ - 'file' => $file, - 'name' => OriginalName::read($this->dir, $file), - 'width' => (int) ($size[0] ?? 0), - 'height' => (int) ($size[1] ?? 0), - 'bytes' => (int) filesize($path), - 'updatedAt' => gmdate('c', (int) filemtime($path)), - ]; - } - usort($items, static fn(array $a, array $b): int => strcmp($b['updatedAt'], $a['updatedAt'])); - return $items; - } - - public function delete(string $file): bool - { - if (!self::isValidName($file)) { - return false; - } - $path = $this->dir . '/' . $file; - if (!is_file($path) || !unlink($path)) { - return false; - } - OriginalName::delete($this->dir, $file); - return true; - } - - /** - * Writes via a temp file in the same directory, so a reader never sees a half-written - * image: the name only ever appears once the bytes behind it are complete. - */ - private function writeFile(string $path, string $bytes): void - { - $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; - if (file_put_contents($tmp, $bytes, LOCK_EX) === false || !rename($tmp, $path)) { - @unlink($tmp); - throw new RuntimeException('Cannot write texture file'); - } - @chmod($path, 0664); + $size = @getimagesize($path); + return ['width' => (int) ($size[0] ?? 0), 'height' => (int) ($size[1] ?? 0)]; } } diff --git a/api/lib/UserStore.php b/api/lib/UserStore.php index 21883e9b..a4759f2b 100644 --- a/api/lib/UserStore.php +++ b/api/lib/UserStore.php @@ -216,11 +216,6 @@ final class UserStore if ($json === false) { throw new RuntimeException('Cannot encode users'); } - $tmp = $this->file . '.' . bin2hex(random_bytes(4)) . '.tmp'; - if (file_put_contents($tmp, $json, LOCK_EX) === false || !rename($tmp, $this->file)) { - @unlink($tmp); - throw new RuntimeException('Cannot write users file'); - } - @chmod($this->file, 0640); + AtomicFile::write($this->file, $json, AtomicFile::HIDDEN, 'users file'); } } diff --git a/src/core/commands/animationCommands.ts b/src/core/commands/animationCommands.ts index 9c73cddd..67063270 100644 --- a/src/core/commands/animationCommands.ts +++ b/src/core/commands/animationCommands.ts @@ -270,7 +270,7 @@ export class SetKeyInterpolationCommand extends TimelineCommand { else key.interpolation = this.interpolation; changed += 1; } - if (changed === 0) throw new CommandError('Only a number or colour key can ease'); + if (changed === 0) throw new CommandError('Only a number or color key can ease'); } } diff --git a/src/core/commands/colliderCommands.ts b/src/core/commands/colliderCommands.ts index 790d8c39..d3e9e322 100644 --- a/src/core/commands/colliderCommands.ts +++ b/src/core/commands/colliderCommands.ts @@ -4,7 +4,7 @@ import { isLiveInstance, type ObjectRef, resolvedHolder } from '../model/instanc import type { Asset } from '../model/Asset'; import type { PropertyBag } from '../model/Properties'; import { findAsset, type ObjectHolder, type Project } from '../model/Project'; -import { createSceneObject, type SceneObject } from '../model/SceneObject'; +import { createSceneObject, objectName, type SceneObject } from '../model/SceneObject'; import { resolveSceneObjectProps } from '../properties/resolve'; import { transformOfObject } from '../properties/transform'; import { removeById } from '../util/list'; @@ -52,7 +52,7 @@ abstract class AddPartCommand implements Command { if (requireAsset(project, this.assetId).type !== this.partType) throw new CommandError(`That asset is not a ${this.partType}`); } else { - project.assets.push(this.build(project, target, targetAsset, target.name || targetAsset.name)); + project.assets.push(this.build(project, target, targetAsset, objectName(target, targetAsset))); } holder.objects.push(createSceneObject(this.objectId, this.assetId, { x: 0, y: 0, z: 0 }, this.targetId)); } diff --git a/src/core/commands/pixelArtCommands.ts b/src/core/commands/pixelArtCommands.ts index 244cbcb8..0ab63962 100644 --- a/src/core/commands/pixelArtCommands.ts +++ b/src/core/commands/pixelArtCommands.ts @@ -72,9 +72,9 @@ export class SetPaletteColorsCommand implements Command { execute(project: Project): void { const palette = requirePalette(project, this.paletteId); if (this.colors.length > PALETTE_MAX) - throw new CommandError(`A palette holds at most ${PALETTE_MAX} colours`); + throw new CommandError(`A palette holds at most ${PALETTE_MAX} colors`); if (!this.colors.every((c) => Number.isInteger(c) && c >= 0 && c <= 0xffffff)) - throw new CommandError('A colour is 0xRRGGBB'); + throw new CommandError('A color is 0xRRGGBB'); this.before = [...palette.colors]; palette.colors = [...this.colors]; } diff --git a/src/core/commands/vectorArtCommands.ts b/src/core/commands/vectorArtCommands.ts index c50d388b..5a738c91 100644 --- a/src/core/commands/vectorArtCommands.ts +++ b/src/core/commands/vectorArtCommands.ts @@ -328,7 +328,7 @@ export class SetVectorShapeStyleCommand implements Command { throw new CommandError('An outline is wider than nothing'); for (const paint of [this.style.paint, this.style.stroke?.paint]) if (paint && paint.type !== 'texture' && paint.stops.length < MIN_STOPS) - throw new CommandError('A gradient needs two colours'); + throw new CommandError('A gradient needs two colors'); this.previous = { fill: shape.fill, opacity: shape.opacity, diff --git a/src/core/graph/GraphInterpreter.ts b/src/core/graph/GraphInterpreter.ts index fce99ceb..547adf84 100644 --- a/src/core/graph/GraphInterpreter.ts +++ b/src/core/graph/GraphInterpreter.ts @@ -105,24 +105,10 @@ export class GraphInterpreter { return this.indexOf(this.graph).ofType(type); } - fire( - type: string, - ctx: NodeContext, - outputs: Record = {}, - filter?: (node: GraphNode) => boolean, - /** Which of the event node's exec outputs the pulse leaves through – a state event names one. */ - port = 'exec', - ): number { - let fired = 0; - for (const node of this.eventNodes(type)) { - if (filter && !filter(node)) continue; - this.pulse(node, ctx, outputs, port); - fired++; - } - return fired; - } - - /** Runs the flow of one event node, whichever it is; `fire` is this for every node of a type. */ + /** + * Runs the flow of one event node, whichever it is. `port` is the exec output the pulse leaves + * through – a state event names one. + */ pulse(node: GraphNode, ctx: NodeContext, outputs: Record = {}, port = 'exec'): void { this.frame = this.emptyFrame(this.graph); this.callStack = []; diff --git a/src/core/graph/nodes/action/PlaySound.ts b/src/core/graph/nodes/action/PlaySound.ts index 07315416..7de82622 100644 --- a/src/core/graph/nodes/action/PlaySound.ts +++ b/src/core/graph/nodes/action/PlaySound.ts @@ -54,7 +54,7 @@ export const PlaySound: NodeDef = { description: 'Plays a sound asset. "volume" multiplies the volume the asset itself carries and "loop" either ' + "leaves the asset's own setting alone or overrules it, so the asset says what the sound is and " + - 'the node only colours it. Each play is a voice of its own: firing this twice in a row is heard ' + + 'the node only colors it. Each play is a voice of its own: firing this twice in a row is heard ' + 'twice, overlapping. The sound belongs to this object and stops when the object is destroyed, ' + 'unless "detached" is on.', inputs: [exec(), soundPort(), withDefault(num('volume'), 1), loopPort(), detachedPort()], diff --git a/src/core/graph/nodes/action/animationNodes.ts b/src/core/graph/nodes/action/animationNodes.ts index 7813ddd6..4fefe70d 100644 --- a/src/core/graph/nodes/action/animationNodes.ts +++ b/src/core/graph/nodes/action/animationNodes.ts @@ -21,7 +21,7 @@ export const PlayAnimation: NodeDef = { description: 'Plays an animation on an object: its rows reach the object and what is placed inside it, so every ' + 'instance plays on its own. A paused one runs on from where it stands; one already running keeps its ' + - 'frame but wins again over the others on the object. Blend fades numbers and colours over that many ' + + 'frame but wins again over the others on the object. Blend fades numbers and colors over that many ' + 'seconds from what they held when it took over; everything else switches at once. Blend out is for ' + 'Once (reset): when its frames run out it fades back to where it started over that many seconds. ' + 'Restart starts one already running or paused over from its first frame – blended in from where it ' + diff --git a/src/core/model/SceneObject.ts b/src/core/model/SceneObject.ts index a2486208..ffa7e2c5 100644 --- a/src/core/model/SceneObject.ts +++ b/src/core/model/SceneObject.ts @@ -42,6 +42,16 @@ export interface ObjectGroup { name: string; } +/** What an object is called: its own name, else its asset's, and `?` while the asset is gone. */ +export function objectName(object: { name: string }, asset: { name: string } | undefined): string { + return object.name || asset?.name || '?'; +} + +/** What a group is called; one that was never named is a "Group". */ +export function groupName(group: ObjectGroup): string { + return group.name || 'Group'; +} + export function createSceneObject( id: string, assetId: string, diff --git a/src/core/model/animation.ts b/src/core/model/animation.ts index 52803331..f38659c1 100644 --- a/src/core/model/animation.ts +++ b/src/core/model/animation.ts @@ -16,7 +16,7 @@ import { treeOrder } from './hierarchy'; import { instanceChildId, resolveObjects } from './instance'; import { findAsset, type Project } from './Project'; import { colorOf, type PropertyBag, type PropertyValue, textOf } from './Properties'; -import type { SceneObject } from './SceneObject'; +import { objectName, type SceneObject } from './SceneObject'; import { mixColor } from '../util/color'; /** `once` holds the last value when it ends, `once-reset` puts back what was there before it started. */ @@ -483,7 +483,7 @@ export function trackDef( /** How the timeline names what a row aims at: the object (or the root itself) and the property. */ export function trackLabel(project: Project, animation: AnimationAsset, track: AnimationTrack): string { const aimed = aimedAt(project, animation, track.target); - const who = !aimed ? '(missing)' : aimed.object ? aimed.object.name || aimed.asset.name : aimed.asset.name; + const who = !aimed ? '(missing)' : aimed.object ? objectName(aimed.object, aimed.asset) : aimed.asset.name; const def = aimed && animatableDefsOf(project, aimed.asset, aimed.bag).find((d) => d.key === track.property); return `${who} · ${def ? def.label : aimed ? `${track.property} (missing)` : track.property}`; diff --git a/src/core/model/objectRefs.ts b/src/core/model/objectRefs.ts index 5717b2fa..3048820f 100644 --- a/src/core/model/objectRefs.ts +++ b/src/core/model/objectRefs.ts @@ -7,7 +7,7 @@ import { findAsset, type Project } from './Project'; import { treeOrder, type ObjectTree } from './hierarchy'; import { resolveObjects } from './instance'; import { mapNested, type PropertyBag, type PropertyValue, tagged, tagOf, untag } from './Properties'; -import type { SceneObject } from './SceneObject'; +import { objectName, type SceneObject } from './SceneObject'; import { Recorder } from '../util/recorder'; /** The id an object's place names; '' for none and for anything that is no object's place. */ @@ -59,7 +59,7 @@ export function objectChoices(project: Project, holder: ObjectTree, objectId: st const choices: ObjectChoice[] = []; for (const { object, depth } of treeOrder({ ...holder, objects })) { if (!object.id.startsWith(scope)) continue; - const label = object.name || findAsset(project, object.assetId)?.name || object.id; + const label = objectName(object, findAsset(project, object.assetId)); choices.push({ ref: object.id.slice(scope.length), label, depth }); } if (!scope) return choices; diff --git a/src/core/particles/nodes.ts b/src/core/particles/nodes.ts index b4a3752d..2f02c36a 100644 --- a/src/core/particles/nodes.ts +++ b/src/core/particles/nodes.ts @@ -229,7 +229,7 @@ export const ParticleColor: ParticleNodeDef = { type: 'ParticleColor', label: 'Color', category: 'particles', - description: 'A constant colour.', + description: 'A constant color.', inputs: [], outputs: [numberOut('color')], params: [flatDef('value', 'color', { label: 'Value', value: 0xffffff })], @@ -262,7 +262,7 @@ export const ParticleOutput: ParticleNodeDef = { label: 'Output', category: 'particles', description: - 'What the particle looks like: x and y offset it from the source, the scales multiply its size, rotation turns it, colour tints it and alpha fades it.', + 'What the particle looks like: x and y offset it from the source, the scales multiply its size, rotation turns it, color tints it and alpha fades it.', inputs: [ { key: 'x', label: 'x', type: 'number', inline: false }, { key: 'y', label: 'y', type: 'number', inline: false }, @@ -423,7 +423,7 @@ export const ParticleMixColor: ParticleNodeDef = { type: 'ParticleMixColor', label: 'Mix color', category: 'particles', - description: 'Blends two colours channel by channel: t = 0 is a, t = 1 is b. A flame fading to smoke.', + description: 'Blends two colors channel by channel: t = 0 is a, t = 1 is b. A flame fading to smoke.', inputs: [colorIn('a', 0xffffff), colorIn('b', 0x000000), numberIn('t', 0.5)], outputs: [numberOut('color')], params: [], diff --git a/src/core/shader/compile.ts b/src/core/shader/compile.ts index cb49c13a..5b227b24 100644 --- a/src/core/shader/compile.ts +++ b/src/core/shader/compile.ts @@ -83,7 +83,7 @@ export function compileShader(graph: Graph): ShaderBuild { }, conform: (node, port, expr) => { if (port.type === 'number' && expr.type === 'vec3') { - walk.problem(node, `"${port.key}" needs a number, not a colour`); + walk.problem(node, `"${port.key}" needs a number, not a color`); return null; } if (port.type === 'vec3' && expr.type === 'float') return { code: asVec3(expr), type: 'vec3' }; diff --git a/src/core/shader/nodes.ts b/src/core/shader/nodes.ts index 39aed96e..36fd0372 100644 --- a/src/core/shader/nodes.ts +++ b/src/core/shader/nodes.ts @@ -150,7 +150,7 @@ export const ShaderOutput: ShaderNodeDef = { label: 'Output', category: 'shader', description: - 'The colour and opacity the sprite is drawn with; unconnected, each is the texture’s. Edge says how the picture continues past its border when sampled outside it.', + 'The color and opacity the sprite is drawn with; unconnected, each is the texture’s. Edge says how the picture continues past its border when sampled outside it.', inputs: [vec3Port('color'), { key: 'alpha', label: 'alpha', type: 'number', inline: false }], outputs: [], params: [{ key: EDGE_PARAM, label: 'Edge', kind: 'enum', default: DEFAULT_EDGE, options: EDGE_OPTIONS }], @@ -241,7 +241,7 @@ export const ShaderColor: ShaderNodeDef = { type: SHADER_COLOR, label: 'Color', category: 'shader', - description: 'A constant colour.', + description: 'A constant color.', inputs: [], outputs: [vec3Port('color')], params: [flatDef('value', 'color', { label: 'Value', value: 0xffffff })], @@ -261,19 +261,19 @@ export const ShaderNumber: ShaderNodeDef = { emit: (_inputs, params) => ({ value: { code: floatLiteral(Number(params.value ?? 0)), type: 'float' } }), }; -export const ShaderAdd = arithmetic('ShaderAdd', 'Add', '+', 'a + b, per channel for colours.', 0); +export const ShaderAdd = arithmetic('ShaderAdd', 'Add', '+', 'a + b, per channel for colors.', 0); export const ShaderSubtract = arithmetic( 'ShaderSubtract', 'Subtract', '-', - 'a - b, per channel for colours.', + 'a - b, per channel for colors.', 0, ); export const ShaderMultiply = arithmetic( 'ShaderMultiply', 'Multiply', '*', - 'a × b, per channel for colours.', + 'a × b, per channel for colors.', 1, ); /** @@ -291,7 +291,7 @@ export const ShaderDivide: ShaderNodeDef = { type: 'ShaderDivide', label: 'Divide', category: 'shader', - description: 'a / b, per channel for colours. Dividing by 0 is 0, not a pixel the driver picks.', + description: 'a / b, per channel for colors. Dividing by 0 is 0, not a pixel the driver picks.', inputs: [anyIn('a', 1), anyIn('b', 1)], outputs: [anyOut('result')], params: [], @@ -305,7 +305,7 @@ export const ShaderClamp: ShaderNodeDef = { type: 'ShaderClamp', label: 'Clamp', category: 'shader', - description: 'Holds the value inside min..max, per channel for colours.', + description: 'Holds the value inside min..max, per channel for colors.', inputs: [anyIn('value', 0), floatIn('min', 0), floatIn('max', 1)], outputs: [anyOut('result')], params: [], @@ -332,7 +332,7 @@ export const ShaderLuminance: ShaderNodeDef = { type: 'ShaderLuminance', label: 'Luminance', category: 'shader', - description: 'How bright a colour is (Rec. 709 weights), as one number.', + description: 'How bright a color is (Rec. 709 weights), as one number.', inputs: [vec3Port('color')], outputs: [{ key: 'value', label: 'value', type: 'number' }], params: [], @@ -358,7 +358,7 @@ export const ShaderSin = unary( 'ShaderSin', 'Sin', 'sin', - 'The sine of a number in radians: one full wave every 6.283. Per channel for colours.', + 'The sine of a number in radians: one full wave every 6.283. Per channel for colors.', ); export const ShaderFloor = unary( 'ShaderFloor', @@ -376,14 +376,14 @@ export const ShaderAbs = unary( 'ShaderAbs', 'Abs', 'abs', - 'The distance from 0, sign dropped: -0.3 becomes 0.3. A value crossing 0 becomes a V. Per channel for colours.', + 'The distance from 0, sign dropped: -0.3 becomes 0.3. A value crossing 0 becomes a V. Per channel for colors.', ); export const ShaderStep: ShaderNodeDef = { type: 'ShaderStep', label: 'Step', category: 'shader', - description: '0 while value is below edge, 1 from edge on: a hard cut. Per channel for colours.', + description: '0 while value is below edge, 1 from edge on: a hard cut. Per channel for colors.', inputs: [floatIn('edge', 0.5), anyIn('value', 0)], outputs: [anyOut('result')], params: [], @@ -398,7 +398,7 @@ export const ShaderPow: ShaderNodeDef = { label: 'Pow', category: 'shader', description: - 'value to the power of exponent: 2 squares it, 0.5 is the square root. Below 0 the value counts as 0. Per channel for colours.', + 'value to the power of exponent: 2 squares it, 0.5 is the square root. Below 0 the value counts as 0. Per channel for colors.', inputs: [anyIn('value', 0), floatIn('exponent', 2)], outputs: [anyOut('result')], params: [], @@ -414,7 +414,7 @@ export const ShaderSqrt: ShaderNodeDef = { label: 'Sqrt', category: 'shader', description: - 'The square root: 0.25 becomes 0.5, 4 becomes 2. Below 0 the value counts as 0. Per channel for colours.', + 'The square root: 0.25 becomes 0.5, 4 becomes 2. Below 0 the value counts as 0. Per channel for colors.', inputs: [anyIn('value', 0)], outputs: [anyOut('result')], params: [], diff --git a/src/core/store/FileStore.ts b/src/core/store/FileStore.ts index a607a5d5..0b5894fc 100644 --- a/src/core/store/FileStore.ts +++ b/src/core/store/FileStore.ts @@ -19,10 +19,9 @@ export interface StoredFile extends UploadedFile { * so there is nothing else to send. `type` only fills the Content-Type header – the server trusts * the bytes, not the label – and uploading the same bytes twice yields the same file, the hash of * its content. `name` is the caption kept beside it; uploading the same bytes under a new name - * renames what the pickers show. + * renames what the pickers show. Nothing here deletes a file: another account's project may wear it. */ export interface FileStore { upload(bytes: Blob | ArrayBuffer, type: string, name?: string): Promise; list(): Promise; - delete(file: string): Promise; } diff --git a/src/core/store/HttpFileStore.ts b/src/core/store/HttpFileStore.ts index 16f02827..465359e3 100644 --- a/src/core/store/HttpFileStore.ts +++ b/src/core/store/HttpFileStore.ts @@ -27,8 +27,4 @@ export class HttpFileStore< async list(): Promise { return apiRequest(this.endpoint, 'GET', this.route, {}); } - - async delete(file: string): Promise { - await apiRequest(this.endpoint, 'DELETE', `${this.route}/${encodeURIComponent(file)}`, {}); - } } diff --git a/src/editor/ProjectSession.ts b/src/editor/ProjectSession.ts index 5b3bf565..96dc1909 100644 --- a/src/editor/ProjectSession.ts +++ b/src/editor/ProjectSession.ts @@ -150,10 +150,21 @@ export class ProjectSession { return this.pickerDialog.current(); } + /** The question stands in for the picker: Cancel goes back to it, and so does the deletion. */ private confirmDelete(id: string, name: string): void { new Dialog(`Delete project "${name}"?`, 'All assets and scenes are removed permanently.', [ - { label: 'Cancel' }, - { label: 'Delete', danger: true, onClick: () => void this.run(this.deleteProject(id)) }, + { label: 'Cancel', onClick: () => this.openProjectPicker() }, + { + label: 'Delete', + danger: true, + onClick: () => + void this.run( + (async () => { + await this.deleteProject(id); + this.openProjectPicker(); + })(), + ), + }, ]).openCentered(); } @@ -262,7 +273,7 @@ export class ProjectSession { `Restore snapshot "${snapshot.name}"?`, 'The project as it is now is kept as a snapshot of its own first, so this can be taken back.', [ - { label: 'Cancel' }, + { label: 'Cancel', onClick: () => this.openSnapshots() }, { label: 'Restore', primary: true, diff --git a/src/editor/actions.ts b/src/editor/actions.ts index 52c77345..a1dc6cb5 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -2,6 +2,7 @@ * Editor actions that several places trigger the same way (inspector, hierarchy context * menu, keyboard): they run the command and put the selection where the user expects it. */ +import { RemoveAnimationKeysCommand, RemoveAnimationTracksCommand } from '../core/commands/animationCommands'; import { annotationNoun, RemoveAnnotationCommand } from '../core/commands/annotationCommands'; import { DeleteAssetCommand, SetActiveSceneCommand } from '../core/commands/assetCommands'; import { @@ -12,8 +13,10 @@ import { } from '../core/commands/colliderCommands'; import { oneStep } from '../core/commands/CompoundCommand'; import { CreateContainerCommand } from '../core/commands/containerCommands'; +import { RemoveFrameCommand } from '../core/commands/frameCommands'; import { RemoveNodeCommand } from '../core/commands/graphCommands'; import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands'; +import { RemoveVectorShapeCommand, RemoveVectorShapesCommand } from '../core/commands/vectorArtCommands'; import type { AssetType } from '../core/model/AssetTypes'; import { curveUsages } from '../core/model/curve'; import { structTypeUsages } from '../core/model/structTypes'; @@ -29,7 +32,6 @@ import { animationsUsing, tilesUsing, findPropertyUsages, findUsages } from '../ import { Dialog } from '../ui'; import { type EditorState, objectSelection } from './state'; -/** Groups the objects into a new container asset placed in their stead; selects the container. */ /** * What a double-click on an asset does, wherever it happens (asset panel, "Open …" entries, a link * in the inspector): open its tab – and a scene, whose tab is the scene view, becomes the active one. @@ -39,6 +41,7 @@ export function activateAsset(state: EditorState, assetId: string): void { state.openAsset(assetId); } +/** Groups the objects into a new container asset placed in their stead; selects the container. */ export function createContainer(state: EditorState, holderId: string, objectIds: readonly string[]): boolean { const command = new CreateContainerCommand(holderId, objectIds); if (!state.execute(command)) return false; @@ -80,6 +83,18 @@ export function removeObjects(state: EditorState, holderId: string, objectIds: r return state.execute(new RemoveSceneObjectsCommand(holderId, objectIds)); } +/** Takes shapes of a vector art out as one undo step; the drawing itself stays selected. */ +export function removeShapes(state: EditorState, assetId: string, shapeIds: readonly string[]): boolean { + if (shapeIds.length === 0) return false; + const removed = state.execute( + shapeIds.length === 1 + ? new RemoveVectorShapeCommand(assetId, shapeIds[0]!) + : new RemoveVectorShapesCommand(assetId, shapeIds), + ); + if (removed) state.select({ kind: 'asset', assetId }); + return removed; +} + /** Deletes graph nodes as one undo step, however many were selected. */ export function removeNodes(state: EditorState, assetId: string, nodeIds: readonly string[]): boolean { const commands = nodeIds.map((id) => new RemoveNodeCommand(assetId, id)); @@ -115,6 +130,43 @@ export function removeGraphSelection( return state.execute(oneStep(label, commands)); } +/** + * What Delete does to the selection; false when nothing is selected. Every kind is a case of its + * own, so a new kind of selection cannot slip through without saying what Delete takes away. + */ +export function removeSelection(state: EditorState): boolean { + const s = state.selection; + if (!s) return false; + switch (s.kind) { + case 'asset': + confirmDeleteAsset(state, s.assetId); + return true; + case 'object': + removeObjects(state, s.holderId, s.objectIds); + return true; + case 'node': + removeGraphSelection(state, s.assetId, s.nodeIds, s.annotationIds); + return true; + case 'annotation': + removeGraphSelection(state, s.assetId, [], s.annotationIds); + return true; + case 'frame': + state.execute(new RemoveFrameCommand(s.assetId, s.frameId)); + return true; + case 'shape': + removeShapes(state, s.assetId, s.shapeIds); + return true; + case 'track': + // The keys when some are picked, else the row itself – like a frame of a texture. + state.execute( + s.keys.length > 0 + ? new RemoveAnimationKeysCommand(s.assetId, s.keys) + : new RemoveAnimationTracksCommand(s.assetId, [s.trackId]), + ); + return true; + } +} + /** Asks before deleting an asset, spelling out what goes with it. */ export function confirmDeleteAsset(state: EditorState, assetId: string): void { const asset = findAsset(state.project, assetId); @@ -167,5 +219,5 @@ function wornDetail(sprites: number, animations: number, tiles: number, type: As // "a, b and c" rather than "a and b and c", which three kinds of user made reachable. const list = who.length > 2 ? `${who.slice(0, -1).join(', ')} and ${who[who.length - 1]}` : who.join(' and '); - return `${list} reference it and show the plain colour in its place.`; + return `${list} reference it and show the plain color in its place.`; } diff --git a/src/editor/animation/RowDialog.ts b/src/editor/animation/RowDialog.ts index 3b4215d2..aee0c0bc 100644 --- a/src/editor/animation/RowDialog.ts +++ b/src/editor/animation/RowDialog.ts @@ -5,6 +5,7 @@ import { import { animationTargets, targetDefs, trackDef } from '../../core/model/animation'; import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset'; import { findAsset } from '../../core/model/Project'; +import { objectName } from '../../core/model/SceneObject'; import type { PropertyDef } from '../../core/properties/PropertySchema'; import { type Button, Dropdown, Label, Menu, type MenuItem } from '../../ui'; import { dialogSlot, FormDialog } from '../FormDialog'; @@ -90,7 +91,7 @@ export class RowDialog extends FormDialog { for (const { id, object, depth } of animationTargets(this.state.project, root)) choices.push({ id, - label: `${' '.repeat(depth)}${object.name || findAsset(this.state.project, object.assetId)?.name || '?'}`, + label: `${' '.repeat(depth)}${objectName(object, findAsset(this.state.project, object.assetId))}`, }); return choices; } diff --git a/src/editor/inspector/annotationSection.ts b/src/editor/inspector/annotationSection.ts index 6013bb7e..b0302d42 100644 --- a/src/editor/inspector/annotationSection.ts +++ b/src/editor/inspector/annotationSection.ts @@ -49,7 +49,7 @@ export function buildAnnotationSection(host: InspectorHost, assetId: string, ann ); addNote( host, - 'The text is drawn black or white, whichever reads better on this colour – so any colour stays legible.', + 'The text is drawn black or white, whichever reads better on this color – so any color stays legible.', 'small', ); if (annotation.color !== undefined) @@ -103,7 +103,7 @@ function addRow( function describe(kind: AnnotationKind): string { return kind === 'group' - ? 'Nodes whose centre lies in the box move with it. Deleting the group leaves them where they are.' + ? 'Nodes whose center lies in the box move with it. Deleting the group leaves them where they are.' : 'A comment is read, not run: it frames nothing and moves alone.'; } diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index 21f0d005..c62907a1 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -29,7 +29,7 @@ import { import { type ObjectChoice, objectChoices } from '../../core/model/objectRefs'; import { type PropertyValue, textOf } from '../../core/model/Properties'; import { findAsset, findObjectHolder, type Project } from '../../core/model/Project'; -import type { SceneObject } from '../../core/model/SceneObject'; +import { objectName, type SceneObject } from '../../core/model/SceneObject'; import { COMMON_PROPS, isCommonProp, @@ -120,7 +120,7 @@ export function buildObjectsSection(host: InspectorHost, holderId: string, objec for (const id of objectIds) { const object = findObject(view, id); const asset = object && findAsset(host.state.project, object.assetId); - if (object && asset) addNote(host, `${object.name || asset.name} · ${asset.type}`); + if (object && asset) addNote(host, `${objectName(object, asset)} · ${asset.type}`); } addGap(host); addNote(host, 'Ctrl-click adds or removes objects.', 'small'); @@ -136,7 +136,7 @@ export function buildObjectsSection(host: InspectorHost, holderId: string, objec new Button('Create container', { onClick: () => createContainer(host.state, holderId, objectIds) }), ); addNote(host, 'Groups the selection into a reusable container', 'small'); - addNote(host, 'asset placed at its centre (Ctrl+G).', 'small'); + addNote(host, 'asset placed at its center (Ctrl+G).', 'small'); addGap(host); host.body.add( new Button('Remove', { diff --git a/src/editor/inspector/parentRow.ts b/src/editor/inspector/parentRow.ts index 9b5937f9..0e9c5834 100644 --- a/src/editor/inspector/parentRow.ts +++ b/src/editor/inspector/parentRow.ts @@ -1,7 +1,7 @@ import { SetParentCommand } from '../../core/commands/sceneObjectCommands'; import { findObject, isSelfOrAncestor, treeOrder } from '../../core/model/hierarchy'; import { findAsset, type ObjectHolder } from '../../core/model/Project'; -import type { SceneObject } from '../../core/model/SceneObject'; +import { objectName, type SceneObject } from '../../core/model/SceneObject'; import { Dropdown, Label, Menu, type MenuItem } from '../../ui'; import { labelledRow } from './fieldFactory'; import type { InspectorHost } from './host'; @@ -14,7 +14,7 @@ import { viewOf } from './objectQueries'; */ export function parentRow(host: InspectorHost, holder: ObjectHolder, object: SceneObject, fixed: boolean) { const { row } = labelledRow('Parent'); - const nameOf = (o: SceneObject) => o.name || findAsset(host.state.project, o.assetId)?.name || '?'; + const nameOf = (o: SceneObject) => objectName(o, findAsset(host.state.project, o.assetId)); const parent = findObject(holder, object.parentId); const name = parent ? nameOf(parent) : 'None'; if (fixed) { diff --git a/src/editor/inspector/shapeSection.ts b/src/editor/inspector/shapeSection.ts index 2a737baa..e8007b35 100644 --- a/src/editor/inspector/shapeSection.ts +++ b/src/editor/inspector/shapeSection.ts @@ -14,7 +14,6 @@ import { DuplicateVectorShapeCommand, MoveVectorShapeCommand, RemoveVectorShapeCommand, - RemoveVectorShapesCommand, RenameVectorShapeCommand, SetVectorShapeStyleCommand, } from '../../core/commands/vectorArtCommands'; @@ -52,6 +51,7 @@ import { } from '../../core/model/vectorPaint'; import type { PropertyDef } from '../../core/properties/PropertySchema'; import { Button, HStack, Theme } from '../../ui'; +import { removeShapes } from '../actions'; import { type EditorState, shapeSelection } from '../state'; import { GradientBar } from '../vectorart/GradientBar'; import { ShapeList } from '../vectorart/ShapeList'; @@ -74,7 +74,7 @@ interface PreviewStyle { export type PaintPart = 'fill' | 'stroke'; const FILL_KIND_LABELS: Record = { - solid: 'Solid colour', + solid: 'Solid color', linear: 'Linear gradient', radial: 'Radial gradient', texture: 'Texture', @@ -112,7 +112,7 @@ const TEXTURE_DEF: PropertyDef = { function stopDefs(part: PaintPart): { color: PropertyDef; alpha: PropertyDef; offset: PropertyDef } { const prefix = part === 'fill' ? 'fillStop' : 'lineStop'; return { - color: { key: `${prefix}Color`, label: 'Stop colour', kind: 'color', default: 0xffffff }, + color: { key: `${prefix}Color`, label: 'Stop color', kind: 'color', default: 0xffffff }, alpha: { key: `${prefix}Alpha`, label: 'Stop opacity', @@ -187,7 +187,7 @@ const OPACITY_DEF: PropertyDef = { const OUTLINE_DEF: PropertyDef = { key: 'outline', label: 'Outline', kind: 'boolean', default: false }; const STROKE_COLOR_DEF: PropertyDef = { key: 'strokeColor', - label: 'Line colour', + label: 'Line color', kind: 'color', default: DEFAULT_STROKE.color, }; @@ -303,7 +303,7 @@ export function buildShapeSection( STROKE_COLOR_DEF, (s) => s.stroke?.color ?? DEFAULT_STROKE.color, (input, s) => ({ stroke: { ...strokeOf(s), color: Number(input) } }), - 'Set line colour', + 'Set line color', ); else gradientRows(host, assetId, shapeId, 'stroke'); styleRow( @@ -323,10 +323,7 @@ export function buildShapeSection( host.body.add( new Button('Delete shapes', { variant: 'danger', - onClick: () => { - host.state.execute(new RemoveVectorShapesCommand(assetId, shapeIds)); - host.state.select({ kind: 'asset', assetId }); - }, + onClick: () => removeShapes(host.state, assetId, shapeIds), }), ); host.body.add(addShapeButton(host, assetId)); @@ -683,9 +680,9 @@ function gradientRows(host: InspectorHost, assetId: string, shapeId: string, par }, label, ); - stopRow(defs.color, (stop) => stop.color, 'color', 'Set stop colour'); + stopRow(defs.color, (stop) => stop.color, 'color', 'Set stop color'); stopRow(defs.alpha, (stop) => stop.alpha, 'alpha', 'Set stop opacity'); - stopRow(defs.offset, (stop) => stop.offset, 'offset', 'Move colour stop'); + stopRow(defs.offset, (stop) => stop.offset, 'offset', 'Move color stop'); const remove = new Button('Remove stop', { variant: 'ghost', @@ -701,7 +698,7 @@ function gradientRows(host: InspectorHost, assetId: string, shapeId: string, par assetId, shapeId, withGradient(shape, part, next), - 'Remove colour stop', + 'Remove color stop', ), ); }, diff --git a/src/editor/panels/GroupNameDialog.ts b/src/editor/panels/GroupNameDialog.ts index 85286219..ca35cf4e 100644 --- a/src/editor/panels/GroupNameDialog.ts +++ b/src/editor/panels/GroupNameDialog.ts @@ -1,4 +1,4 @@ -import type { ObjectGroup } from '../../core/model/SceneObject'; +import { groupName, type ObjectGroup } from '../../core/model/SceneObject'; import { type Button, Dropdown, Menu, TextField, ui } from '../../ui'; import { dialogSlot, FormDialog } from '../FormDialog'; @@ -72,8 +72,8 @@ export class GroupNameDialog extends FormDialog { return [ { label: NEW_GROUP, onSelect: pick('', NEW_GROUP) }, ...(this.join?.groups ?? []).map((g) => ({ - label: g.name || 'Group', - onSelect: pick(g.id, g.name || 'Group'), + label: groupName(g), + onSelect: pick(g.id, groupName(g)), })), ]; } diff --git a/src/editor/panels/HierarchyPanel.ts b/src/editor/panels/HierarchyPanel.ts index b1bdf072..f1bdd866 100644 --- a/src/editor/panels/HierarchyPanel.ts +++ b/src/editor/panels/HierarchyPanel.ts @@ -11,6 +11,7 @@ import { } from '../../core/model/hierarchy'; import { resolvedHolder } from '../../core/model/instance'; import { findAsset, type ObjectHolder } from '../../core/model/Project'; +import { groupName, objectName } from '../../core/model/SceneObject'; import { deviationMarks } from '../../core/properties/deviation'; import { type DropSpot, ListView, Menu, Panel, ScrollContainer } from '../../ui'; import { groupRowItems, selectGroup } from '../objectGroups'; @@ -272,7 +273,7 @@ export class HierarchyPanel extends Panel { if (row.kind === 'group') return { id: groupRowId(row.group.id), - label: row.group.name || 'Group', + label: groupName(row.group), hint: String(row.memberIds.length), icon: GROUP_ICON_SVG, iconAtEnd: true, @@ -284,7 +285,7 @@ export class HierarchyPanel extends Panel { const asset = findAsset(this.state.project, object.assetId); return { id: object.id, - label: object.name || asset?.name || '?', + label: objectName(object, asset), icon: asset && ASSET_ICON_SVG[asset.type], iconAtEnd: true, depth, diff --git a/src/editor/pixelart/PixelArtSide.ts b/src/editor/pixelart/PixelArtSide.ts index d1407d07..655271b7 100644 --- a/src/editor/pixelart/PixelArtSide.ts +++ b/src/editor/pixelart/PixelArtSide.ts @@ -120,7 +120,7 @@ export class PixelArtSide extends ScrollContainer { this.toolOptions = row(this.filledOption, this.contiguousOption, new Spacer(), this.brushButton); c.add(this.toolOptions); - c.add(heading('Colours')); + c.add(heading('Colors')); this.palette.onPick = (index) => this.canvas.setColor(index); this.palette.onReflow = () => this.relayoutContent(); c.add(this.palette); @@ -395,7 +395,7 @@ export class PixelArtSide extends ScrollContainer { this.addColorButton.setEnabled(palette !== null); this.removeColorButton.setEnabled(editable); this.paletteNote.setText( - palette ? `${palette.name} · ${palette.colors.length} colours` : 'Default colours', + palette ? `${palette.name} · ${palette.colors.length} colors` : 'Default colors', ); this.createPaletteButton.visible = asset !== null && palette === null; diff --git a/src/editor/pixelart/pixelActions.ts b/src/editor/pixelart/pixelActions.ts index 99f293d4..dd933909 100644 --- a/src/editor/pixelart/pixelActions.ts +++ b/src/editor/pixelart/pixelActions.ts @@ -167,17 +167,17 @@ export function setPaletteColor( if (index < 1 || index > palette.colors.length || palette.colors[index - 1] === color) return false; const colors = [...palette.colors]; colors[index - 1] = color; - return state.execute(new SetPaletteColorsCommand(palette.id, 'Palette colour', colors)); + return state.execute(new SetPaletteColorsCommand(palette.id, 'Palette color', colors)); } /** Appends a colour; returns its index, 0 when the palette is full. */ export function addPaletteColor(state: EditorState, palette: PaletteAsset, color: number): number { if (palette.colors.length >= PALETTE_MAX) { - state.setNotice(`A palette holds at most ${PALETTE_MAX} colours.`); + state.setNotice(`A palette holds at most ${PALETTE_MAX} colors.`); return 0; } const colors = [...palette.colors, color]; - return state.execute(new SetPaletteColorsCommand(palette.id, 'Add colour', colors)) ? colors.length : 0; + return state.execute(new SetPaletteColorsCommand(palette.id, 'Add color', colors)) ? colors.length : 0; } /** @@ -203,9 +203,9 @@ export function removePaletteColor(state: EditorState, palette: PaletteAsset, in if (next) replaced[file] = pixelCels.put(next); } if (Object.keys(replaced).length > 0) - rewrites.push(new EditPixelArtCommand(asset.id, 'Remove colour', (s) => withCelsReplaced(s, replaced))); + rewrites.push(new EditPixelArtCommand(asset.id, 'Remove color', (s) => withCelsReplaced(s, replaced))); } const colors = palette.colors.filter((_, i) => i !== index - 1); - const recolor = new SetPaletteColorsCommand(palette.id, 'Remove colour', colors); - return state.execute(oneStep('Remove colour', [recolor, ...rewrites])); + const recolor = new SetPaletteColorsCommand(palette.id, 'Remove color', colors); + return state.execute(oneStep('Remove color', [recolor, ...rewrites])); } diff --git a/src/editor/plugins/pluginApi.ts b/src/editor/plugins/pluginApi.ts index 443942c9..61f3951e 100644 --- a/src/editor/plugins/pluginApi.ts +++ b/src/editor/plugins/pluginApi.ts @@ -110,5 +110,3 @@ export function createPluginApi(pluginId: string, host: PluginHost) { ui: Object.freeze({ NodeBoard }), }); } - -export type PluginApi = ReturnType; diff --git a/src/editor/shader/previewPicture.ts b/src/editor/shader/previewPicture.ts index f5a87f9e..39a1d1db 100644 --- a/src/editor/shader/previewPicture.ts +++ b/src/editor/shader/previewPicture.ts @@ -2,6 +2,7 @@ import { findObject } from '../../core/model/hierarchy'; import { resolvedHolder } from '../../core/model/instance'; import type { PropertyBag } from '../../core/model/Properties'; import { findAsset, findObjectHolder, type Project } from '../../core/model/Project'; +import { objectName } from '../../core/model/SceneObject'; import { findFrame, resolvePicture, resolveTexture, type TextureRef } from '../../core/model/texture'; import type { FlatDef } from '../../core/properties/PropertySchema'; import { resolveAssetProps, resolveSceneObjectProps } from '../../core/properties/resolve'; @@ -46,7 +47,7 @@ export function previewPicture(project: Project, subject: PictureSubject): Previ const object = holder && findObject(resolvedHolder(project, holder), subject.objectId); const asset = object && findAsset(project, object.assetId); if (!object || asset?.type !== 'sprite') return null; - return spritePicture(project, object.name || asset.name, resolveSceneObjectProps(project, asset, object)); + return spritePicture(project, objectName(object, asset), resolveSceneObjectProps(project, asset, object)); } if (subject.kind === 'frame') { const found = findFrame(project, subject.frameId); diff --git a/src/editor/shortcuts.ts b/src/editor/shortcuts.ts index 217682b8..9912a2c8 100644 --- a/src/editor/shortcuts.ts +++ b/src/editor/shortcuts.ts @@ -1,7 +1,5 @@ -import { RemoveAnimationKeysCommand, RemoveAnimationTracksCommand } from '../core/commands/animationCommands'; -import { RemoveFrameCommand } from '../core/commands/frameCommands'; import { ui } from '../ui'; -import { confirmDeleteAsset, createContainer, removeGraphSelection, removeObjects } from './actions'; +import { createContainer, removeSelection } from './actions'; import type { EditorState } from './state'; export interface ShortcutActions { @@ -45,22 +43,7 @@ export function installShortcuts(state: EditorState, actions: ShortcutActions): if (s?.kind === 'object') createContainer(state, s.holderId, s.objectIds); return true; } - if (event.key === 'Delete' || event.key === 'Backspace') { - const s = state.selection; - if (s?.kind === 'object') removeObjects(state, s.holderId, s.objectIds); - else if (s?.kind === 'node') removeGraphSelection(state, s.assetId, s.nodeIds, s.annotationIds); - else if (s?.kind === 'frame') state.execute(new RemoveFrameCommand(s.assetId, s.frameId)); - else if (s?.kind === 'annotation') removeGraphSelection(state, s.assetId, [], s.annotationIds); - // The keys when some are picked, else the row itself – like a frame of a texture. - else if (s?.kind === 'track') - state.execute( - s.keys.length > 0 - ? new RemoveAnimationKeysCommand(s.assetId, s.keys) - : new RemoveAnimationTracksCommand(s.assetId, [s.trackId]), - ); - else if (s?.kind === 'asset') confirmDeleteAsset(state, s.assetId); - return s !== null; - } + if (event.key === 'Delete' || event.key === 'Backspace') return removeSelection(state); // Ctrl+Enter starts play and then holds/resumes it. A plain letter would be taken away from // the game, which owns the keyboard while playing. if (mod && event.key === 'Enter') { diff --git a/src/editor/variables/VariablesEditor.ts b/src/editor/variables/VariablesEditor.ts index 4e077ae2..fdc3d3e0 100644 --- a/src/editor/variables/VariablesEditor.ts +++ b/src/editor/variables/VariablesEditor.ts @@ -7,6 +7,7 @@ import type { Container } from 'pixi.js'; import { isVariableOwner } from '../../core/model/Asset'; import { findObject } from '../../core/model/hierarchy'; import { findAsset } from '../../core/model/Project'; +import { objectName } from '../../core/model/SceneObject'; import { Label, ScrollContainer, Theme, ui, VStack, HStack } from '../../ui'; import { assetSignature, buildVariables, refreshAssetSection } from '../inspector/assetSection'; import type { InspectorHost } from '../inspector/host'; @@ -31,7 +32,7 @@ function ownerOf(state: EditorState, target: Extract { const shapeIds = this.selectedShapeIds(); if (shapeIds.length === 0) return super.onKeyDown(event); this.endPen(); - this.state.execute( - shapeIds.length === 1 - ? new RemoveVectorShapeCommand(this.assetId, shapeIds[0]!) - : new RemoveVectorShapesCommand(this.assetId, shapeIds), - ); - this.state.select({ kind: 'asset', assetId: this.assetId }); + removeShapes(this.state, this.assetId, shapeIds); return true; } @@ -784,7 +778,7 @@ export class VectorArtCanvas extends SheetCanvas { if (this.target === 'stroke' && !shape.stroke) return 'Paint: this shape has no outline.'; return this.shownPaint() ? `Paint: drag the two handles of the ${part}.` - : `Paint: the ${part} is one colour – pick a ${this.target === 'fill' ? 'gradient or texture' : 'gradient'} in the inspector.`; + : `Paint: the ${part} is one color – pick a ${this.target === 'fill' ? 'gradient or texture' : 'gradient'} in the inspector.`; } private syncHint(asset: VectorArtAsset | null): void { diff --git a/src/runtime/World.ts b/src/runtime/World.ts index cdf9be61..82874e4a 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -11,7 +11,7 @@ import { referenceCandidates } from '../core/model/objectRefs'; import type { Cell } from '../core/model/tilemap'; import { descendantsBelow } from '../core/model/walk'; import { findAsset, findAssetOf, findScene, type Project } from '../core/model/Project'; -import { createSceneObject, type SceneObject } from '../core/model/SceneObject'; +import { createSceneObject, objectName, type SceneObject } from '../core/model/SceneObject'; import { apply, IDENTITY } from '../core/model/matrix'; import { hasTransformProps } from '../core/properties/PropertySchema'; import { carriedTransform, localMatrixOf, transformOf } from '../core/properties/transform'; @@ -247,7 +247,7 @@ export class World { } entity.assetId = asset.id; entity.type = asset.type; - entity.name = object.name || asset.name; + entity.name = objectName(object, asset); entity.x = object.position.x; entity.y = object.position.y; entity.z = object.position.z; diff --git a/src/ui/UIContext.ts b/src/ui/UIContext.ts index 7b7fe32a..17bd8ad9 100644 --- a/src/ui/UIContext.ts +++ b/src/ui/UIContext.ts @@ -36,6 +36,6 @@ export function initUI(app: Application, root: Container, overlay: Container): U } export function ui(): UIContext { - if (!context) throw new Error('UI not initialised – call initUI() first'); + if (!context) throw new Error('UI not initialized – call initUI() first'); return context; } diff --git a/src/ui/widgets/Dialog.ts b/src/ui/widgets/Dialog.ts index 8baca2b5..f8ff2509 100644 --- a/src/ui/widgets/Dialog.ts +++ b/src/ui/widgets/Dialog.ts @@ -12,10 +12,16 @@ export interface DialogButton { onClick?: () => void; } -/** A modal box with title, message and a right-aligned button row. Any button closes it. */ +/** + * A modal box with title, message and a right-aligned button row. Any button closes it, and Escape + * is the plain button – the one neither primary nor danger, the Cancel – so both lead back alike. + */ export class Dialog extends Popup { + private readonly cancel: (() => void) | undefined; + constructor(title: string, message: string, buttons: DialogButton[]) { super({ modal: true, padding: Theme.padding * 2, gap: Theme.padding }); + this.cancel = buttons.find((b) => !b.primary && !b.danger)?.onClick; this.minWidth = 320; this.content.add(new Label(title, { style: 'bodyBold' })); if (message) this.content.add(new Label(message, { style: 'muted' })); @@ -30,4 +36,8 @@ export class Dialog extends Popup { } this.content.add(row); } + + protected override dismiss(): void { + this.closeThen(this.cancel)(); + } } diff --git a/src/ui/widgets/Popup.ts b/src/ui/widgets/Popup.ts index c8248c6f..7439427f 100644 --- a/src/ui/widgets/Popup.ts +++ b/src/ui/widgets/Popup.ts @@ -151,10 +151,15 @@ export class Popup extends Widget { private readonly onEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.stopPropagation(); - this.close(); + this.dismiss(); } }; + /** What Escape does. A popup closes; a dialog makes it its Cancel. */ + protected dismiss(): void { + this.close(); + } + override measure(): Size { const c = this.content.measure(); return { w: Math.max(this.minWidth, c.w), h: Math.max(this.minHeight, c.h) }; diff --git a/src/viewport/items.ts b/src/viewport/items.ts index 3d1b2dba..774be42b 100644 --- a/src/viewport/items.ts +++ b/src/viewport/items.ts @@ -22,6 +22,7 @@ import { solidRects, } from '../core/model/tilemapCollision'; import { findAsset, findAssetOf, type ObjectHolder, type Project } from '../core/model/Project'; +import { objectName } from '../core/model/SceneObject'; import { fontFileOf, fontSmoothOf } from '../core/model/font'; import { resolveTexture, type TextureRef } from '../core/model/texture'; import type { FrameOutline } from '../core/model/frameOutline'; @@ -206,7 +207,7 @@ export function collectHolderItems( items.push({ id: object.id, type: asset.type, - name: object.name || asset.name, + name: objectName(object, asset), x: m.tx, y: m.ty, z, diff --git a/tests/core/dynamic-ports.test.ts b/tests/core/dynamic-ports.test.ts index 1d01d396..2634e567 100644 --- a/tests/core/dynamic-ports.test.ts +++ b/tests/core/dynamic-ports.test.ts @@ -13,7 +13,7 @@ import { } from '../../src/core/graph'; import { graphLookup } from '../../src/core/model/graphLookup'; import type { BehaviorAsset } from '../../src/core/model/Asset'; -import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers'; +import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture, fire } from '../helpers'; function sequenceFixture() { const fixture = makeProject(); @@ -76,7 +76,7 @@ describe('a Sequence sized by what is plugged into it', () => { edit.connect(node, port, edit.addNode('Log', { message: port.toUpperCase() }), 'exec'); const ctx = fakeContext(); - new GraphInterpreter(behavior.graph, graphLookup(fixture.project)).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph, graphLookup(fixture.project)), 'OnStart', ctx); expect(ctx.logs).toEqual(['A', 'B']); }); }); @@ -122,7 +122,7 @@ describe('a struct travelling through a nested behavior', () => { // One edge into the call, where two numbers would need two. expect(caller.graph.edges.filter((e) => e.to.node === call.id && e.to.port !== 'exec')).toHaveLength(1); const ctx = fakeContext({ variables: { result: 0 } }); - new GraphInterpreter(caller.graph, graphLookup(fixture.project)).fire('OnStart', ctx); + fire(new GraphInterpreter(caller.graph, graphLookup(fixture.project)), 'OnStart', ctx); expect(ctx.variables.result).toBe(42); }); }); diff --git a/tests/core/find-match.test.ts b/tests/core/find-match.test.ts index 2ace6b15..52f4d39f 100644 --- a/tests/core/find-match.test.ts +++ b/tests/core/find-match.test.ts @@ -14,7 +14,7 @@ import { } from '../../src/core/graph'; import type { PropertyValue } from '../../src/core/model/Properties'; import { blockFrames, BLOCK_PAD } from '../../src/editor/graph/blockFrames'; -import { fakeContext, graphEditor, makeBehavior, makeProject } from '../helpers'; +import { fakeContext, graphEditor, makeBehavior, makeProject, fire } from '../helpers'; /** A behavior with a list variable `names` and a Find over it, the Match it brought along beside it. */ function block() { @@ -56,7 +56,7 @@ function search(names: readonly PropertyValue[], wanted: PropertyValue, by: 'ite [none, 'text', miss, 'message'], ]); const ctx = fakeContext({ variables: { names: [...names] } }); - new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); return ctx.logs; } @@ -91,7 +91,7 @@ describe('Find … Match at run time', () => { [match, 'notFound', after, 'exec'], ]); const ctx = fakeContext({ variables: { names: ['a'] } }); - new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); expect(ctx.logs).toEqual(['Find: No Match tests its item']); }); }); diff --git a/tests/core/find-modes.test.ts b/tests/core/find-modes.test.ts index bc26a592..496f7dd1 100644 --- a/tests/core/find-modes.test.ts +++ b/tests/core/find-modes.test.ts @@ -13,7 +13,7 @@ import { import { graphLookup } from '../../src/core/model/graphLookup'; import type { PropertyValue } from '../../src/core/model/Properties'; import { paletteEntries, rankedEntries } from '../../src/editor/graph/paletteEntries'; -import { fakeContext, graphEditor, makeBehavior, makeProject } from '../helpers'; +import { fakeContext, graphEditor, makeBehavior, makeProject, fire } from '../helpers'; /** A behavior with a list variable `names`, a Find over it testing `item == wanted`, and its Match. */ function block(wanted: PropertyValue = 'b', mode?: FindMode) { @@ -54,7 +54,7 @@ function answer(mode: FindMode, names: readonly PropertyValue[], ports: string[] from = { node: log, port: 'exec' }; } const ctx = fakeContext({ variables: { names: [...names] } }); - new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); return ctx.logs; } diff --git a/tests/core/folded-edge.test.ts b/tests/core/folded-edge.test.ts index dc3990e7..c15771a8 100644 --- a/tests/core/folded-edge.test.ts +++ b/tests/core/folded-edge.test.ts @@ -14,7 +14,7 @@ import { foldMarkLabel, foldMarkRadius, } from '../../src/editor/graph/folding'; -import { fakeContext, graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers'; +import { fakeContext, graphEditor, makeBehavior, makeProject, roundTrip, fire } from '../helpers'; /** "On start → Move(self, dx = 7)": two edges, so a fold can hide one and leave the other. */ function mover() { @@ -38,7 +38,7 @@ describe('folding an edge', () => { expect(behavior.graph.edges.every((e) => e.folded === true)).toBe(true); const ctx = fakeContext(); - new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); expect(ctx.self.x).toBe(7); // And the edges themselves are untouched apart from the flag. expect(findAsset(project, behavior.id)).toBe(behavior); diff --git a/tests/core/graph.test.ts b/tests/core/graph.test.ts index c4be77e1..278c8e72 100644 --- a/tests/core/graph.test.ts +++ b/tests/core/graph.test.ts @@ -37,6 +37,7 @@ import { makeSpriteScene, place, roundTrip, + fire, } from '../helpers'; /** Adds "On key held(ArrowRight) → Move(owner, dx = speed × dt)" to a project, built through commands like the editor would. */ @@ -103,10 +104,10 @@ describe('GraphInterpreter', () => { const { behavior } = withMover(makeProject()); const interpreter = new GraphInterpreter(behavior.graph); const ctx = fakeContext({ variables: { speed: 100 } }); - const fired = interpreter.fire('OnKeyHeld', ctx, { dt: 0.5 }, (n) => n.params.key === 'ArrowRight'); + const fired = fire(interpreter, 'OnKeyHeld', ctx, { dt: 0.5 }, (n) => n.params.key === 'ArrowRight'); expect(fired).toBe(1); expect(ctx.owner!.x).toBe(50); - expect(interpreter.fire('OnKeyHeld', ctx, { dt: 0.5 }, (n) => n.params.key === 'ArrowLeft')).toBe(0); + expect(fire(interpreter, 'OnKeyHeld', ctx, { dt: 0.5 }, (n) => n.params.key === 'ArrowLeft')).toBe(0); expect(ctx.owner!.x).toBe(50); }); @@ -120,7 +121,7 @@ describe('GraphInterpreter', () => { [self, 'object', move, 'object'], ]); const ctx = fakeContext(); - new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); expect(ctx.self.x).toBe(7); expect(ctx.self.y).toBe(-3); }); @@ -137,7 +138,7 @@ describe('GraphInterpreter', () => { [clamp, 'result', move, 'dy'], ]); const ctx = fakeContext(); - new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); expect(ctx.self.x).toBe(4); // scale defaulted to 1, not 0 expect(ctx.self.y).toBe(1); // clamp(5, 0, 1) }); @@ -162,7 +163,7 @@ describe('GraphInterpreter', () => { const interpreter = new GraphInterpreter(behavior.graph); const run = (keys: string[]) => { const ctx = fakeContext({ variables: { speed: 100 }, dt: 0.5, isKeyDown: (k) => keys.includes(k) }); - interpreter.fire('OnUpdate', ctx, { dt: 0.5 }); + fire(interpreter, 'OnUpdate', ctx, { dt: 0.5 }); return { x: ctx.owner!.x, y: ctx.owner!.y }; }; expect(run([])).toEqual({ x: 0, y: 0 }); @@ -199,7 +200,7 @@ describe('GraphInterpreter', () => { previous = log; }); const ctx = fakeContext(); - new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); expect(ctx.logs).toEqual(['0.6', '5', '4', '2', '-1', '3']); }); @@ -226,7 +227,7 @@ describe('GraphInterpreter', () => { const interpreter = new GraphInterpreter(behavior.graph); const ctx = fakeContext({ variables: { count: 0 } }); - for (let i = 0; i < 5; i++) interpreter.fire('OnStart', ctx); + for (let i = 0; i < 5; i++) fire(interpreter, 'OnStart', ctx); expect(ctx.variables.count).toBe(3); expect(ctx.logs).toEqual(['then', 'then', 'then', 'else', 'else']); }); @@ -237,7 +238,7 @@ describe('GraphInterpreter', () => { const edges: string[] = []; const visited: string[] = []; interpreter.trace = { onEdge: (id) => edges.push(id), onNode: (id) => visited.push(id) }; - interpreter.fire('OnKeyHeld', fakeContext(), { dt: 1 }); + fire(interpreter, 'OnKeyHeld', fakeContext(), { dt: 1 }); expect(edges).toHaveLength(1); expect(visited).toContain(nodes.mul.id); expect(visited).toContain(nodes.move.id); @@ -253,7 +254,7 @@ describe('GraphInterpreter', () => { [a, 'a', b, 'exec'], [b, 'a', a, 'exec'], ]); - expect(() => new GraphInterpreter(behavior.graph).fire('OnStart', fakeContext())).toThrow( + expect(() => fire(new GraphInterpreter(behavior.graph), 'OnStart', fakeContext())).toThrow( /nested too deeply/, ); }); @@ -267,7 +268,7 @@ describe('GraphInterpreter', () => { [start, 'exec', repeat, 'exec'], [repeat, 'body', log, 'exec'], ]); - expect(() => new GraphInterpreter(behavior.graph).fire('OnStart', fakeContext())).toThrow(/step limit/); + expect(() => fire(new GraphInterpreter(behavior.graph), 'OnStart', fakeContext())).toThrow(/step limit/); }); }); @@ -287,7 +288,7 @@ describe('Object by name', () => { edit.wire([[join, 'text', byName, 'name']]); } const ctx = fakeContext({ findByName: (name) => (name === 'Target' ? fakeEntity('Target') : null) }); - new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); return ctx.logs; } @@ -327,7 +328,7 @@ describe('Repeat', () => { it('runs the body once per round, each round seeing its own index', () => { const { behavior } = summingLoop(5); const ctx = fakeContext({ variables: { count: 0 } }); - new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); // 0+1+2+3+4. A stale data cache would add the first round's index five times (0) instead. expect(ctx.variables.count).toBe(10); }); @@ -335,7 +336,7 @@ describe('Repeat', () => { it('runs nothing at all for a count of zero', () => { const { behavior } = summingLoop(0); const ctx = fakeContext({ variables: { count: 7 } }); - new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); expect(ctx.variables.count).toBe(7); }); @@ -344,7 +345,7 @@ describe('Repeat', () => { const done = edit.addNode('Log', { message: 'done' }); edit.wire([[nodes.repeat, 'done', done, 'exec']]); const ctx = fakeContext({ variables: { count: 0 } }); - new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); expect({ count: ctx.variables.count, logs: ctx.logs }).toEqual({ count: 3, logs: ['done'] }); }); @@ -364,7 +365,7 @@ describe('Repeat', () => { [add, 'result', set, 'count'], ]); const ctx = fakeContext({ variables: { count: 0 } }); - new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); expect(ctx.variables.count).toBe(12); }); @@ -387,7 +388,7 @@ describe('Repeat', () => { [afterAdd, 'result', after, 'count'], ]); const ctx = fakeContext({ variables: { count: 0 } }); - new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); // 3 rounds, then ×10 on the *current* count – a cache left over from the last round would give 20. expect(ctx.variables.count).toBe(30); }); @@ -429,7 +430,7 @@ describe('For each', () => { ]); } const ctx = fakeContext({ variables: { names: [...names] } }); - new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); return ctx.logs; } diff --git a/tests/core/hierarchy.test.ts b/tests/core/hierarchy.test.ts index 3697d0e0..7cbe4161 100644 --- a/tests/core/hierarchy.test.ts +++ b/tests/core/hierarchy.test.ts @@ -26,6 +26,7 @@ import { place, roundTrip, startWorld, + fire, } from '../helpers'; /** Three boxes, all top level: a at (100,100,z1), b at (10,0), c at (0,5). */ @@ -264,7 +265,7 @@ describe('runtime hierarchy', () => { ], }; const ctx = fakeContext({ self: child, owner: parent, variables: {} }); - new GraphInterpreter(graph).fire('OnStart', ctx); + fire(new GraphInterpreter(graph), 'OnStart', ctx); expect(ctx.logs).toEqual(['105', '5', '100']); }); }); diff --git a/tests/core/http-store.test.ts b/tests/core/http-store.test.ts index 3ea9f970..823e8704 100644 --- a/tests/core/http-store.test.ts +++ b/tests/core/http-store.test.ts @@ -105,17 +105,12 @@ describe('HttpFileStore', () => { ); }); - it('lists and deletes', async () => { + it('lists', async () => { stubFetch(JSON.stringify([{ file: FILE, width: 8, height: 4, bytes: 12, updatedAt: 'now' }]), { status: 200, }); await expect(new HttpFileStore('/api', 'textures').list()).resolves.toEqual([ { file: FILE, width: 8, height: 4, bytes: 12, updatedAt: 'now' }, ]); - - const mock = stubFetch(null, { status: 204 }); - await new HttpFileStore('/api', 'textures').delete(FILE); - expect(mock.mock.calls[0]![0]).toBe(`/api?r=${encodeURIComponent(`textures/${FILE}`)}`); - expect(mock.mock.calls[0]![1]?.method).toBe('DELETE'); }); }); diff --git a/tests/core/inline-defaults.test.ts b/tests/core/inline-defaults.test.ts index 8f3791e9..ee357c40 100644 --- a/tests/core/inline-defaults.test.ts +++ b/tests/core/inline-defaults.test.ts @@ -8,7 +8,7 @@ import { } from '../../src/core/graph'; import { BUILT_IN_NODES } from '../../src/core/graph/nodes'; import { graphLookup } from '../../src/core/model/graphLookup'; -import { fakeContext, graphEditor, makeBehavior, makeProject } from '../helpers'; +import { fakeContext, graphEditor, makeBehavior, makeProject, fire } from '../helpers'; const untouched = { params: {} }; @@ -31,7 +31,7 @@ function logged( ...(source === text ? [] : ([[source, port, text, 'value']] as const)), ]); const ctx = fakeContext(); - new GraphInterpreter(behavior.graph, graphLookup(fixture.project)).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph, graphLookup(fixture.project)), 'OnStart', ctx); return ctx.logs; } diff --git a/tests/core/keep-state.test.ts b/tests/core/keep-state.test.ts index e2804b9e..ea01fdaf 100644 --- a/tests/core/keep-state.test.ts +++ b/tests/core/keep-state.test.ts @@ -32,6 +32,7 @@ import { type ProjectFixture, roundTrip, startWorld, + fire, } from '../helpers'; /** @@ -98,7 +99,7 @@ function threeCalls(fixture: ProjectFixture, callee: BehaviorAsset): unknown[][] const ctx = fakeContext({ variables: { r0: 0, r1: 0 } }); const seen: unknown[][] = []; for (let i = 0; i < 3; i++) { - interpreter.fire('OnStart', ctx); + fire(interpreter, 'OnStart', ctx); seen.push([ctx.variables.r0, ctx.variables.r1]); } return seen; @@ -151,9 +152,9 @@ describe('Keep on a behavior variable (#229)', () => { const caller = callerOf(fixture, counter(fixture), 2); const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project)); const ctx = fakeContext({ variables: { r0: 0, r1: 0 } }); - interpreter.fire('OnStart', ctx); + fire(interpreter, 'OnStart', ctx); expect([ctx.variables.r0, ctx.variables.r1]).toEqual([1, 1]); - interpreter.fire('OnStart', ctx); + fire(interpreter, 'OnStart', ctx); expect([ctx.variables.r0, ctx.variables.r1]).toEqual([2, 2]); // The caller's own bag never learns of `count`: the kept values stay the callee's. expect('count' in ctx.variables).toBe(false); @@ -196,8 +197,8 @@ describe('Keep on a behavior variable (#229)', () => { const caller = callerOf(fixture, middle, 2); const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project)); const ctx = fakeContext({ variables: { r0: 0, r1: 0 } }); - interpreter.fire('OnStart', ctx); - interpreter.fire('OnStart', ctx); + fire(interpreter, 'OnStart', ctx); + fire(interpreter, 'OnStart', ctx); // One shared inner counter would have handed out 1, 2, 3, 4. expect([ctx.variables.r0, ctx.variables.r1]).toEqual([2, 2]); }); @@ -234,8 +235,8 @@ describe('Keep on a behavior variable (#229)', () => { ); const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project)); const ctx = fakeContext(); - interpreter.fire('OnStart', ctx); - interpreter.fire('OnStart', ctx); + fire(interpreter, 'OnStart', ctx); + fire(interpreter, 'OnStart', ctx); expect(ctx.logs).toEqual(['first', 'first']); // A local machine of the callee is none of the caller's business either. expect(ctx.states).toEqual({}); diff --git a/tests/core/live-values.test.ts b/tests/core/live-values.test.ts index 8098313b..5cc7ce1e 100644 --- a/tests/core/live-values.test.ts +++ b/tests/core/live-values.test.ts @@ -15,7 +15,15 @@ import { graphLookup } from '../../src/core/model/graphLookup'; import { BehaviorSystem } from '../../src/runtime/systems/BehaviorSystem'; import { Input } from '../../src/runtime/systems/Input'; import { World } from '../../src/runtime/World'; -import { fakeContext, graphEditor, makeBehavior, makeProject, makeSpriteScene, place } from '../helpers'; +import { + fakeContext, + graphEditor, + makeBehavior, + makeProject, + makeSpriteScene, + place, + fire, +} from '../helpers'; function recorder() { const seen = new Map>>(); @@ -47,7 +55,7 @@ describe('live values', () => { const interpreter = new GraphInterpreter(behavior.graph, graphLookup(fixture.project)); interpreter.trace = listener; const ctx = fakeContext({ variables: { count: 5, other: 9 } }); - interpreter.fire('OnStart', ctx); + fire(interpreter, 'OnStart', ctx); ctx.variables.count = 99; const values = seen.get(behavior.graph)!; @@ -77,7 +85,7 @@ describe('live values', () => { const { seen, listener } = recorder(); const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project)); interpreter.trace = listener; - interpreter.fire('OnStart', fakeContext()); + fire(interpreter, 'OnStart', fakeContext()); expect(seen.get(callee.graph)?.get(start.id)).toEqual({ in1: 21 }); expect(seen.get(callee.graph)?.get(times.id)).toEqual({ result: 42 }); diff --git a/tests/core/nested-behavior.test.ts b/tests/core/nested-behavior.test.ts index 7b266b2e..19c77241 100644 --- a/tests/core/nested-behavior.test.ts +++ b/tests/core/nested-behavior.test.ts @@ -17,7 +17,7 @@ import { import type { BehaviorAsset } from '../../src/core/model/Asset'; import { graphLookup } from '../../src/core/model/graphLookup'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; -import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers'; +import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture, fire } from '../helpers'; /** * A behavior that doubles what it is given: `in1` → Multiply by 2 → `out1`, reached through its @@ -57,7 +57,7 @@ function callerOf(fixture: ProjectFixture, callee: BehaviorAsset, input: number) function runOnStart(fixture: ProjectFixture, asset: BehaviorAsset, variables: Record = {}) { const interpreter = new GraphInterpreter(asset.graph, graphLookup(fixture.project)); const ctx = fakeContext({ variables }); - interpreter.fire('OnStart', ctx); + fire(interpreter, 'OnStart', ctx); return { ctx, interpreter }; } @@ -367,7 +367,7 @@ describe('declared ports', () => { const caller = loaded.assets.find((a) => a.name === 'Caller') as BehaviorAsset; const interpreter = new GraphInterpreter(caller.graph, graphLookup(loaded)); const ctx = fakeContext({ variables: { result: 0 } }); - interpreter.fire('OnStart', ctx); + fire(interpreter, 'OnStart', ctx); expect(ctx.variables.result).toBe(12); }); diff --git a/tests/core/nodes.test.ts b/tests/core/nodes.test.ts index ff90925b..5f2c11b6 100644 --- a/tests/core/nodes.test.ts +++ b/tests/core/nodes.test.ts @@ -8,6 +8,7 @@ import { makeSpriteScene, place, startWorld, + fire, } from '../helpers'; // Node-by-node behaviour lives in node-catalog.test.ts; what is left here needs a running @@ -73,7 +74,7 @@ describe('Spawn node', () => { return fakeEntity('Crate', position.x); }, }); - new GraphInterpreter(graph).fire('OnStart', ctx); + fire(new GraphInterpreter(graph), 'OnStart', ctx); expect(calls).toEqual([{ assetId: 'crate', position: { x: 5, y: 7, z: 0 }, parent: null }]); expect(ctx.logs).toEqual(['Crate']); // the spawned object flowed into Log }); @@ -84,10 +85,10 @@ describe('Spawn node', () => { ...graph, nodes: graph.nodes.map((n) => (n.id === 'spawn' ? { ...n, params: {} } : n)), }; - new GraphInterpreter(noAsset).fire('OnStart', ctx); + fire(new GraphInterpreter(noAsset), 'OnStart', ctx); expect(ctx.logs).toEqual(['Spawn: no asset selected', '']); const ctx2 = fakeContext({ spawn: () => null }); - new GraphInterpreter(graph).fire('OnStart', ctx2); + fire(new GraphInterpreter(graph), 'OnStart', ctx2); expect(ctx2.logs[0]).toBe('Spawn: asset crate not found'); }); }); diff --git a/tests/core/object-groups.test.ts b/tests/core/object-groups.test.ts index 63346407..ecc49937 100644 --- a/tests/core/object-groups.test.ts +++ b/tests/core/object-groups.test.ts @@ -21,6 +21,7 @@ import { } from '../../src/core/model/hierarchy'; import { instanceChildId, resolvedHolder } from '../../src/core/model/instance'; import { findAsset, findScene } from '../../src/core/model/Project'; +import { groupName, objectName } from '../../src/core/model/SceneObject'; import { serializeProject } from '../../src/core/store/serialize'; import { makeSpriteScene, place, roundTrip } from '../helpers'; @@ -211,3 +212,16 @@ describe('hierarchy groups (#241)', () => { expect(content).toEqual([x, z, y].map((id) => instanceChildId(instance, id!))); }); }); + +describe('the names the lists show', () => { + it('call an object by its own name, else by its asset, and "?" once the asset is gone', () => { + expect(objectName({ name: 'Hero' }, { name: 'Sprite' })).toBe('Hero'); + expect(objectName({ name: '' }, { name: 'Sprite' })).toBe('Sprite'); + expect(objectName({ name: '' }, undefined)).toBe('?'); + }); + + it('call a group that was never named a "Group"', () => { + expect(groupName({ id: 'g', name: '' })).toBe('Group'); + expect(groupName({ id: 'g', name: 'Crates' })).toBe('Crates'); + }); +}); diff --git a/tests/core/port-splits.test.ts b/tests/core/port-splits.test.ts index 27d8ff06..9c9cd174 100644 --- a/tests/core/port-splits.test.ts +++ b/tests/core/port-splits.test.ts @@ -26,7 +26,7 @@ import type { BehaviorAsset, StructAsset } from '../../src/core/model/Asset'; import { graphLookup } from '../../src/core/model/graphLookup'; import { outputStructType, structTypeUsages } from '../../src/core/model/structTypes'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; -import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers'; +import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture, fire } from '../helpers'; /** `Loot` = { Name: "Coin", Amount: 1 } */ function lootType(f: ProjectFixture): StructAsset { @@ -126,7 +126,7 @@ describe('splitting an output', () => { { Name: 'Gem', Amount: 7 }, ]; const ctx = fakeContext({ variables: { bag } }); - new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); expect(ctx.logs).toEqual(['7']); }); diff --git a/tests/core/property-ports.test.ts b/tests/core/property-ports.test.ts index ed87b93a..312b38ca 100644 --- a/tests/core/property-ports.test.ts +++ b/tests/core/property-ports.test.ts @@ -20,7 +20,7 @@ import { import type { BehaviorAsset } from '../../src/core/model/Asset'; import { graphLookup } from '../../src/core/model/graphLookup'; import type { PropertyBag } from '../../src/core/model/Properties'; -import { fakeContext, fakeEntity, graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers'; +import { fakeContext, fakeEntity, graphEditor, makeBehavior, makeProject, roundTrip, fire } from '../helpers'; const SPRITE = { [OBJECT_TYPE_PARAM]: 'sprite' }; const set = (key: string): DeclaredPort => ({ key, kind: 'number', write: true }); @@ -46,7 +46,7 @@ function onSelf(ports: DeclaredPort[], params: PropertyBag = SPRITE) { function run(behavior: BehaviorAsset, fixture: ReturnType, props: PropertyBag) { const self = { ...fakeEntity('self'), props: { ...props } }; const ctx = fakeContext({ self, variables: { seen: 0 } }); - new GraphInterpreter(behavior.graph, graphLookup(fixture.project)).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph, graphLookup(fixture.project)), 'OnStart', ctx); return { props: self.props, seen: ctx.variables.seen }; } @@ -81,7 +81,7 @@ describe('a node that reads and writes properties of an object', () => { ]); const self_ = { ...fakeEntity('self'), props: { coins: 5 } }; const interpreter = new GraphInterpreter(behavior.graph, graphLookup(fixture.project)); - for (let i = 0; i < 2; i++) interpreter.fire('OnStart', fakeContext({ self: self_ })); + for (let i = 0; i < 2; i++) fire(interpreter, 'OnStart', fakeContext({ self: self_ })); expect(self_.props.coins).toBe(7); }); diff --git a/tests/core/shaders.test.ts b/tests/core/shaders.test.ts index 2b8bdfd4..360a4fd1 100644 --- a/tests/core/shaders.test.ts +++ b/tests/core/shaders.test.ts @@ -329,7 +329,7 @@ describe('compileShader', () => { wrong.link(doubled, 'result', out, 'alpha'); const build = compileShader(wrong.asset().graph); expect(build.compiled).toBeNull(); - expect(build.problems).toEqual({ [out]: '"alpha" needs a number, not a colour' }); + expect(build.problems).toEqual({ [out]: '"alpha" needs a number, not a color' }); }); it('names the problem on the node, and compiles nothing while there is one', () => { @@ -345,8 +345,8 @@ describe('compileShader', () => { s.link(mixed, 'result', output, 'color'); let build = compileShader(s.asset().graph); expect(build.compiled).toBeNull(); - expect(build.problems).toEqual({ [mixed]: '"t" needs a number, not a colour' }); - expect(build.reason).toBe('"t" needs a number, not a colour'); + expect(build.problems).toEqual({ [mixed]: '"t" needs a number, not a color' }); + expect(build.reason).toBe('"t" needs a number, not a color'); const dup = shaderEditor(f, 'Dup'); dup.add(SHADER_OUTPUT); diff --git a/tests/core/state-machine.test.ts b/tests/core/state-machine.test.ts index 98d028be..01bff98f 100644 --- a/tests/core/state-machine.test.ts +++ b/tests/core/state-machine.test.ts @@ -51,6 +51,7 @@ import { type ProjectFixture, roundTrip, startWorld, + fire, } from '../helpers'; /** A machine "Doors" with the states Open and Closed, added the way the tab does it. */ @@ -250,12 +251,12 @@ describe('a running state machine', () => { const behavior = build(f, machine.id, machine.ids); const ctx = fakeContext(); const interpreter = new GraphInterpreter(behavior.graph, graphLookup(f.project)); - interpreter.fire('OnUpdate', ctx); + fire(interpreter, 'OnUpdate', ctx); expect(ctx.logs).toEqual([]); - interpreter.fire('OnStart', ctx); + fire(interpreter, 'OnStart', ctx); expect(ctx.states).toEqual({ [machine.id]: machine.ids[0] }); - interpreter.fire('OnUpdate', ctx); - interpreter.fire('OnUpdate', ctx); + fire(interpreter, 'OnUpdate', ctx); + fire(interpreter, 'OnUpdate', ctx); expect(ctx.logs).toEqual(['open', 'closed']); expect(ctx.states).toEqual({ [machine.id]: machine.ids[1] }); }); @@ -296,7 +297,7 @@ describe('a running state machine', () => { [set, 'exec', call, 'exec'], ]); const ctx = fakeContext(); - new GraphInterpreter(outer.graph, graphLookup(f.project)).fire('OnStart', ctx); + fire(new GraphInterpreter(outer.graph, graphLookup(f.project)), 'OnStart', ctx); expect(ctx.states).toEqual({ [machine.id]: machine.ids[0] }); expect(ctx.logs).toEqual([]); }); @@ -328,12 +329,12 @@ describe('a running state machine', () => { ]); const ctx = fakeContext({ variables: { doors: machine.id } }); const interpreter = new GraphInterpreter(behavior.graph, graphLookup(f.project)); - interpreter.fire('OnStart', ctx); + fire(interpreter, 'OnStart', ctx); // The name moved the machine; the one nothing answers to took "not found" instead of ending. expect(ctx.states).toEqual({ [machine.id]: machine.ids[1] }); expect(ctx.logs).toEqual(['no such state']); // The branch has no state outputs at all here, so even a state that is set leaves through else. - interpreter.fire('OnUpdate', ctx); + fire(interpreter, 'OnUpdate', ctx); expect(ctx.logs).toEqual(['no such state', 'unknown to the graph']); }); @@ -566,7 +567,7 @@ describe('reading the current state', () => { [current, 'name', log, 'message'], ]); const ctx = fakeContext(); - new GraphInterpreter(behavior.graph, graphLookup(f.project)).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph, graphLookup(f.project)), 'OnStart', ctx); expect(ctx.logs).toEqual(['Open']); }); @@ -657,7 +658,7 @@ describe('transitions between states', () => { [set, 'exec', next, 'exec'], ]); const ctx = fakeContext(); - new GraphInterpreter(behavior.graph, graphLookup(f.project)).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph, graphLookup(f.project)), 'OnStart', ctx); expect(ctx.states).toEqual({ [machine.id]: closed }); }); diff --git a/tests/core/variable-node.test.ts b/tests/core/variable-node.test.ts index e547a58d..5b1fd258 100644 --- a/tests/core/variable-node.test.ts +++ b/tests/core/variable-node.test.ts @@ -5,7 +5,7 @@ import { AddVariableCommand, RenameVariableCommand } from '../../src/core/comman import { type GraphNode, GraphInterpreter, inlineValue, NO_LOOKUP, resolveDef } from '../../src/core/graph'; import type { BehaviorAsset } from '../../src/core/model/Asset'; import { graphLookup } from '../../src/core/model/graphLookup'; -import { fakeContext, graphEditor, makeBehavior, makeProject } from '../helpers'; +import { fakeContext, graphEditor, makeBehavior, makeProject, fire } from '../helpers'; function setup(variables = ['count', 'copy']) { const fixture = makeProject(); @@ -19,7 +19,7 @@ function run( variables: Record = { count: 5, copy: 0 }, ) { const ctx = fakeContext({ variables: { ...variables } }); - new GraphInterpreter(behavior.graph, graphLookup(fixture.project)).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph, graphLookup(fixture.project)), 'OnStart', ctx); return ctx.variables; } diff --git a/tests/core/variable-ports.test.ts b/tests/core/variable-ports.test.ts index 4d820695..f9a6c7cc 100644 --- a/tests/core/variable-ports.test.ts +++ b/tests/core/variable-ports.test.ts @@ -21,7 +21,7 @@ import { } from '../../src/core/graph'; import type { BehaviorAsset } from '../../src/core/model/Asset'; import { graphLookup } from '../../src/core/model/graphLookup'; -import { fakeContext, graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers'; +import { fakeContext, graphEditor, makeBehavior, makeProject, roundTrip, fire } from '../helpers'; /** `px` and `py` are set in one node and read back in another, their sum landing in `sum`. */ function bothWays() { @@ -50,7 +50,7 @@ function run( ) { const interpreter = new GraphInterpreter(behavior.graph, graphLookup(fixture.project)); const ctx = fakeContext({ variables }); - interpreter.fire('OnStart', ctx); + fire(interpreter, 'OnStart', ctx); return ctx.variables; } @@ -147,7 +147,7 @@ describe('a node that reads or writes several variables at once', () => { ]); const ctx = fakeContext({ variables: { piece: { shape: 3, rot: 0 } } }); - new GraphInterpreter(behavior.graph, graphLookup(fixture.project)).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph, graphLookup(fixture.project)), 'OnStart', ctx); expect(ctx.variables.piece).toEqual({ shape: 3, rot: 2 }); }); }); diff --git a/tests/editor/texture-import.test.ts b/tests/editor/texture-import.test.ts index 2c13a775..56d6fc77 100644 --- a/tests/editor/texture-import.test.ts +++ b/tests/editor/texture-import.test.ts @@ -127,7 +127,7 @@ describe('importStoredImage', () => { const FILE = `${'a'.repeat(64)}.png`; const image = { file: FILE, name: '', width: 64, height: 48, bytes: 900 }; /** The picker takes the image from the list; nothing here ever uploads. */ - const textures = { upload: vi.fn(), list: vi.fn(), delete: vi.fn() } as unknown as TextureStore; + const textures = { upload: vi.fn(), list: vi.fn() } as unknown as TextureStore; function setup(): EditorState { const state = new EditorState(); diff --git a/tests/helpers.ts b/tests/helpers.ts index f3e147d3..3efd806e 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -22,6 +22,7 @@ import { VARIABLES, VARIABLES_PARAM, } from '../src/core/graph'; +import type { GraphInterpreter } from '../src/core/graph/GraphInterpreter'; import type { BehaviorAsset, SceneAsset, VariablesAsset } from '../src/core/model/Asset'; import { findObject } from '../src/core/model/hierarchy'; import { worldPosition } from '../src/core/properties/transform'; @@ -289,6 +290,22 @@ export function runDef( return def.run!(ctx, inputs, params, def, entry, lookup); } +/** + * Runs every event node of `type` in the graph, in the order the graph holds them, and answers how + * many ran. A game orders its events across all behaviors instead (#264); a test of one graph does not. + */ +export function fire( + interpreter: GraphInterpreter, + type: string, + ctx: NodeContext, + outputs: Record = {}, + filter?: (node: GraphNode) => boolean, +): number { + const nodes = interpreter.eventNodes(type).filter((node) => !filter || filter(node)); + for (const node of nodes) interpreter.pulse(node, ctx, outputs); + return nodes.length; +} + /** Runs one node definition in isolation and returns its data outputs. */ export function runNode( type: string, diff --git a/tests/php/accounts.test.php b/tests/php/accounts.test.php index 73baf951..683f301e 100644 --- a/tests/php/accounts.test.php +++ b/tests/php/accounts.test.php @@ -8,6 +8,7 @@ declare(strict_types=1); */ require __DIR__ . '/../../api/lib/AccountRules.php'; +require __DIR__ . '/../../api/lib/AtomicFile.php'; require __DIR__ . '/../../api/lib/Store.php'; require __DIR__ . '/../../api/lib/UserStore.php'; @@ -112,6 +113,43 @@ check('transfer keeps the revision', $moved['revision'] === 2, $moved['revision' $other = (new Store($projects, 'carl'))->create('Carl\'s game'); check('an administrator deletes somebody else\'s project', $admin->delete($other['id']) && $admin->listOf('carl') === []); +// The web server serves `data/` as static files, so a readable project would skip the sign-in. +check('a project file is not readable for the web server', (fileperms("{$projects}/{$id}.json") & 0o007) === 0); + +// --- Store: saves at the same time ------------------------------------------------------------- + +// Every worker saves with the revision it just read and tries again when it was beaten to it, so +// no save may be lost: each one that succeeded has to show in the revision. +if (!function_exists('pcntl_fork')) { + echo "SKIP saves at the same time (this PHP has no pcntl)\n"; +} else { + $raceDir = "{$dir}/race"; + $raceId = (new Store($raceDir, 'anna'))->create('Race')['id']; + $workers = 8; + $rounds = 40; + $children = []; + for ($w = 0; $w < $workers; $w++) { + $pid = pcntl_fork(); + if ($pid === 0) { + $store = new Store($raceDir, 'anna'); + for ($won = 0; $won < $rounds;) { + try { + $store->save($raceId, $store->load($raceId)['revision'], ['name' => 'Race', 'by' => $w]); + $won++; + } catch (StaleRevisionException) { + } + } + exit(0); + } + $children[] = $pid; + } + foreach ($children as $pid) { + pcntl_waitpid($pid, $status); + } + $final = (new Store($raceDir, 'anna'))->load($raceId)['revision']; + check("{$workers} tabs saving at once lose none of the saves", $final === 1 + $workers * $rounds, $final); +} + // --- AccountRules ------------------------------------------------------------------------------ check('renaming yourself is refused', AccountRules::renameRefusal('boss', 'BOSS') !== null); diff --git a/tests/php/releases.test.php b/tests/php/releases.test.php index d91be487..ebb41db9 100644 --- a/tests/php/releases.test.php +++ b/tests/php/releases.test.php @@ -6,6 +6,7 @@ declare(strict_types=1); * with `php tests/php/releases.test.php` and reads the last line. */ +require __DIR__ . '/../../api/lib/AtomicFile.php'; require __DIR__ . '/../../api/lib/Store.php'; require __DIR__ . '/../../api/lib/ReleaseStore.php'; diff --git a/tests/php/snapshots.test.php b/tests/php/snapshots.test.php index 72d0e627..63704886 100644 --- a/tests/php/snapshots.test.php +++ b/tests/php/snapshots.test.php @@ -6,6 +6,7 @@ declare(strict_types=1); * with `php tests/php/snapshots.test.php` and reads the last line. */ +require __DIR__ . '/../../api/lib/AtomicFile.php'; require __DIR__ . '/../../api/lib/Store.php'; require __DIR__ . '/../../api/lib/SnapshotStore.php'; @@ -36,6 +37,7 @@ $second = $snapshots->create('p1', ['revision' => 5] + $doc, 'second'); check('a snapshot keeps its name and the revision it copied', $first['name'] === 'first' && $first['revision'] === 4, $first); check('two snapshots get two ids', $first['id'] !== $second['id']); check('the summary carries no project', !isset($first['project'])); +check('the snapshot file is not readable for the web server', (fileperms("{$dir}/p1/{$first['id']}.json") & 0o007) === 0); $listed = $snapshots->list('p1'); check( diff --git a/tools/e2e/12-textures.mjs b/tools/e2e/12-textures.mjs index cb06c67a..a8b60f60 100644 --- a/tools/e2e/12-textures.mjs +++ b/tools/e2e/12-textures.mjs @@ -416,6 +416,15 @@ export async function textures(t) { (await json('__gm.state.notice')) === 'Added the image to the project.', JSON.stringify(await json('__gm.state.notice')), ); + // Bytes that are no picture are the caller's mistake: a 422 that says so, not a server error. + const refusedUpload = await evaluate(`fetch('/game-maker/api/index.php?r=textures&name=x.png', + { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: 'hello' }) + .then(async (r) => ({ status: r.status, error: (await r.json()).error }))`); + check( + 'bytes that are no picture are turned away with 422, not a server error', + refusedUpload.status === 422 && refusedUpload.error === 'Not a readable image', + JSON.stringify(refusedUpload), + ); // Reusing a file already in the project must not make a second asset on it – two assets on one // picture would disagree about its filter. @@ -486,4 +495,6 @@ export async function textures(t) { (await json("__gm.state.project.assets.filter((a) => a.type === 'sprite').length")) === spritesBefore, ); } + // Saved before the next step: one that navigates would hang at the unsaved-changes prompt. + await t.saved(); } diff --git a/tools/e2e/47-vectorart.mjs b/tools/e2e/47-vectorart.mjs index b7900df2..6a1aa025 100644 --- a/tools/e2e/47-vectorart.mjs +++ b/tools/e2e/47-vectorart.mjs @@ -398,6 +398,24 @@ export async function vectorArt(t) { JSON.stringify({ cross, afterCross, selected: await json('__gm.vectorArt.selected()') }), ); + // Delete takes a shape away however it was picked. A row of the list leaves nothing focused, so the + // key reaches the editor's shortcuts rather than the canvas. + await clickText('Add shape'); + await settle(); + const spare = (await shapeIds()).at(-1); + const spareRow = await json(`__gm.vectorArt.shapeRowAt(${JSON.stringify(spare)})`); + await click(spareRow.x + spareRow.w / 2, spareRow.y + spareRow.h / 2); + await settle(); + const pickedSpare = await json('__gm.vectorArt.selected()'); + await press('Delete'); + await settle(); + const afterDelete = await shapeIds(); + check( + 'Delete removes a shape picked in the list', + pickedSpare === spare && afterDelete.length === before47e && !afterDelete.includes(spare), + JSON.stringify({ spare, pickedSpare, afterDelete }), + ); + // 47e-ii. The duplicate mark puts a copy right above its shape and selects it; a double-click on // a name edits it in the row. Both are undone again, the blocks after count on the shapes. const fieldName = (await shapeOf('va-field')).name; @@ -883,7 +901,7 @@ export async function vectorArt(t) { JSON.stringify(addedPaint?.stops), ); - const stopField = await json("__gm.inspectorField('Stop colour', true)"); + const stopField = await json("__gm.inspectorField('Stop color', true)"); await click(stopField.x, stopField.y); await press('a', { ctrl: true }); await typeText('ff0000'); @@ -892,7 +910,7 @@ export async function vectorArt(t) { const reddened = (await sky())?.paint; const redMiddle = await tabPixel(128, 128); check( - 'the Stop colour row recolours the picked stop, and the middle of the shape turns red', + 'the Stop color row recolors the picked stop, and the middle of the shape turns red', reddened?.stops[2]?.color === 0xff0000 && colourDistance(redMiddle, '#ff0000') < 24, JSON.stringify({ stops: reddened?.stops, redMiddle }), ); diff --git a/tools/e2e/49-users.mjs b/tools/e2e/49-users.mjs index 5b1880ac..c67fbcd5 100644 --- a/tools/e2e/49-users.mjs +++ b/tools/e2e/49-users.mjs @@ -99,12 +99,23 @@ export async function users(t) { JSON.stringify(userMenu), ); // A session of its own: step 45 changes the password of `e2e`, which ends the tools' cached one. - const refused = await call(await logIn(baseUrl, E2E_USER, await e2ePassword()), 'GET', 'users'); + const userCookie = await logIn(baseUrl, E2E_USER, await e2ePassword()); + const refused = await call(userCookie, 'GET', 'users'); check( `the API answers ${E2E_USER} 403 on the accounts`, refused.status === 403 && (await refused.json()).error === 'Administrators only', String(refused.status), ); + // An image, font or sound is shared, and a project of another account may wear it. The name is + // valid but nobody's, so nothing is deleted either way. + const nobodys = `textures/${'0'.repeat(64)}.png`; + const byUser = (await call(userCookie, 'DELETE', nobodys)).status; + const byAdmin = (await asAdmin('DELETE', nobodys)).status; + check( + 'only an administrator may delete a shared file', + byUser === 403 && byAdmin === 404, + JSON.stringify({ byUser, byAdmin }), + ); // 49b. Signed in as the administrator, the fixture – which belongs to `e2e` – opens all the // same, and the menu has the user management before the plugins. diff --git a/tools/e2e/50-snapshots.mjs b/tools/e2e/50-snapshots.mjs index c2a379e4..51fa8857 100644 --- a/tools/e2e/50-snapshots.mjs +++ b/tools/e2e/50-snapshots.mjs @@ -97,7 +97,27 @@ export async function snapshots(t) { revision: await json('__gm.state.revision'), }; - // 50c. Restore puts the snapshot back, keeps the renamed stand as "Before restoring …", reloads + // 50c. Escape on the question is its Cancel: the list comes back and nothing is restored. + const cameBack = (expression, label) => + waitUntil(expression, { label }).then( + () => true, + () => false, + ); + await openSnapshots(); + await clickLast('Clean start'); + await clickLast('Restore'); + await press('Escape'); + const listBack = await cameBack('__gm.snapshots() !== null', 'the list came back'); + check( + 'Escape on "Restore snapshot?" leads back to the list and restores nothing', + listBack && + (await json('__gm.state.project.name')) === RENAMED && + (await json('__gm.state.revision')) === later.revision, + JSON.stringify({ listBack, name: await json('__gm.state.project.name') }), + ); + if (listBack) await clickLast('Cancel'); + + // Restore puts the snapshot back, keeps the renamed stand as "Before restoring …", reloads // the project with an empty undo history, and tells so. await openSnapshots(); await clickLast('Clean start'); @@ -159,8 +179,35 @@ export async function snapshots(t) { ); await clickLast('Cancel'); - // 50f. Deleting the project deletes its snapshots: the route answers 404 afterwards. - await api('DELETE', `projects/${made.id}`); + // 50f. The project goes by the picker. Its question stands in for the picker: Cancel leads back to + // it, and so does the deletion, with the project gone from the list. + await clickToolbar('menu'); + await clickText('Open project…'); + await waitUntil('__gm.projectPicker() !== null', { label: 'the project picker opened' }); + await clickLast('Delete'); + await clickLast('Cancel'); + const pickerBack = await cameBack('__gm.projectPicker() !== null', 'the picker came back'); + check( + 'Cancel on "Delete project?" leads back to the picker and keeps the project', + pickerBack && (await stored()).name === RENAMED, + JSON.stringify({ pickerBack, picker: await json('__gm.projectPicker()') }), + ); + if (pickerBack) { + await clickLast('Delete'); + await clickLast('Delete'); + } else await api('DELETE', `projects/${made.id}`); + const pickerAgain = await cameBack( + `__gm.projectPicker() !== null && !__gm.projectPicker().names.includes(${JSON.stringify(RENAMED)})`, + 'the picker came back without the project', + ); + check( + 'deleting from the picker leads back to it, without the project', + pickerAgain, + JSON.stringify(await json('__gm.projectPicker()')), + ); + if (pickerAgain) await clickLast('Cancel'); + + // Deleting the project deletes its snapshots: the route answers 404 afterwards. const gone = await api('GET', `projects/${made.id}/snapshots`); check('deleting the project takes its snapshots along', gone.status === 404, String(gone.status)); } finally { diff --git a/tools/make-user.php b/tools/make-user.php index 2f7de385..795c269e 100644 --- a/tools/make-user.php +++ b/tools/make-user.php @@ -11,6 +11,7 @@ declare(strict_types=1); * php tools/make-user.php --list */ +require __DIR__ . '/../api/lib/AtomicFile.php'; require __DIR__ . '/../api/lib/UserStore.php'; $file = dirname(__DIR__) . '/data/users.json';