diff --git a/.changes/label-avoids-own-bends.json b/.changes/label-avoids-own-bends.json new file mode 100644 index 00000000..608869e1 --- /dev/null +++ b/.changes/label-avoids-own-bends.json @@ -0,0 +1 @@ +{ "type": "user-facing", "releaseNoteId": "release:0.28.1" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bbe2c0b..e990f9eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable Loop Studio releases, newest first. Behavioral changes are pinned in versioned spec documents (see the [README](README.md#technical-reference)); this file is the narrative history, not the contract. +## v0.28.1 — 2026-10-14 + +A connection's label keeps off the bend points you placed on it, where its line has room ([`docs/edge-routing.md`](docs/edge-routing.md) §ER14.3); the Early MMO example leaves the Templates menu. + +- **The rule.** A routed label takes the first free slot of its own line; free now also means clear of a 16 px square on each of the connection's own STORED bend points (the 12 px bend marker plus 2 px), in the first two placement passes. The last-resort passes ignore the bends, so a line with no slot clear of them still has its label, possibly over a bend: the overlap is avoided where a slot allows, not ruled out. The rule reads stored data only, so selecting or deselecting a connection, or zooming, does not by itself move a label. +- **Measured on the screen** (the bundled examples with bends added to one connection at a time, its rendered label compared with its rendered bend markers): a label touching one of its own markers went from 23 / 202, 49 / 262 and 29 / 254 (three ways to place the bends) to 1, 6 and 0. Most of the 7 left are short lines with bends close together. +- **Other connections.** Labels are placed in order and lines are rerouted around labels, so a label that moves off a bend can move another one: in the same measurement another label moved in 18 of 934 cases and another line changed in 20, in Early MMO, the gacha banner and the playback choreography; none in the other examples. In one MMO case a neighbouring line now runs along a different line for longer and its label sits on that line; it did not reproduce in the two small diagrams checked in the browser, so it is recorded as a limit of that stress case. +- **Early MMO leaves the Templates menu.** The Early MMO progression example is no longer offered by Templates ▾ or the phone's More → Templates; it stays in the repository — `examples/mmo-progression.json`, its Template entry and its translations — as developer stress material for large diagrams. A document made from it still opens, from its file, an autosave or a share link, and its node names still follow a language switch. The menu checks (first screen, language) moved to Coffee and the gacha banner; MMO's own behaviour and performance are still tested, by opening its file. Its two colour-view baselines were removed with its menu case. + +**Notes.** Four What's new lines in 18 languages: the translation contract tests pass, and none had native-speaker review. The router's port grouping by face and React Flow's one-render gap after a change of face are separate, later work. + ## v0.28.0 — 2026-10-14 A connection can be turned around without drawing it again — the second line-editing release ([`docs/edge-routing.md`](docs/edge-routing.md) §ER19). diff --git a/README.md b/README.md index b47f69f1..937813d6 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,9 @@ session neither reads nor saves stored work, author information or settings ([docs/storage-sessions.md](docs/storage-sessions.md)). **Run it now: ** — available in -[18 languages](#languages), with five bundled Templates ranging from a small -production flow to a large game economy and a three-zone probability/pity -comparison. +[18 languages](#languages), with four bundled Templates ranging from a small +production flow to a coffee roastery's daily operations and a three-zone +probability/pity comparison. ![Loop Studio's two-tier toolbar in a temporary session, its orange-bordered Temporary session button beside the menu buttons, and the Coffee roastery Template in the compact new nodes, each name outside its node, grouped into three labelled zone frames with its money in $/day, a few steps into a run with the Timeline filling in below](docs/assets/hero-coffee.png) @@ -140,7 +140,20 @@ Additional feature-specific design documents (localization, mobile, module system, large-graph readability, simulation playback, edge routing, data import, …) live under [`docs/`](docs/). -## Latest — v0.28.0 +## Latest — v0.28.1 + +A connection's label keeps off the bend points you placed on it, where its line has room. + +- **Labels clear of your bends**: where there is room, a connection's label is placed clear of + the bend points you added to it; where there is not enough room, it can still overlap one +- **Stays put**: selecting a connection, deselecting it or zooming does not by itself change + where its label sits +- **Other connections**: in a dense diagram, a label that moves off a bend can also move the + label or the line of another connection +- **Early MMO example**: no longer listed under Templates; it stays as developer test material + for large diagrams, and a document you already made from it still opens and works as before + +## v0.28.0 A connection can be turned around without drawing it again. @@ -193,29 +206,9 @@ between. The badges of two connections that leave a node together can still meet briefly when they start at different moments, and amounts at the middle zoom level are not yet easier to read. -## v0.26.0 - -A new node: a compact body with its role and value inside and its name just outside, so the -size of a node no longer depends on the length of a translation. - -- **New node design**: the name sits outside the body and stays visible at the middle zoom - level; a value too long for the body ends in an ellipsis and stays complete in its tooltip - and in the Inspector -- **Connections from any side**: a connection can leave or enter a node at its top, bottom, - left or right, and a new connection remembers the side it was drawn from; existing documents - are not re-placed, their values and the meaning of their connections are kept, and they look - different with the new node -- **Notices under the toolbar**: the update notice and the first-time hints sit in a strip - under the toolbar, and the diagram does not move when they appear or close -- **A Pool's current amount**: a read-only field in the Inspector, beside the starting - amount, also on phones -- **Re-placed Templates**: the Coffee roastery (money in $/day) and the 3-zone gacha banner - comparison are laid out again for the new node - -A document with chosen sides, saved again by v0.25.0 or earlier, loses those sides. v0.26.1 -corrects the wording of two lines of this release's What's new note. - -See [`CHANGELOG.md`](CHANGELOG.md) for the full notes of these releases, v0.25.0 (nodes +See [`CHANGELOG.md`](CHANGELOG.md) for the full notes of these releases, v0.26.0 (the +compact node with its name outside, connections from any side, notices under the toolbar), +v0.25.0 (nodes on a grid, orthogonal connections around them, editable connection shapes and text inside nodes), v0.24.0 (three playback speed tiers, and a speed choice and fewer moving markers on the phone), v0.23.0 (a Pool diff --git a/docs/diagram-layout.md b/docs/diagram-layout.md index 1ef04b1b..c6c16697 100644 --- a/docs/diagram-layout.md +++ b/docs/diagram-layout.md @@ -94,7 +94,7 @@ The Controls rail's **Tidy to grid** re-places the current document by the DL2.2 - DL5.3 A saved frame travels with the nodes it held (centre inside it before the move): it is rebuilt around them, keeping its own padding on each side and at least 24 px (§CR17), snapped outward. No frame cuts a node, by the widest boxes. Two frames that stood apart keep at least 32 px between them (`FRAME_GAP`; a frame's title sits in that gap, and two margins alone would make neighbouring frames touch): space is inserted as in §DL2 — every node beyond the nearer frame's far edge moves by the same grid step — so no order is reversed and no clearance shrinks. - DL5.4 Every connection is Auto orthogonal. A Manual exception (bend points) is used only where placement cannot resolve a case, each recorded with its connection id and reason; none is needed: in all 18 languages no route is blocked or on the outer corridor and no fan overlaps past its stub (measured 2026-10-10). - DL5.5 The menu-open framings (`initialView`): - - MMO puts readability before the whole start path, and the core start nodes before the zoom: its rect is sized for about 1.05 at 1600 x 1000, inside the Controls rail's reserved column, and its `keep` rect (§DL5.6) caps the zoom so every core start node opens whole in every language — Character creation, Active character, Starter encounters, Starter Lv 1–5. At 1600 x 1000 it opens at 1.040 (node text 12.5 px smallest, 14.6 px median; Thai Starter Lv 1–5, the widest, ends 8 px inside the pane, above the minimap); at 1280 x 800 at 0.725, or 0.772 with the minimap collapsed. Later paths may start off screen; Reset view and the minimap show the whole graph. + - MMO puts readability before the whole start path, and the core start nodes before the zoom: its rect is sized for about 1.05 at 1600 x 1000, inside the Controls rail's reserved column, and its `keep` rect (§DL5.6) caps the zoom so every core start node opens whole in every language — Character creation, Active character, Starter encounters, Starter Lv 1–5. At 1600 x 1000 it opens at 1.040 (node text 12.5 px smallest, 14.6 px median; Thai Starter Lv 1–5, the widest, ends 8 px inside the pane, above the minimap); at 1280 x 800 at 0.725, or 0.772 with the minimap collapsed. Later paths may start off screen; Reset view and the minimap show the whole graph. Since 0.28.1 MMO is not in the Templates menu (a developer stress fixture opened by Import), so this view, applied on a menu open only, no longer applies in the product; it stays in the entry. - Coffee opens on its operating flow — the five daily levers, the supply and the roasting & sales frames — at about 0.5 in a 1280 x 800 window (L1 with room, never at the 0.45 edge; floor 0.46); the forecast metrics may start off screen. - Gacha keeps its overview: the comparison row and the Free zone, at its 0.45 floor. - DL5.6 `keep` — the nodes that must open whole (`InitialView.keep`, `viewportForRect` in `src/components/canvasFit.ts`). An `initialView` MAY carry a `keep` rect: the core start nodes at their widest box over the 18 languages (`templateBoxes.json`), the tight union of those boxes (pinned by `src/model/templates.initialView.test.ts`). The zoom is the rect's fit, capped at the largest zoom that shows `keep` inside the pane (8 px from its right and bottom edges, clear of the Controls rail); the minimap's corner (224 x 176 px with its margin) counts only when `keep` would overlap it on BOTH axes, and then `keep` clears it by whichever side keeps the larger zoom. So a top row may run past the minimap's column above it, and a collapsed minimap never lets the zoom grow past what `keep` allows. The readability floor (`minZoom`) still wins on a pane too small for `keep` (a phone opens at the floor, as before). Only MMO carries `keep`; the left alignment, the vertical centring and the other Templates' views are unchanged. diff --git a/docs/edge-routing.md b/docs/edge-routing.md index f65ba034..42d19b53 100644 --- a/docs/edge-routing.md +++ b/docs/edge-routing.md @@ -781,6 +781,14 @@ pinned single-edge core (golden + differential tests); the app no longer calls i - A label takes the first free slot on its own route — the midpoint, then 0.4, 0.6, 0.3, 0.7, 0.25, 0.75, 0.2, 0.8 of its length — clear of every node box (2 px margin) and every label already placed; the midpoint when none is free. +- A label also keeps off its own connection's STORED bend points + (`waypoints`): a `BEND_LABEL_BOX` (16 px) square on each, the 12 px bend + marker plus 2 px, in the first two passes (clear of everything, then clear of + nodes and labels). The bend markers are drawn there while the connection is + selected; the rule holds whether it is selected or not, so a selection never + moves the label. The last-resort passes ignore the bends, so a line with no + slot clear of them still has its label, possibly over a bend: the overlap is + avoided where a slot allows, not ruled out (share notes 50 and 52). - Then up to `LABEL_ROUNDS` (2) guarded rounds: an edge whose route runs through other edges' labels is routed again with those labels as soft obstacles (`computeSoftReroute`: one rung of `SOFT_PADS` — 12, then 6 px — diff --git a/docs/example-mmo-progression.md b/docs/example-mmo-progression.md index 03230ef4..c7e4aeea 100644 --- a/docs/example-mmo-progression.md +++ b/docs/example-mmo-progression.md @@ -10,6 +10,17 @@ invariants** in the acceptance conditions (§EM10.1). This is a **non-frozen** d marker — and merges as *settled design, implementation pending*, like [`docs/localization.md`](localization.md) and [`docs/guided-tour.md`](guided-tour.md). +> **0.28.1 (2026-10-14) — no longer in the Templates menu.** The example is now +> a **developer stress fixture**: it stays a Template entry in +> `src/model/templates.ts` with `menu: false` (so its translations and the +> §TLO11 locale switch still apply), keeps `examples/mmo-progression.json`, its +> fixture, its scripts and its tests, and is opened by **Import**. Templates ▾ +> and the phone's More → Templates no longer list it; a document made from it +> still opens. A change that only affects it does not block a release; a +> readability problem it shows is fixed only when a small case reproduces it. +> The design below, including the menu registration (§EM10), is the record of +> how it shipped. + The example is the **second follow-up** to the localization base (after the guided tour, before contextual inline help). It is a **product demo / Templates entry**, not a precision instrument and not a test oracle: it exists to show diff --git a/docs/mmo-multilingual-layout.md b/docs/mmo-multilingual-layout.md index a8d7be38..89a6512b 100644 --- a/docs/mmo-multilingual-layout.md +++ b/docs/mmo-multilingual-layout.md @@ -6,6 +6,13 @@ toolbar responsive PR). The bundled **MMO progression** template (97 nodes / 144 edges) has node-box overlaps and an unreadable initial camera; the problem is worst in JA but is **not JA-only**. +> **0.28.1 (2026-10-14).** MMO left the Templates menu and is a developer stress +> fixture opened by Import ([`docs/example-mmo-progression.md`](example-mmo-progression.md)). +> Its `initialView` (§MML3) is applied on a fresh menu open only, so it no +> longer applies in the product; it stays in the Template entry, and the +> menu-framing contract is checked on Coffee and the gacha banner instead. The +> layout measurements below are the record of the MMO pass. + ## MML0. What the measurement pass found All figures are **flow coordinates** (measured at zoom = 1, so screen px = flow diff --git a/docs/product-direction.md b/docs/product-direction.md index e6ed882e..5045aee0 100644 --- a/docs/product-direction.md +++ b/docs/product-direction.md @@ -143,6 +143,11 @@ own default open-state and its own messaging in the Templates menu.** That is the single messaging bug this table exists to prevent. - *Early MMO progression* stays an **Example**, stays edit-locked, is **not** "fixed" or demoted. It did its job: it verified the ceiling. + **Superseded 0.28.1 (2026-10-14):** it left the Templates menu and is now a + developer stress fixture — still a Template entry (`menu: false`) and still + edit-locked when opened, opened by Import, its documents still open. A change + that only affects it does not block a release + ([`docs/example-mmo-progression.md`](example-mmo-progression.md)). --- diff --git a/e2e/dense-graph-pan.spec.ts b/e2e/dense-graph-pan.spec.ts index a54f8ec6..583d60a5 100644 --- a/e2e/dense-graph-pan.spec.ts +++ b/e2e/dense-graph-pan.spec.ts @@ -2,7 +2,8 @@ import { readFileSync } from 'node:fs' import type { Page } from '@playwright/test' import { expect, importGraph, openApp, resetAll, test } from './support/loop' -/** the bundled Early-MMO Template — 97 nodes / 144 edges, real Bézier + +/** the Early-MMO document (a developer stress fixture since 0.28.1, read from + * its file) — 97 nodes / 144 edges, real Bézier + * orthogonal routing, real crossings. Used only for the dense-graph * edge-tap performance regression test below. */ const readMmoProgression = (): string => diff --git a/e2e/descriptive-copy-wrapping.spec.ts b/e2e/descriptive-copy-wrapping.spec.ts index 7a7712e9..ee083ab6 100644 --- a/e2e/descriptive-copy-wrapping.spec.ts +++ b/e2e/descriptive-copy-wrapping.spec.ts @@ -121,7 +121,9 @@ test.describe('Korean keeps its wrapping contract', () => { [...new Set(rows.map((r) => r.wordBreak))], 'Korean descriptive copy keeps `keep-all`', ).toEqual(['keep-all']) - expect(rows.reduce((n, r) => n + r.lines, 0), 'the agreed Korean line count').toBe(38) + // 0.28.1: 36, was 38 — the Early MMO blurb (2 lines, measured in the + // Templates menu's own box) left the menu with its entry + expect(rows.reduce((n, r) => n + r.lines, 0), 'the agreed Korean line count').toBe(36) // measured, not assumed: a break is mid-eojeol when two adjacent // NON-space characters end up on different lines @@ -170,7 +172,8 @@ test.describe('English is untouched', () => { await openApp(p) const rows = await sweepDescriptiveCopy(p) expect(overflowing(rows).map(describe1)).toEqual([]) - expect(rows.reduce((n, r) => n + r.lines, 0), 'the English line count is unchanged').toBe(40) + // 0.28.1: 38, was 40 — the Early MMO blurb (2 lines) left the Templates menu + expect(rows.reduce((n, r) => n + r.lines, 0), 'the English line count is unchanged').toBe(38) await ctx.close() }) }) diff --git a/e2e/dist.spec.ts b/e2e/dist.spec.ts index 75b9dff9..99e00068 100644 --- a/e2e/dist.spec.ts +++ b/e2e/dist.spec.ts @@ -130,31 +130,38 @@ test.describe('production build (Cloudflare Pages shape)', () => { expect(bad, 'no failed or cross-origin requests').toEqual([]) }) - test('the "Early MMO progression" Template loads its bundled graph + recommended MC config', async ({ page }) => { + // The largest Template the menu offers (Early MMO, the previous subject here, + // left the menu in 0.28.1 and stays a developer stress fixture). + test('the "3-zone gacha banner comparison" Template loads its bundled graph + recommended MC config', async ({ page }) => { const { bad } = await openProd(page) + const GACHA = JSON.parse(readFileSync(new URL('../examples/gacha-banner-zones.json', import.meta.url), 'utf8')) as { + nodes: unknown[] + recommendedRunConfig: { runs: number; steps: number; baseSeed: number } + } // Templates ▾ is the first `.menu` in the toolbar actions await page.locator('.toolbar__actions .menu').first().locator('> button').click() await page .locator('.toolbar__actions .menu') .first() - .locator('.menu__pop [role="menuitem"]', { hasText: 'Early MMO progression' }) + .locator('.menu__pop [role="menuitem"]', { hasText: '3-zone gacha banner comparison' }) .click() const confirm = page.locator('.mcdlg--confirm').getByRole('button', { name: /load template/i }) if (await confirm.isVisible().catch(() => false)) await confirm.click() - // the canonical examples/mmo-progression.json is 97 nodes - await expect(page.locator('.react-flow__node')).toHaveCount(97) + // every node of the canonical examples/gacha-banner-zones.json + await expect(page.locator('.react-flow__node')).toHaveCount(GACHA.nodes.length) // the file's recommendedRunConfig pre-fills the Monte-Carlo dialog + const rc = GACHA.recommendedRunConfig await page.locator('.pstrip__mc button', { hasText: 'Monte Carlo' }).click() const dlg = page.locator('.mcdlg[aria-labelledby="mcdlg-title"]') await expect(dlg).toBeVisible() const nums = dlg.locator('.mcdlg__field input[type="number"]') - await expect(nums.nth(0)).toHaveValue('200') - await expect(nums.nth(1)).toHaveValue('150') - await expect(nums.nth(2)).toHaveValue('1') - await expect(dlg.locator('.mcdlg__foot .btn--primary')).toHaveText('Run 200 runs') + await expect(nums.nth(0)).toHaveValue(String(rc.runs)) + await expect(nums.nth(1)).toHaveValue(String(rc.steps)) + await expect(nums.nth(2)).toHaveValue(String(rc.baseSeed)) + await expect(dlg.locator('.mcdlg__foot .btn--primary')).toHaveText(new RegExp(`^Run ${rc.runs.toLocaleString('en-US').replace(',', ',?')} runs$`)) await page.keyboard.press('Escape') expect(bad, 'no failed or cross-origin requests').toEqual([]) @@ -239,7 +246,11 @@ test.describe('production build (Cloudflare Pages shape)', () => { expect(bad, 'no failed or cross-origin requests').toEqual([]) }) - test('a bulk locale switch on the MMO Template keeps edge endpoints aligned with their real handle positions (no window.__loop)', async ({ + // 0.28.1 — Early MMO left the Templates menu (a developer stress fixture now): + // the document is opened from its FILE, as a user's own file is. It is still + // a Template entry, so its node names still follow a locale switch; the + // largest graph with parallel sets, it stays the subject of this check. + test('a bulk locale switch on the MMO document keeps edge endpoints aligned with their real handle positions (no window.__loop)', async ({ page, }) => { // The node structure (v0.26.0): the name sits OUTSIDE a fixed-height body, @@ -261,24 +272,32 @@ test.describe('production build (Cloudflare Pages shape)', () => { // shipped-language spellings (same pattern as e2e/i18n.spec.ts's // openSettings), not just the English one. const settingsBtn = page.locator('.toolbar__actions .menu > button', { hasText: /^(Settings|설정|設定)$/ }) - await settingsBtn.click() - await page.locator('.toolbar .lang-switch').click() - await page.locator('.lang-menu__item[data-locale="ko"]').click() - await expect.poll(() => page.evaluate(() => document.documentElement.lang)).toBe('ko') + // picking a language leaves Settings open; close it, so the next switch's + // first click opens it again rather than closing it (the Templates menu + // used to close it, before the MMO document was opened from its file) + const switchTo = async (locale: string) => { + await settingsBtn.click() + await page.locator('.toolbar .lang-switch').click() + await page.locator(`.lang-menu__item[data-locale="${locale}"]`).click() + await expect.poll(() => page.evaluate(() => document.documentElement.lang)).toBe(locale) + await page.keyboard.press('Escape') + await expect(page.locator('.toolbar .lang-switch')).toBeHidden() + } + await switchTo('en') - await page.locator('.toolbar__actions .menu').first().locator('> button').click() - await page - .locator('.toolbar__actions .menu') - .first() - .locator('.menu__pop [role="menuitem"]', { hasText: '초반 MMO 성장' }) - .click() - // locale-independent: the confirm dialog's primary action button by - // class, not by text (the label is Korean here, since locale is 'ko') + // open the MMO document from its file (the real file input) + const MMO_TEXT = readFileSync(new URL('../examples/mmo-progression.json', import.meta.url), 'utf8') + await page.locator('input[type=file][accept*="json"]').first().setInputFiles({ name: 'mmo-progression.json', mimeType: 'application/json', buffer: Buffer.from(MMO_TEXT, 'utf8') }) + // locale-independent: the confirm dialog's primary action button by class const confirm = page.locator('.mcdlg--confirm .btn--primary') if (await confirm.isVisible().catch(() => false)) await confirm.click() await expect(page.locator('.react-flow__node')).toHaveCount(97) + await expect(page.locator('.react-flow__node', { hasText: 'Level' }).first()).toBeVisible() + + // into Korean: the names follow (the document is still a Template entry's) + await switchTo('ko') await page.evaluate(() => document.fonts.ready) - // representative Korean node label, confirming the KO Template actually loaded + // representative Korean node label, confirming the KO names are drawn await expect(page.locator('.react-flow__node', { hasText: '레벨' }).first()).toBeVisible() // issue #344 §ER14.5 — the routed connections are drawn once their first // generation commits; the canvas is aria-busy until then (the DOM side of diff --git a/e2e/document-boundary.spec.ts b/e2e/document-boundary.spec.ts index 910acafc..6762b18b 100644 --- a/e2e/document-boundary.spec.ts +++ b/e2e/document-boundary.spec.ts @@ -93,10 +93,10 @@ async function storageAction(page: Page, action: 'to-temporary' | 'delete-work') } test.describe('#334 — a new document starts unlocked, with no history', () => { - test('File → New from the locked MMO template: unlocked, empty, Undo and Redo disabled; a palette click edits; a reload keeps it unlocked', async ({ page }) => { + test('File → New from the locked gacha template (Early MMO left the menu in 0.28.1): unlocked, empty, Undo and Redo disabled; a palette click edits; a reload keeps it unlocked', async ({ page }) => { await openApp(page) await resetAll(page) - await openTemplate(page, 'Early MMO progression') + await openTemplate(page, '3-zone gacha banner comparison') await expect(lockBtn(page)).toHaveAttribute('aria-pressed', 'true') await fileNew(page) @@ -112,7 +112,7 @@ test.describe('#334 — a new document starts unlocked, with no history', () => test('a temporary session without the document, from a locked template: unlocked, no history', async ({ page }) => { await openApp(page) await resetAll(page) - await openTemplate(page, 'Early MMO progression') + await openTemplate(page, '3-zone gacha banner comparison') await expect(lockBtn(page)).toHaveAttribute('aria-pressed', 'true') await storageAction(page, 'to-temporary') expect(await nodeCount(page)).toBe(0) @@ -122,7 +122,7 @@ test.describe('#334 — a new document starts unlocked, with no history', () => test('Delete work data, from a locked template: unlocked, no history', async ({ page }) => { await openApp(page) await resetAll(page) - await openTemplate(page, 'Early MMO progression') + await openTemplate(page, '3-zone gacha banner comparison') await expect(lockBtn(page)).toHaveAttribute('aria-pressed', 'true') await storageAction(page, 'delete-work') expect(await nodeCount(page)).toBe(0) diff --git a/e2e/flow-colour-views-visual.spec.ts b/e2e/flow-colour-views-visual.spec.ts index 91df2a54..1b437129 100644 --- a/e2e/flow-colour-views-visual.spec.ts +++ b/e2e/flow-colour-views-visual.spec.ts @@ -66,10 +66,10 @@ test('forced colours: the minimap and the timeline keep their kind and series co await expect(page.locator('.timeline__panel')).toHaveScreenshot(...snap(page, 'flow-views-forced-timeline')) }) +// the Templates the menu offers; Early MMO left the menu in 0.28.1 (Lumi: its two baselines removed) const TEMPLATES = [ { id: 'coffee', name: 'Coffee roastery operations flow', waitFor: 'cafe_retail_demand_kg' }, { id: 'gacha', name: '3-zone gacha banner comparison', waitFor: 'cmp1_hit_rate_free' }, - { id: 'mmo', name: 'Early MMO progression (levels 1–15)', waitFor: 'char_creation' }, ] // the stored colours are drawn unchanged in both themes (D-1), so the dark // shots hold them against the dark structure lines, labels and frames diff --git a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-mmo-dark-chromium-win32.png b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-mmo-dark-chromium-win32.png deleted file mode 100644 index 07c40ed6..00000000 Binary files a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-mmo-dark-chromium-win32.png and /dev/null differ diff --git a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-mmo-light-chromium-win32.png b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-mmo-light-chromium-win32.png deleted file mode 100644 index 5a4dee66..00000000 Binary files a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-mmo-light-chromium-win32.png and /dev/null differ diff --git a/e2e/flow-colour-views.spec.ts b/e2e/flow-colour-views.spec.ts index 3ea23ef3..fac5ab20 100644 --- a/e2e/flow-colour-views.spec.ts +++ b/e2e/flow-colour-views.spec.ts @@ -220,10 +220,10 @@ test.describe('flow colour — the Inspector offers each colour once (FC-5)', () }) test.describe('flow colour — the bundled Templates (FC-6)', () => { + // the Templates the menu offers; Early MMO left the menu in 0.28.1 (its colours stay checked from the file, src/engine/templateFlowColours.test.ts) const TEMPLATES = [ { name: 'Coffee roastery operations flow', waitFor: 'cafe_retail_demand_kg', file: 'coffee-roastery.json' }, { name: '3-zone gacha banner comparison', waitFor: 'cmp1_hit_rate_free', file: 'gacha-banner-zones.json' }, - { name: 'Early MMO progression (levels 1–15)', waitFor: 'char_creation', file: 'mmo-progression.json' }, ] for (const t of TEMPLATES) { test(`${t.name}: opens with its three flow colours, on the canvas and the minimap`, async ({ page }) => { diff --git a/e2e/gacha-banner-zones.spec.ts b/e2e/gacha-banner-zones.spec.ts index b27907ce..1c9297de 100644 --- a/e2e/gacha-banner-zones.spec.ts +++ b/e2e/gacha-banner-zones.spec.ts @@ -120,7 +120,7 @@ test.describe('3-zone gacha banner comparison Template', () => { await expect(page.locator('.canvas.canvas--locked')).toBeVisible() }) - test('menu order: Coffee, then MMO, then Gacha (Hanrim, 2026-09-13 reorder)', async ({ page }) => { + test('menu order: Coffee, then Gacha, last (Hanrim, 2026-09-13 reorder); Early MMO is not offered (0.28.1)', async ({ page }) => { await openApp(page) await resetAll(page) await templatesBtn(page).click() @@ -129,11 +129,12 @@ test.describe('3-zone gacha banner comparison Template', () => { .locator('.menu__pop [role="menuitem"] .menu__name') .allInnerTexts() const coffeeIdx = names.findIndex((n) => n.includes('Coffee roastery')) - const mmoIdx = names.findIndex((n) => n.includes('Early MMO progression')) const gachaIdx = names.findIndex((n) => n === EN_NAME) expect(coffeeIdx).toBeGreaterThanOrEqual(0) - expect(mmoIdx).toBeGreaterThan(coffeeIdx) - expect(gachaIdx).toBeGreaterThan(mmoIdx) + expect(gachaIdx).toBeGreaterThan(coffeeIdx) + expect(gachaIdx).toBe(names.length - 1) + // a developer stress fixture now, not a user Template (Hanrim / Lumi, 2026-10-14) + expect(names.some((n) => n.includes('Early MMO progression'))).toBe(false) }) test('runs exactly pulls_per_zone + 1 pull-steps, not yet ended: conserves per zone', async ({ page }) => { diff --git a/e2e/help-menu-readability.spec.ts b/e2e/help-menu-readability.spec.ts index d39eaffd..3f187ce2 100644 --- a/e2e/help-menu-readability.spec.ts +++ b/e2e/help-menu-readability.spec.ts @@ -150,7 +150,7 @@ test('KO Templates menu: 340px, every description whole and inside the 2-line cl expect(width, 'Templates opts into the wide popover').toBe(300) const blurbs = await measureOpenMenu(page, '.menu__blurb') - expect(blurbs.length, 'one description per template').toBe(5) + expect(blurbs.length, 'one description per template (0.28.1: Early MMO is not in the menu)').toBe(4) for (const b of blurbs) { expect(b.fontPx, `"${b.text}": font size`).toBeGreaterThanOrEqual(BODY_MIN_PX) expect(b.contrast, `"${b.text}": contrast`).toBeGreaterThanOrEqual(BODY_MIN_CONTRAST) @@ -220,7 +220,7 @@ test('a widened popover still fits inside a narrow desktop viewport', async ({ p test.describe('menu keyboard — Templates, Insert module and File', () => { for (const [label, name, count] of [ - ['Templates', /템플릿/, 5], + ['Templates', /템플릿/, 4], // 0.28.1: Early MMO is not in the menu ['Insert module', /모듈/, 4], ['File', /파일/, 7], ] as const) { diff --git a/e2e/i18n-de.spec.ts b/e2e/i18n-de.spec.ts index 7d6272e3..cfdb8a52 100644 --- a/e2e/i18n-de.spec.ts +++ b/e2e/i18n-de.spec.ts @@ -180,24 +180,26 @@ test('a bundled Template opens with German node labels', async ({ page }) => { const templates = page.locator('.toolbar__actions .menu').first() await templates.locator('> button').click() await expect(templates.locator('.menu__pop')).toBeVisible() - // by NAME, not position — the German name also proves the Templates menu - // itself is translated - await templates.locator('.menu__pop [role="menuitem"]', { hasText: 'MMO' }).click() + // by its German NAME, not position — which also proves the Templates menu + // itself is translated (Coffee: Early MMO, the previous subject, left the + // menu in 0.28.1) + await templates.locator('.menu__pop [role="menuitem"]', { hasText: 'Betriebsablauf einer Kaffeerösterei' }).click() const confirm = page.locator('.mcdlg--confirm .btn--primary') if (await confirm.isVisible().catch(() => false)) await confirm.click() + await expect + .poll(() => page.evaluate(() => (window as unknown as { __loop: Loop }).__loop.graph.getState().nodes.length)) + .toBeGreaterThan(20) const labels = await page.evaluate(() => (window as unknown as { __loop: Loop }).__loop.graph .getState() .nodes.map((n: any) => n.data.label as string), ) - expect(labels.length).toBeGreaterThan(50) - // `Gold` and `XP` are deliberately absent from this list: they are the - // same word in German, so finding them proves nothing either way - for (const no of ['Level', 'Elapsed steps', 'Reward', 'Rare loot', 'Clock']) + for (const no of ['Green bean stock', 'Roasted bean stock', 'Green bean delivery', 'Projected daily revenue', 'End-of-day leftover']) expect(labels, `"${no}" must not survive into a German document`).not.toContain(no) // and the overlay actually supplied German - expect(labels.some((l: string) => /[äöüÄÖÜß]/.test(l) || /ung\b|keit\b|punkte/i.test(l))).toBe(true) + for (const want of ['Bestand Rohkaffee', 'Bestand gerösteter Kaffee', 'Reste am Tagesende']) + expect(labels, `the German overlay must supply "${want}"`).toContain(want) }) // ------------------------------------------------------------------ 7 diff --git a/e2e/i18n-es-es.spec.ts b/e2e/i18n-es-es.spec.ts index 7c75792b..6e65463b 100644 --- a/e2e/i18n-es-es.spec.ts +++ b/e2e/i18n-es-es.spec.ts @@ -191,19 +191,21 @@ test('the labels that differ from es-419 differ in the product', async ({ page } .locator('.toolbar__actions .menu', { has: page.getByRole('button', { name: 'Plantillas' }) }) .getByRole('button', { name: 'Plantillas' }) .click() - await page.locator('.menu__name', { hasText: 'Progresión temprana de MMO' }).click() + // Coffee (Early MMO, the previous subject, left the menu in 0.28.1): its one + // node name that differs from es-419 is the roasted supply margin + await page.locator('.menu__name', { hasText: 'Flujo de operaciones de un tostador de café' }).click() await expect .poll(() => page.evaluate(() => [...document.querySelectorAll('.nodef__name')].map((t) => t.textContent?.trim()), ), ) - .toContain('Puntuación de equipo') + .toContain('Margen del suministro de tostado') const titles = await page.evaluate(() => [...document.querySelectorAll('.nodef__name')].map((t) => t.textContent?.trim()), ) - expect(titles, 'the Latin American score word must not appear here').not.toContain( - 'Puntaje de equipo', + expect(titles, 'the Latin American supply word must not appear here').not.toContain( + 'Margen del abasto de tostado', ) }) diff --git a/e2e/i18n-fr.spec.ts b/e2e/i18n-fr.spec.ts index 04ec7aa5..51f41023 100644 --- a/e2e/i18n-fr.spec.ts +++ b/e2e/i18n-fr.spec.ts @@ -401,22 +401,26 @@ test('a bundled Template opens with French node labels', async ({ page }) => { const templates = page.locator('.toolbar__actions .menu').first() await templates.locator('> button').click() await expect(templates.locator('.menu__pop')).toBeVisible() + // Coffee, by its French name (Early MMO, the previous subject, left the menu + // in 0.28.1) await templates - .locator('.menu__pop [role="menuitem"]', { hasText: 'Progression MMO' }) + .locator('.menu__pop [role="menuitem"]', { hasText: 'Flux d’exploitation d’une torréfaction' }) .click() const confirm = page.locator('.mcdlg--confirm .btn--primary') if (await confirm.isVisible().catch(() => false)) await confirm.click() + await expect + .poll(() => page.evaluate(() => (window as unknown as { __loop: Loop }).__loop.graph.getState().nodes.length)) + .toBeGreaterThan(20) const labels = await page.evaluate(() => (window as unknown as { __loop: Loop }).__loop.graph .getState() .nodes.map((n: any) => n.data.label as string), ) - expect(labels.length).toBeGreaterThan(50) - for (const want of ['Niveau', 'Or', 'Pas écoulés', 'Récompense', 'Butin rare']) { + for (const want of ['Stock de café vert', 'Stock de café torréfié', 'Livraison de café vert', 'Chiffre d’affaires prévu du jour', 'Invendus de fin de journée']) { expect(labels, `the French overlay must supply "${want}"`).toContain(want) } - for (const no of ['Level', 'Gold', 'Elapsed steps', 'Reward']) { + for (const no of ['Green bean stock', 'Roasted bean stock', 'Green bean delivery', 'Projected daily revenue']) { expect(labels, `"${no}" must not survive into a French document`).not.toContain(no) } }) diff --git a/e2e/i18n-lazy.spec.ts b/e2e/i18n-lazy.spec.ts index 0bfa585d..7f9e8354 100644 --- a/e2e/i18n-lazy.spec.ts +++ b/e2e/i18n-lazy.spec.ts @@ -21,18 +21,19 @@ const i18nState = (page: Page) => const htmlLang = (page: Page) => page.evaluate(() => document.documentElement.lang) const stored = (page: Page) => page.evaluate(() => localStorage.getItem('loop-studio/ui-locale/1')) -const MMO = 'Early MMO progression (levels 1–15)' -async function openMmo(page: Page) { +// a Template the menu offers (Early MMO, the previous subject, left the menu in 0.28.1) +const COFFEE = 'Coffee roastery operations flow' +async function openCoffee(page: Page) { await page.locator('.toolbar__actions .menu').first().locator('> button').click() await page .locator('.toolbar__actions .menu') .first() - .locator('.menu__pop [role="menuitem"]', { hasText: MMO }) + .locator('.menu__pop [role="menuitem"]', { hasText: COFFEE }) .click() const confirm = page.locator('.mcdlg--confirm').getByRole('button', { name: /load template/i }) await confirm.waitFor({ state: 'visible', timeout: 1200 }).catch(() => {}) if (await confirm.count()) await confirm.click() - await expect(page.locator('.react-flow__node[data-id="char_creation"]')).toBeVisible() + await expect(page.locator('.react-flow__node[data-id="green_stock"]')).toBeVisible() } const nodeLabel = (page: Page, id: string) => page.locator(`.react-flow__node[data-id="${id}"] .nodef__name`).innerText() @@ -44,15 +45,15 @@ test.describe('locale lazy-loading — behaviour', () => { await openApp(page) await resetAll(page) expect(await loop(page)).toBe(true) - await openMmo(page) - expect(await nodeLabel(page, 'char_creation')).toBe('Character creation') + await openCoffee(page) + expect(await nodeLabel(page, 'green_stock')).toBe('Green bean stock') // drive the switch and sample lang + a node label the moment it settles await setLocale(page, 'ko') await expect.poll(() => htmlLang(page)).toBe('ko') // by the time is 'ko', the node label is already Korean — // never an intermediate English frame - expect(await nodeLabel(page, 'char_creation')).toBe('캐릭터 생성') + expect(await nodeLabel(page, 'green_stock')).toBe('생두 재고') expect((await i18nState(page)).loadError).toBeNull() }) @@ -96,7 +97,7 @@ test.describe('locale lazy-loading — behaviour', () => { }) => { await openApp(page) await resetAll(page) - await openMmo(page) + await openCoffee(page) // block BOTH halves of the ja load (dev module URLs) await page.route('**/i18n/locales/ja/**', (r) => r.abort()) await page.route('**/i18n/templateLabels/ja*', (r) => r.abort()) @@ -107,7 +108,7 @@ test.describe('locale lazy-loading — behaviour', () => { expect(st.activeLocale).toBe('en') expect(st.loadError?.code).toBe('ja') expect(await htmlLang(page)).toBe('en') - expect(await nodeLabel(page, 'char_creation')).toBe('Character creation') + expect(await nodeLabel(page, 'green_stock')).toBe('Green bean stock') // a successful switch clears the notice await page.unroute('**/i18n/locales/ja/**') @@ -127,22 +128,22 @@ test.describe('locale lazy-loading — behaviour', () => { }) => { await openApp(page) await resetAll(page) - await openMmo(page) - // #334 — MMO opens edit-locked; the rename below is a user edit + await openCoffee(page) + // the rename below is a user edit (the Template opens editable; unlock in case) await page.evaluate(() => (window as any).__loop.ui.getState().setCanvasLocked(false)) // rename one node via the store bridge await page.evaluate(() => { const g = (window as unknown as { __loop: L }).__loop.graph.getState() - const n = g.nodes.find((x: any) => x.id === 'char_creation') + const n = g.nodes.find((x: any) => x.id === 'green_stock') g.updateNodeData(n.id, { label: 'My start' }) }) await setLocale(page, 'ko') await expect.poll(() => htmlLang(page)).toBe('ko') - expect(await nodeLabel(page, 'char_creation')).toBe('My start') // user rename kept - expect(await nodeLabel(page, 'active_char')).toBe('활성 캐릭터') // official switched + expect(await nodeLabel(page, 'green_stock')).toBe('My start') // user rename kept + expect(await nodeLabel(page, 'green_delivery')).toBe('생두 입고') // official switched // one undo removes the rename, not "Korean → English half-state" await page.evaluate(() => (window as unknown as { __loop: L }).__loop.graph.getState().undo()) - expect(await nodeLabel(page, 'char_creation')).toBe('캐릭터 생성') + expect(await nodeLabel(page, 'green_stock')).toBe('생두 재고') }) }) diff --git a/e2e/label-bends.spec.ts b/e2e/label-bends.spec.ts new file mode 100644 index 00000000..6dabc445 --- /dev/null +++ b/e2e/label-bends.spec.ts @@ -0,0 +1,103 @@ +import { readFileSync } from 'node:fs' +import type { Page } from '@playwright/test' +import { expect, openApp, routesSettled, test } from './support/loop' + +// docs/edge-routing.md §ER14.3 — a routed label keeps off its own connection's +// stored bend points, so the bend markers drawn while it is selected do not sit +// under it; and the place does not depend on the selection, the zoom, an undo / +// redo round trip or a reopen. On the small node-structure fixture: e_treat +// (Triage desk → Treated today) with one bend put where its label sits now. + +const FIXTURE = readFileSync(new URL('./fixtures/node-structure-small.json', import.meta.url), 'utf8') +type Pt = { x: number; y: number } +type L = { + graph: { getState: () => any } + ui: { getState: () => any } + rf: { setViewport: (v: object, o?: object) => void } + routeMap: { get: (id: string) => { label: Pt | null; points: Pt[] } | null } + autosave: { flush: () => void } +} +const loop = (page: Page) => page.evaluate.bind(page) + +async function open(page: Page) { + await openApp(page) + await page.setInputFiles('.toolbar__actions input[type=file]', { name: 'node-structure-small.json', mimeType: 'application/json', buffer: Buffer.from(FIXTURE, 'utf8') }) + await expect(page.locator('.react-flow__node')).toHaveCount(7) + await page.evaluate(() => (window as unknown as { __loop: L }).__loop.rf.setViewport({ x: 40, y: 60, zoom: 1 }, { duration: 0 })) + await routesSettled(page) +} +/** the label's centre in flow coordinates, from the route map the canvas draws */ +const labelAt = (page: Page) => page.evaluate(() => (window as unknown as { __loop: L }).__loop.routeMap.get('e_treat')?.label ?? null) +const near = (a: Pt | null, b: Pt | null) => !!a && !!b && Math.abs(a.x - b.x) < 0.5 && Math.abs(a.y - b.y) < 0.5 + +async function selectEdge(page: Page) { + const p = await page.locator('.react-flow__edge[data-id="e_treat"] path.react-flow__edge-path').evaluate((el) => { + const s = el as SVGPathElement + const q = s.getPointAtLength(s.getTotalLength() * 0.15) + const m = s.getScreenCTM()! + return { x: q.x * m.a + q.y * m.c + m.e, y: q.x * m.b + q.y * m.d + m.f } + }) + await page.mouse.click(p.x, p.y) + await expect(page.locator('.inspector .ends')).toBeVisible() +} + +test.describe('a label keeps off its own bend points (§ER14.3)', () => { + test('the label leaves the bend put under it; selected, no bend marker is under it; the place holds through selection, zoom, undo / redo and a reopen', async ({ page }) => { + await open(page) + const before = await labelAt(page) + expect(before).not.toBeNull() + // one bend exactly where the label is now (one undo step, as a bend click) + const bend = { x: Math.round(before!.x), y: Math.round(before!.y) } + await loop(page)((b) => (window as unknown as { __loop: L }).__loop.graph.getState().setEdgeRouting('e_treat', { route: 'orthogonal', waypoints: [b] }), bend) + await routesSettled(page) + const bent = await labelAt(page) + expect(bent).not.toBeNull() + expect(near(bent, before), 'the label left the bend').toBe(false) + + // selected: the rendered label box and the rendered bend markers do not meet + await selectEdge(page) + const marker = page.locator('.route-bend') + await expect(marker).toHaveCount(1) + const lb = (await page.locator('.edge-label[data-edge-id="e_treat"]').boundingBox())! + const mb = (await marker.boundingBox())! + const meet = lb.x < mb.x + mb.width && mb.x < lb.x + lb.width && lb.y < mb.y + mb.height && mb.y < lb.y + lb.height + expect(meet, 'label box over the bend marker').toBe(false) + expect(near(await labelAt(page), bent), 'selecting does not move it').toBe(true) + + // deselected + const pane = (await page.locator('.react-flow__pane').boundingBox())! + await page.mouse.click(pane.x + 20, pane.y + 20) + await expect(page.locator('.route-bend')).toHaveCount(0) + expect(near(await labelAt(page), bent), 'deselecting does not move it').toBe(true) + + // zoom in and back + await page.evaluate(() => (window as unknown as { __loop: L }).__loop.rf.setViewport({ x: 40, y: 60, zoom: 1.6 }, { duration: 0 })) + await routesSettled(page) + expect(near(await labelAt(page), bent), 'zoom does not move it').toBe(true) + await page.evaluate(() => (window as unknown as { __loop: L }).__loop.rf.setViewport({ x: 40, y: 60, zoom: 1 }, { duration: 0 })) + await routesSettled(page) + expect(near(await labelAt(page), bent)).toBe(true) + + // undo the bend: back to the first place; redo: the bent place again + await page.keyboard.press('Control+z') + await routesSettled(page) + await expect.poll(() => labelAt(page).then((p) => near(p, before))).toBe(true) + await page.keyboard.press('Control+Shift+z') + await routesSettled(page) + await expect.poll(() => labelAt(page).then((p) => near(p, bent))).toBe(true) + + // reopen (autosave, reload): the same place + await page.evaluate(() => (window as unknown as { __loop: L }).__loop.autosave.flush()) + await page.reload() + await page.waitForFunction(() => Boolean((window as unknown as { __loop?: unknown }).__loop)) + await routesSettled(page) + await expect.poll(() => labelAt(page).then((p) => near(p, bent))).toBe(true) + }) + + test('a connection without bends keeps the place it had', async ({ page }) => { + await open(page) + const a = await labelAt(page) + await selectEdge(page) + expect(near(await labelAt(page), a)).toBe(true) + }) +}) diff --git a/e2e/minimap-responsive.spec.ts b/e2e/minimap-responsive.spec.ts index 3eaf1767..cb77dd55 100644 --- a/e2e/minimap-responsive.spec.ts +++ b/e2e/minimap-responsive.spec.ts @@ -158,27 +158,29 @@ test.describe('minimap — visible only when the pane can host it', () => { // `< 640 × 380` auto-hide. Collapsing / expanding never moves the camera; the // NEXT Template open frames against whatever the minimap state is then. -// §MML3 — MMO's Template.initialView.minZoom (src/model/templates.ts); a stable -// value also pinned by e2e/template-load-viewport.spec.ts. -const MMO_MIN_ZOOM = 0.6 -const MMO_NAME = 'Early MMO progression (levels 1–15)' +// §MML3 — the gacha Template's initialView.minZoom (src/model/templates.ts). Early +// MMO, the previous subject here, left the Templates menu in 0.28.1; the gacha +// banner comparison is the menu's large Template with a framed start, a stable +// value. +const TPL_MIN_ZOOM = 0.45 +const TPL_NAME = '3-zone gacha banner comparison' const uiState = (p: Page) => p.evaluate(() => (window as unknown as { __loop: { ui: { getState: () => { minimapCollapsed: boolean } } } }).__loop.ui.getState()) const toggleBtn = (p: Page) => p.locator('.minimap-toggle') -/** open MMO through the real Templates menu (the §MML3 framing path) */ -async function openMmoFromMenu(page: Page): Promise { +/** open the gacha Template through the real Templates menu (the §MML3 framing path) */ +async function openTemplateFromMenu(page: Page): Promise { await page.locator('.toolbar__actions .menu').first().locator('> button').click() await page .locator('.toolbar__actions .menu') .first() - .locator('.menu__pop [role="menuitem"]', { hasText: MMO_NAME }) + .locator('.menu__pop [role="menuitem"]', { hasText: TPL_NAME }) .click() const confirm = page.locator('.mcdlg--confirm').getByRole('button', { name: /load template/i }) await confirm.waitFor({ state: 'visible', timeout: 1200 }).catch(() => {}) if (await confirm.count()) await confirm.click() - await expect(page.locator('.react-flow__node[data-id="char_creation"]')).toBeVisible() + await expect(page.locator('.react-flow__node[data-id="cmp1_hit_rate_free"]')).toBeVisible() await page.waitForTimeout(500) // let the fit / measurement-settle effect land } @@ -202,10 +204,11 @@ const onScreen = (p: Page, id: string) => return b.right > a.left + 2 && b.left < a.right - 2 && b.bottom > a.top + 2 && b.top < a.bottom - 2 }, id) -// issue #344 (docs/diagram-layout.md §DL5.6) — the MMO opening view's core -// start nodes open WHOLE (inside the pane, not merely touching it); returns the +// issue #344 (docs/diagram-layout.md §DL5.6) — the opening view's core +// start nodes (the comparison row and the Free zone's front) open WHOLE (inside +// the pane, not merely touching it); returns the // ones that do not -const MMO_CORE = ['char_creation', 'active_char', 'z1_enc_src', 'z1_enc'] +const START_CORE = ['cmp1_hit_rate_free', 'fund_free', 'ticket_free', 'roll_gate_free'] const notWhole = (p: Page) => p.evaluate((ids) => { const a = document.querySelector('.react-flow')!.getBoundingClientRect() @@ -215,7 +218,7 @@ const notWhole = (p: Page) => const b = el.getBoundingClientRect() return !(b.left >= a.left - 0.5 && b.right <= a.right + 0.5 && b.top >= a.top - 0.5 && b.bottom <= a.bottom + 0.5) }) - }, MMO_CORE) + }, START_CORE) test.describe('minimap — the collapse / restore control', () => { test.beforeEach(async ({ page }) => { @@ -346,22 +349,22 @@ test.describe('minimap — the collapse / restore control', () => { test('a Template opened while collapsed reserves NO minimap gap; expanded it does', async ({ page }) => { // expanded run - await openMmoFromMenu(page) + await openTemplateFromMenu(page) const ivExpanded = await lastInitialView(page) expect(ivExpanded, 'applyInitialView ran').not.toBeNull() expect(ivExpanded!.insetR).toBe(224) expect(ivExpanded!.insetB).toBe(176) - expect(ivExpanded!.zoom).toBeGreaterThanOrEqual(MMO_MIN_ZOOM - 1e-6) - expect(await onScreen(page, 'char_creation')).toBe(true) - expect(await onScreen(page, 'z1_enc')).toBe(true) - expect(await onScreen(page, 'end15')).toBe(false) + expect(ivExpanded!.zoom).toBeGreaterThanOrEqual(TPL_MIN_ZOOM - 1e-6) + expect(await onScreen(page, 'cmp1_hit_rate_free')).toBe(true) + expect(await onScreen(page, 'roll_gate_free')).toBe(true) + expect(await onScreen(page, 'pulls_made_pickup')).toBe(false) expect(await notWhole(page)).toEqual([]) // sanity: the framed early band does not sit under the minimap — the two // screen rects collide only when they overlap on BOTH axes (issue #344: // the opening view lets the top row run past the minimap's column, above it) const underMinimap = await page.evaluate(() => { const m = document.querySelector('.react-flow__minimap')!.getBoundingClientRect() - return ['char_creation', 'active_char', 'z1_enc'].filter((id) => { + return ['cmp1_hit_rate_free', 'fund_free', 'roll_gate_free'].filter((id) => { const el = document.querySelector(`.react-flow__node[data-id="${id}"]`) if (!el) return false const r = el.getBoundingClientRect() @@ -373,17 +376,17 @@ test.describe('minimap — the collapse / restore control', () => { // collapse, re-open the same Template at the same pane size await toggleBtn(page).click() await expect(page.locator('.react-flow__minimap')).toHaveCount(0) - await openMmoFromMenu(page) + await openTemplateFromMenu(page) const ivCollapsed = await lastInitialView(page) expect(ivCollapsed!.insetR).toBe(0) // no gap for an invisible minimap expect(ivCollapsed!.insetB).toBe(0) - expect(ivCollapsed!.zoom).toBeGreaterThanOrEqual(MMO_MIN_ZOOM - 1e-6) + expect(ivCollapsed!.zoom).toBeGreaterThanOrEqual(TPL_MIN_ZOOM - 1e-6) // a wider usable width ⇒ same-or-larger fit zoom for the same rect, capped // so the core start nodes stay whole (the `keep` rect) expect(ivCollapsed!.zoom).toBeGreaterThanOrEqual(ivExpanded!.zoom - 1e-6) - expect(await onScreen(page, 'char_creation')).toBe(true) - expect(await onScreen(page, 'z1_enc')).toBe(true) - expect(await onScreen(page, 'end15')).toBe(false) + expect(await onScreen(page, 'cmp1_hit_rate_free')).toBe(true) + expect(await onScreen(page, 'roll_gate_free')).toBe(true) + expect(await onScreen(page, 'pulls_made_pickup')).toBe(false) expect(await notWhole(page)).toEqual([]) }) diff --git a/e2e/mmo-progression.spec.ts b/e2e/mmo-progression.spec.ts index 07c09d84..a376db79 100644 --- a/e2e/mmo-progression.spec.ts +++ b/e2e/mmo-progression.spec.ts @@ -3,17 +3,19 @@ import type { Page } from '@playwright/test' import { ensureTimelineOpen, expect, openApp, resetAll, seedPersonalBrowser, test } from './support/loop' // docs/example-mmo-progression.md §EM10 — the "Early MMO progression (levels -// 1–15)" Templates demo, exercised through the app: pick it from Templates ▾ -// (desktop) and the mobile More → Templates sheet, in EN and KO; run it to -// completion deterministically; check it round-trips; and check the §EM10.1 -// accounting identities from the app's own engine state. +// 1–15)" document, exercised through the app. Since 0.28.1 it is a developer +// stress fixture: no longer offered by Templates ▾ (desktop) or the mobile +// More → Templates sheet, so it is opened from its FILE, as a user's own file +// is, and both menus are checked not to list it. Its own behaviour is unchanged: +// the recommended config applies, it opens edit-locked, its node names follow a +// locale switch, it runs to completion deterministically, it round-trips, and +// the §EM10.1 accounting identities hold in the app's own engine state. // // The canonical graph is examples/mmo-progression.json — the fixture regen guard // (src/engine/mmo-progression.test.ts) owns the value-level acceptance. -const DOC = JSON.parse( - readFileSync(new URL('../examples/mmo-progression.json', import.meta.url), 'utf8'), -) as { +const DOC_TEXT = readFileSync(new URL('../examples/mmo-progression.json', import.meta.url), 'utf8') +const DOC = JSON.parse(DOC_TEXT) as { nodes: { id: string }[] edges: { id: string }[] recommendedRunConfig: { @@ -72,21 +74,34 @@ const runToEnd = (page: Page, seed: number, cap = 200) => // Templates is the first `.menu` in the toolbar actions (locale-agnostic). const templatesBtn = (page: Page) => page.locator('.toolbar__actions .menu').first().locator('> button') -async function pickDesktopTemplate(page: Page, name: string) { - await templatesBtn(page).click() +/** Open examples/mmo-progression.json through the app's real file input (the + * toolbar's Open), confirming a replace if the session asks; wait for the + * whole graph. */ +async function openMmoFile(page: Page) { await page - .locator('.toolbar__actions .menu').first() - .locator('.menu__pop [role="menuitem"]', { hasText: name }) - .click() + .locator('input[type=file][accept*="json"]') + .first() + .setInputFiles({ name: 'mmo-progression.json', mimeType: 'application/json', buffer: Buffer.from(DOC_TEXT, 'utf8') }) + // a modified session asks to replace (the dialog may arrive a moment after + // the file is read); a pristine one loads straight away. Locale-independent: + // the confirm dialog's primary action, by class. + const confirm = page.locator('.mcdlg--confirm .btn--primary') + const count = () => page.evaluate(() => (window as unknown as { __loop: Loop }).__loop.graph.getState().nodes.length) + await expect + .poll(async () => { + if (await confirm.isVisible().catch(() => false)) await confirm.click() + return count() + }) + .toBe(DOC.nodes.length) } test.describe('Early MMO progression example', () => { - test('desktop: Templates ▾ loads the canonical graph + its recommended MC config', async ({ page }) => { + test('desktop: opening its file loads the canonical graph + its recommended MC config', async ({ page }) => { await openApp(page) await resetAll(page) expect(await loop(page)).toBe(true) - await pickDesktopTemplate(page, EN_NAME) + await openMmoFile(page) const g = await graphCounts(page) expect(g.nodes).toBe(DOC.nodes.length) @@ -116,7 +131,7 @@ test.describe('Early MMO progression example', () => { }) => { await openApp(page) await resetAll(page) - await pickDesktopTemplate(page, EN_NAME) + await openMmoFile(page) await ensureTimelineOpen(page) // docs/timeline-series-contract.md §7 — collapsed until the first run const legend = page.locator('.timeline__legend') @@ -159,14 +174,14 @@ test.describe('Early MMO progression example', () => { expect(ts2).toContain('elapsed') expect(ts2.length).toBe(6) // (a fresh template open resetting the legend to the recommended 5 is covered - // by the "Templates ▾ loads the canonical graph + its recommended MC config" + // by the "opening its file loads the canonical graph + its recommended MC config" // test above, and the reload path by timeline-series.spec.ts.) }) test('opens edit-locked — a node still selects into a read-only Inspector; unlock → editable', async ({ page }) => { await openApp(page) await resetAll(page) - await pickDesktopTemplate(page, EN_NAME) + await openMmoFile(page) const isLocked = () => page.evaluate(() => (window as unknown as { __loop: Loop }).__loop.ui.getState().canvasLocked) @@ -194,23 +209,25 @@ test.describe('Early MMO progression example', () => { await expect(aside.locator('input').first()).toBeEnabled() }) - test('the Templates menu name + blurb render in EN and KO; the graph opens with the current locale\'s node labels (docs/template-label-overlay.md)', async ({ page }) => { + test('the Templates menu does not offer it in EN or KO (0.28.1); opened from its file, its node names follow a locale switch (docs/template-label-overlay.md)', async ({ page }) => { await openApp(page) await resetAll(page) const menuPop = page.locator('.toolbar__actions .menu').first().locator('.menu__pop') + // the menu is open and lists the Templates it offers (Coffee), but not this one await templatesBtn(page).click() - const enItem = menuPop.locator('[role="menuitem"]', { hasText: EN_NAME }) - await expect(enItem).toBeVisible() - await expect(enItem.locator('.menu__blurb')).toContainText('quests, hunts and rewards') + await expect(menuPop.locator('[role="menuitem"]', { hasText: 'Coffee roastery' })).toBeVisible() + await expect(menuPop.locator('[role="menuitem"]', { hasText: EN_NAME })).toHaveCount(0) + await expect(menuPop.locator('.menu__blurb', { hasText: 'quests, hunts and rewards' })).toHaveCount(0) await page.keyboard.press('Escape') await setLocale(page, 'ko') + await expect.poll(() => page.evaluate(() => document.documentElement.lang)).toBe('ko') await templatesBtn(page).click() - const koItem = menuPop.locator('[role="menuitem"]', { hasText: KO_NAME }) - await expect(koItem).toBeVisible() - await expect(koItem.locator('.menu__blurb')).toContainText('퀘스트, 사냥, 보상') + await expect(menuPop.locator('[role="menuitem"]', { hasText: '커피' }).first()).toBeVisible() + await expect(menuPop.locator('[role="menuitem"]', { hasText: KO_NAME })).toHaveCount(0) + await expect(menuPop.locator('.menu__blurb', { hasText: '퀘스트, 사냥, 보상' })).toHaveCount(0) await page.keyboard.press('Escape') const labels = () => @@ -224,22 +241,28 @@ test.describe('Early MMO progression example', () => { await expect.poll(() => page.evaluate(() => document.documentElement.lang)).toBe(code) } - // opened in KO ⇒ Korean node labels (from the ko dictionary) - await useLocale('ko') - await pickDesktopTemplate(page, KO_NAME) + // opened from its (English) file in EN ⇒ the canonical English labels + await useLocale('en') + await openMmoFile(page) let l = await labels() - expect(l).toContain('레벨') + expect(l).toContain('Level') + expect(l).toContain('Gold') + expect(l).toContain('Reached level 15') + expect(l).not.toContain('레벨') + + // switched to KO ⇒ Korean node labels (it is still a Template entry's + // document, so the ko dictionary re-seeds its official names) + await useLocale('ko') + await expect.poll(labels).toContain('레벨') + l = await labels() expect(l).toContain('골드') expect(l).toContain('15레벨 도달') expect(l).not.toContain('Level') - // opened in EN ⇒ the canonical English labels (reset first so the second - // pick loads without a replace-confirm) - await resetAll(page) + // and back to EN ⇒ the English labels again await useLocale('en') - await pickDesktopTemplate(page, EN_NAME) + await expect.poll(labels).toContain('Level') l = await labels() - expect(l).toContain('Level') expect(l).toContain('Gold') expect(l).toContain('Reached level 15') expect(l).not.toContain('레벨') @@ -249,7 +272,7 @@ test.describe('Early MMO progression example', () => { await page.emulateMedia({ reducedMotion: 'reduce' }) // each Step settles synchronously await openApp(page) await resetAll(page) - await pickDesktopTemplate(page, EN_NAME) + await openMmoFile(page) const a = await runToEnd(page, 1) expect(a.endedAt).toBeGreaterThan(15) @@ -289,7 +312,7 @@ test.describe('Early MMO progression example', () => { test('Export → re-import round-trips the graph and the recommended config', async ({ page }) => { await openApp(page) await resetAll(page) - await pickDesktopTemplate(page, EN_NAME) + await openMmoFile(page) const before = await graphCounts(page) const roundTripped = await page.evaluate(() => { @@ -321,7 +344,7 @@ test.describe('Early MMO progression example', () => { ) }) - test('mobile: the More → Templates sheet lists and loads it', async ({ browser }) => { + test('mobile: the More → Templates sheet does not list it (0.28.1); its file opens on a phone', async ({ browser }) => { const page = await browser.newPage({ viewport: { width: 390, height: 844 }, isMobile: true, @@ -347,21 +370,15 @@ test.describe('Early MMO progression example', () => { .click() const templates = page.locator('.sheet[aria-label="Templates"]') await expect(templates).toBeVisible() - const row = templates.locator('.sheet__row', { hasText: EN_NAME }) - await expect(row).toBeVisible() - await expect(row.locator('.sheet__row-sub')).toContainText('quests, hunts and rewards') - - await row.click() - // a modified session confirms first; a pristine one loads straight away - const confirm = page.locator('.mcdlg--confirm').getByRole('button', { name: /load template/i }) - if (await confirm.isVisible().catch(() => false)) await confirm.click() - await expect - .poll(() => - page.evaluate( - () => (window as unknown as { __loop: Loop }).__loop.graph.getState().nodes.length, - ), - ) - .toBe(DOC.nodes.length) + // the sheet lists the Templates it offers (Coffee), but not this one + await expect(templates.locator('.sheet__row', { hasText: 'Coffee roastery' })).toBeVisible() + await expect(templates.locator('.sheet__row', { hasText: EN_NAME })).toHaveCount(0) + await expect(templates.locator('.sheet__row-sub', { hasText: 'quests, hunts and rewards' })).toHaveCount(0) + await page.keyboard.press('Escape') + + // the phone's own file input (the top bar's Open) still opens the document + await openMmoFile(page) + expect((await graphCounts(page)).nodeIds).toEqual([...DOC.nodes.map((n) => n.id)].sort()) } finally { await page.close() } diff --git a/e2e/mobile.spec.ts b/e2e/mobile.spec.ts index 5d6b6e4b..8a4641f2 100644 --- a/e2e/mobile.spec.ts +++ b/e2e/mobile.spec.ts @@ -1784,7 +1784,8 @@ test.describe('sheet row secondary label contrast (§MV5 / WCAG 1.4.3)', () => { await escapeSheet(page, which) } // issue #330 PR 3 — More gained the Playback speed row (its sub names the speed) - expect(checked, 'the walk must reach every text sub-label: 6 More + 5 Templates + 4 enabled Export').toBe(15) + // 0.28.1 — Early MMO left the Templates sheet + expect(checked, 'the walk must reach every text sub-label: 6 More + 4 Templates + 4 enabled Export').toBe(14) expect(bad, 'hovered secondary labels below 4.5:1').toEqual([]) }) diff --git a/e2e/pwa.spec.ts b/e2e/pwa.spec.ts index 30b02a35..f3e9c7f3 100644 --- a/e2e/pwa.spec.ts +++ b/e2e/pwa.spec.ts @@ -421,11 +421,12 @@ test('applying a waiting update keeps a locked Template locked after reload', as await installAndControl(page) await page.getByRole('button', { name: 'Templates' }).click() - await page.getByRole('menuitem', { name: /Early MMO progression/ }).click() + // a locked Template the menu offers (Early MMO, locked too, left the menu in 0.28.1) + await page.getByRole('menuitem', { name: /3-zone gacha banner comparison/ }).click() const replaceDlg = page.locator('.mcdlg--confirm') await expect(replaceDlg).toBeVisible() await replaceDlg.getByRole('button', { name: 'Load template' }).click() - await expect(page.locator('.canvas.canvas--locked')).toBeVisible() // examples/mmo-progression.json ships canvasLocked: true + await expect(page.locator('.canvas.canvas--locked')).toBeVisible() // examples/gacha-banner-zones.json ships canvasLocked: true await stageUpdate(page, 'b') await expect(page.locator('.pwa-update')).toBeVisible() diff --git a/e2e/support/snapshot-policy.ts b/e2e/support/snapshot-policy.ts index ec22041d..e4d88a97 100644 --- a/e2e/support/snapshot-policy.ts +++ b/e2e/support/snapshot-policy.ts @@ -97,8 +97,6 @@ export const SNAPSHOTS: Record any }> const g = (page: Page) => page.evaluate(() => (window as unknown as { __loop: Loop }).__loop.graph.getState()) @@ -33,9 +39,9 @@ async function setLocale(page: Page, code: string) { // Templates ▾ is the first `.menu` in the toolbar actions (locale-agnostic). const templatesBtn = (page: Page) => page.locator('.toolbar__actions .menu').first().locator('> button') -/** #334 — the MMO template opens edit-locked, and the lock now refuses every - * user edit at the store too; a test that edits it lifts the lock first, the - * way a user has to. */ +/** #334 — the MMO document opens edit-locked, and the lock now refuses every + * user edit at the store too; a test that edits a document lifts the lock + * first (if it is on), the way a user has to. */ async function unlockCanvas(page: Page) { const lock = page.locator('.react-flow__controls-button.rf-lock') if ((await lock.getAttribute('aria-pressed')) === 'true') await lock.click() @@ -57,8 +63,28 @@ async function pickTemplate(page: Page, hasText: string) { await routesSettled(page) } -const MMO_EN = 'Early MMO progression' -const MMO_KO = '초반 MMO 성장' +const COFFEE_EN = 'Coffee roastery operations flow' +const COFFEE_KO = '커피 로스터리 운영 흐름' + +const MMO_TEXT = readFileSync(new URL('../examples/mmo-progression.json', import.meta.url), 'utf8') +/** Open examples/mmo-progression.json through the app's real file input (its + * file's own English names), confirming a replace if the session asks (the + * dialog may arrive a moment after the file is read), then wait for its + * routes. */ +async function openMmoFile(page: Page) { + await page + .locator('input[type=file][accept*="json"]') + .first() + .setInputFiles({ name: 'mmo-progression.json', mimeType: 'application/json', buffer: Buffer.from(MMO_TEXT, 'utf8') }) + const confirm = page.locator('.mcdlg--confirm .btn--primary') + await expect + .poll(async () => { + if (await confirm.isVisible().catch(() => false)) await confirm.click() + return page.evaluate(() => (window as unknown as { __loop: Loop }).__loop.graph.getState().nodes.length) + }) + .toBeGreaterThan(90) + await routesSettled(page) +} const GRAPH_STORAGE_KEY = 'loop-studio:graph:v1' const catValue = (page: Page, key: string) => @@ -68,41 +94,41 @@ const catValue = (page: Page, key: string) => ) test.describe('template label overlay', () => { - test('MMO opens with the current locale node labels; a later language switch re-seeds the OFFICIAL labels but keeps a user rename', async ({ page }) => { + test('Coffee opens with the current locale node labels; a later language switch re-seeds the OFFICIAL labels but keeps a user rename', async ({ page }) => { await openApp(page) await resetAll(page) await setLocale(page, 'ko') - await pickTemplate(page, MMO_KO) + await pickTemplate(page, COFFEE_KO) let l = await labels(page) - expect(l).toContain('레벨') - expect(l).toContain('골드') - expect(l).not.toContain('Level') + expect(l).toContain('생두 재고') + expect(l).toContain('로스팅 원두 재고') + expect(l).not.toContain('Green bean stock') - // the user renames the `gold` node to something of their own + // the user renames the `roasted_stock` node to something of their own await unlockCanvas(page) await page.evaluate(() => { const gs = (window as unknown as { __loop: Loop }).__loop.graph.getState() - const gold = gs.nodes.find((n: any) => n.id === 'gold') - gs.updateNodeData(gold.id, { label: '내 금고' }) + const roasted = gs.nodes.find((n: any) => n.id === 'roasted_stock') + gs.updateNodeData(roasted.id, { label: '내 원두 창고' }) }) // switch the app language while the KO document is open — the OFFICIAL // template labels follow the new language (§TLO11); the user rename does not await setLocale(page, 'en') l = await labels(page) - expect(l).toContain('Level') // 레벨 → Level - expect(l).not.toContain('레벨') - expect(l).toContain('내 금고') // the user rename is preserved - expect(l).not.toContain('Gold') // (that node is now '내 금고') + expect(l).toContain('Green bean stock') // 생두 재고 → Green bean stock + expect(l).not.toContain('생두 재고') + expect(l).toContain('내 원두 창고') // the user rename is preserved + expect(l).not.toContain('Roasted bean stock') // (that node is now '내 원두 창고') // …and a fresh EN open is the canonical English await resetAll(page) - await pickTemplate(page, MMO_EN) + await pickTemplate(page, COFFEE_EN) l = await labels(page) - expect(l).toContain('Level') - expect(l).toContain('Gold') - expect(l).not.toContain('레벨') + expect(l).toContain('Green bean stock') + expect(l).toContain('Roasted bean stock') + expect(l).not.toContain('생두 재고') }) test('the equilibrium sample opens with KO node labels under a KO locale, EN canonical under EN', async ({ page }) => { @@ -154,12 +180,12 @@ test.describe('template label overlay', () => { expect(l.sort()).toEqual(['Ore Stock', 'Out']) }) - test('re-open isolation: opening MMO in KO then EN yields the pristine English canonical', async ({ page }) => { + test('re-open isolation: opening Coffee in KO then EN yields the pristine English canonical', async ({ page }) => { await openApp(page) await resetAll(page) await setLocale(page, 'ko') - await pickTemplate(page, MMO_KO) + await pickTemplate(page, COFFEE_KO) // mutate the open document await page.evaluate(() => { const gs = (window as unknown as { __loop: Loop }).__loop.graph.getState() @@ -168,16 +194,16 @@ test.describe('template label overlay', () => { await resetAll(page) await setLocale(page, 'en') - await pickTemplate(page, MMO_EN) + await pickTemplate(page, COFFEE_EN) const s = await g(page) - expect(s.nodes.map((n: any) => n.data.label)).toContain('Level') - expect(s.nodes.map((n: any) => n.data.label)).not.toContain('레벨') + expect(s.nodes.map((n: any) => n.data.label)).toContain('Green bean stock') + expect(s.nodes.map((n: any) => n.data.label)).not.toContain('생두 재고') expect(s.selectedNodeId).toBeNull() }) // Desktop and mobile edit two different `doLoadTemplate` paths — the mobile // More → Templates sheet needs its own functional check that the overlay ran. - test('mobile More → Templates opens MMO with the current-locale (KO) node labels', async ({ + test('mobile More → Templates opens Coffee with the current-locale (KO) node labels', async ({ browser, }: { browser: Browser @@ -204,14 +230,14 @@ test.describe('template label overlay', () => { const moreLabel = await catValue(page, 'mobile.more') // '더 보기' const tplLabel = await catValue(page, 'templates.menuLabel') // '템플릿' - const mmoName = await catValue(page, 'templates.mmoProgression.name') // '초반 MMO 성장 (1–15레벨)' + const coffeeName = await catValue(page, 'templates.coffeeRoastery.name') // '커피 로스터리 운영 흐름' await page.locator('.mob-more').click() await page .locator(`.sheet[aria-label="${moreLabel}"] .sheet__row`, { hasText: tplLabel }) .click() await page - .locator(`.sheet[aria-label="${tplLabel}"] .sheet__row`, { hasText: mmoName }) + .locator(`.sheet[aria-label="${tplLabel}"] .sheet__row`, { hasText: coffeeName }) .click() // a non-pristine session confirms the replace first; a pristine one loads // straight away @@ -219,12 +245,12 @@ test.describe('template label overlay', () => { const confirm = page.getByRole('button', { name: confirmLabel }) if (await confirm.isVisible().catch(() => false)) await confirm.click() - await expect.poll(() => labels(page).then((l) => l.length)).toBeGreaterThan(50) + await expect.poll(() => labels(page).then((l) => l.length)).toBeGreaterThan(20) const l = await labels(page) - expect(l).toContain('레벨') - expect(l).toContain('골드') - expect(l).toContain('15레벨 도달') - expect(l).not.toContain('Level') + expect(l).toContain('생두 재고') + expect(l).toContain('로스팅 원두 재고') + expect(l).toContain('생두 입고') + expect(l).not.toContain('Green bean stock') } finally { await page.close() } @@ -239,26 +265,26 @@ test.describe('template label overlay', () => { // KO menu open ⇒ Korean labels; then the user renames a node await setLocale(page, 'ko') - await pickTemplate(page, MMO_KO) - expect(await labels(page)).toContain('레벨') + await pickTemplate(page, COFFEE_KO) + expect(await labels(page)).toContain('생두 재고') await unlockCanvas(page) await page.evaluate(() => { const gs = (window as unknown as { __loop: Loop }).__loop.graph.getState() - const gold = gs.nodes.find((n: any) => n.id === 'gold') - gs.updateNodeData(gold.id, { label: '내 금고' }) + const roasted = gs.nodes.find((n: any) => n.id === 'roasted_stock') + gs.updateNodeData(roasted.id, { label: '내 원두 창고' }) }) // switch the app language to EN ⇒ OFFICIAL labels follow, the rename stays await setLocale(page, 'en') - expect(await labels(page)).toContain('Level') - expect(await labels(page)).not.toContain('레벨') - expect(await labels(page)).toContain('내 금고') + expect(await labels(page)).toContain('Green bean stock') + expect(await labels(page)).not.toContain('생두 재고') + expect(await labels(page)).toContain('내 원두 창고') // let the autosave debounce persist, then a plain reload await expect .poll(() => page.evaluate( - (k) => localStorage.getItem(k)?.includes('내 금고') ?? false, + (k) => localStorage.getItem(k)?.includes('내 원두 창고') ?? false, GRAPH_STORAGE_KEY, ), ) @@ -271,17 +297,17 @@ test.describe('template label overlay', () => { // change so §TLO11 does nothing either expect(await htmlLang(page)).toBe('en') const restored = await labels(page) - expect(restored).toContain('Level') - expect(restored).toContain('Reached level 15') // official label followed the switch - expect(restored).toContain('내 금고') // the user rename survived the reload - expect(restored).not.toContain('레벨') + expect(restored).toContain('Green bean stock') + expect(restored).toContain('Green bean delivery') // official label followed the switch + expect(restored).toContain('내 원두 창고') // the user rename survived the reload + expect(restored).not.toContain('생두 재고') // and opening the English Template fresh from the menu is the canonical await resetAll(page) - await pickTemplate(page, MMO_EN) + await pickTemplate(page, COFFEE_EN) const en = await labels(page) - expect(en).toContain('Level') - expect(en).not.toContain('레벨') + expect(en).toContain('Roasted bean stock') + expect(en).not.toContain('생두 재고') }) }) @@ -303,7 +329,7 @@ test.describe('official template label — locale switch (§TLO11)', () => { test('KO → JA → EN round-trips the official labels of the bundled templates', async ({ page }) => { await openApp(page) const cases = [ - { pick: MMO_KO, ko: '레벨', ja: 'レベル', en: 'Level' }, + { pick: COFFEE_KO, ko: '생두 재고', ja: '生豆在庫', en: 'Green bean stock' }, { pick: '용량 교착', ko: '가공', ja: '加工', en: 'Processing' }, ] for (const c of cases) { @@ -325,11 +351,11 @@ test.describe('official template label — locale switch (§TLO11)', () => { await openApp(page) await resetAll(page) await setLocale(page, 'en') - await pickTemplate(page, MMO_EN) + await pickTemplate(page, COFFEE_EN) // rename one official node to exactly "