diff --git a/tests/browser/concept-inventory.mjs b/tests/browser/concept-inventory.mjs index 862ad30..c082a7f 100644 --- a/tests/browser/concept-inventory.mjs +++ b/tests/browser/concept-inventory.mjs @@ -78,6 +78,17 @@ export async function seedConcept(request,data={}) { return response.json(); } +/** + * Seed the concept fixture and fix the browser's clock at the fixture's own instant (`manifest.now`), so the page and + * the fixture read one "now": a prompt used now reads "Used today" and the caption names the fixture's day even when + * the run crosses midnight between seeding and rendering. Timers keep running; only `Date` stands still. + */ +export async function seedConceptAt(page,request,data={}) { + const manifest=await seedConcept(request,data); + await page.clock.setFixedTime(new Date(manifest.now*1000)); + return manifest; +} + /** * Save `tags` as the current image tag set of the listed file at `path` (root 0) through the real observe and tag * routes (#249 PR C), releasing the capture afterwards; returns the receipt's saved names. diff --git a/tests/browser/images-concept.spec.mjs b/tests/browser/images-concept.spec.mjs index e7e219c..7d9b1e1 100644 --- a/tests/browser/images-concept.spec.mjs +++ b/tests/browser/images-concept.spec.mjs @@ -1,7 +1,7 @@ import {writeFile} from 'node:fs/promises'; import {test,expect} from './fixtures.mjs'; import {captureCanonicalState} from './history-canonical-comparison.mjs'; -import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow,withoutItems} from './concept-inventory.mjs'; +import {SIZES,THEMES,MOTIONS,seedConceptAt,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow,withoutItems} from './concept-inventory.mjs'; // Task C7 of #249: the production Images page against the immutable concept, on the concept-shaped real fixture (24 // images of the concept's six prompts, its seeds, sizes and favorites). Heading, toolbar, filter row, caption, grid, @@ -83,7 +83,7 @@ function listInfo(card,actionsWidth) { /** Recorded Images deviations only (spec §8); everything else in the concept inventory must equal production. */ const nocase=(a,b)=>{const x=a.toLowerCase(),y=b.toLowerCase();return xy?1:ab?1:0;}; -export function mapImagesConcept(reference,{manifest,now=new Date()}) { +export function mapImagesConcept(reference,{manifest,now=new Date(manifest.now*1000)}) { const paths=new Map(manifest.images.map(image=>[image.filename,image.path])); // D14: the sort appends production's two sorts. Save view is the concept's own (saved views, #249 D16 retired). const toolbar={...reference.toolbar}; @@ -316,7 +316,7 @@ async function behaviorsOf(page,scope) { return {hover,opened,viewerTitle,focusAfterViewer,selected,layouts,search,single}; } -test.beforeEach(async({request},testInfo)=>{testInfo.manifest=await seedConcept(request);}); +test.beforeEach(async({page,request},testInfo)=>{testInfo.manifest=await seedConceptAt(page,request);}); for(const [width,height] of SIZES)for(const theme of THEMES)test(`images page geometry, tokens, controls and behaviors match the concept ${width}x${height} ${theme}`,async({page,request},testInfo)=>{ test.setTimeout(240_000); @@ -346,7 +346,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images page ge importDiff:diffInventory(mapImportConcept(referenceImport),actualImport),referenceBehaviors,actualBehaviors}; } // Load more in production: a fixture past one listing page appends one batch and shows the concept's toast. - testInfo.manifest=await seedConcept(request,{extra_images:LOAD_MORE_EXTRA}); + testInfo.manifest=await seedConceptAt(page,request,{extra_images:LOAD_MORE_EXTRA}); for(const motion of MOTIONS) { const total=FIXTURE_IMAGES+LOAD_MORE_EXTRA; await openProductionLoaded(page,theme,motion,total); @@ -507,7 +507,7 @@ test('one appended image reads in the singular',async({page,request})=>{ test.setTimeout(120_000); await page.setViewportSize({width:1600,height:1100}); const total=PRODUCTION_BATCH+1; - await seedConcept(request,{extra_images:total-FIXTURE_IMAGES}); + await seedConceptAt(page,request,{extra_images:total-FIXTURE_IMAGES}); await openProductionLoaded(page,'dark','no-preference',total); await expect(page.locator('.infinite-continuation > p').first()).toHaveText(countCopy(`${PRODUCTION_BATCH} of ${total} images · more load as you scroll`)); // A dispatched press: scrolling the button into view lets the page's own observer load the one image first. @@ -523,7 +523,7 @@ test('one image appended by scrolling reads in the singular',async({page,request test.setTimeout(120_000); await page.setViewportSize({width:1600,height:1100}); const total=PRODUCTION_BATCH+1; - await seedConcept(request,{extra_images:total-FIXTURE_IMAGES}); + await seedConceptAt(page,request,{extra_images:total-FIXTURE_IMAGES}); await openProductionLoaded(page,'dark','no-preference',total); await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${PRODUCTION_BATCH} of ${total} images`)); await page.locator('.infinite-continuation').scrollIntoViewIfNeeded(); @@ -544,7 +544,7 @@ test('one footer height for the collection (D3)',async({page,request})=>{ const reference=await inventoryOf(page,'#main'); expect(reference.grid.columns).toBe(2); expect(reference.cards.every(card=>card.info.p.height>parseFloat(card.info.p.lineHeight))).toBe(true); - const manifest=await seedConcept(request,{seedless_images:2,short_seedless:true}); + const manifest=await seedConceptAt(page,request,{seedless_images:2,short_seedless:true}); await openProductionLoaded(page,'dark','no-preference',FIXTURE_IMAGES+2); const actual=await inventoryOf(page,'#workspace-main'); const box=card=>({left:card.left,top:card.top,width:card.width,height:card.height}); diff --git a/tests/browser/library-concept.spec.mjs b/tests/browser/library-concept.spec.mjs index eeab9b9..8e85bae 100644 --- a/tests/browser/library-concept.spec.mjs +++ b/tests/browser/library-concept.spec.mjs @@ -1,7 +1,7 @@ import {writeFile} from 'node:fs/promises'; import {test,expect} from './fixtures.mjs'; import {captureCanonicalState} from './history-canonical-comparison.mjs'; -import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow} from './concept-inventory.mjs'; +import {SIZES,THEMES,MOTIONS,seedConceptAt,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow} from './concept-inventory.mjs'; // Task E3 of #249: the production Prompt library shelf against the immutable concept (app.js renderLibrary a572–586, // style.css s648–708), on the concept-shaped real fixture (the concept's six prompts with their negatives, categories, @@ -37,7 +37,8 @@ export function mapLibraryConcept(reference) { // D43: tag chips come in the store's alphabetical order; the row flows again from the concept's own chips. tags:sortedTags(card.tags), // D23: the stars are aria-hidden, with a visually hidden "Rated N out of 5" spoken in their place. The fixture's - // run_count (4) and last_used_at (now) give the concept's own "4 runs" and "Used today". The two-line clamp is + // run_count (4) and last_used_at (its own "now", where the page's clock stands) give the concept's own "4 runs" + // and "Used today". The two-line clamp is // applied below through `clampedCards`, measured on the concept with `CLAMP_CSS` (its samples wrap at 390px). footer:{...card.footer,facts:{...card.footer.facts,spoken:card.footer.facts.spoken.replace(/^(★+)/,stars=>`Rated ${stars.length} out of 5`)}}})); return {...reference,library:{...library,toolbar, @@ -238,7 +239,8 @@ async function openHost(context) { } test.beforeEach(async({page,request},testInfo)=>{ - testInfo.manifest=await seedConcept(request); + // The page's clock stands at the fixture's own "now", so its last use reads "Used today" on either side of midnight. + testInfo.manifest=await seedConceptAt(page,request); // Both documents copy through the clipboard; the stub records what they wrote. await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.copied.push(text);}}});}); }); @@ -322,6 +324,27 @@ for(const theme of THEMES)test(`library grid and cards match the concept 1800x11 expect(compare(reference,actual,{sections:['library'],library:['grid','cards','trailing']})).toEqual([]); }); +// PR #284's CI run: the fixture recorded the last use at 23:59:5x UTC and the shelf rendered at 00:00:05, so production +// read "Used yesterday" against the concept's "Used today". Here the browser's wall clock starts at 23:59:58 UTC, the +// fixture records its use at that instant, and midnight passes before either document opens. +const BEFORE_MIDNIGHT=Date.UTC(2026,9,22,23,59,58); +const PAST_MIDNIGHT_MS=5_000; +test('a run that crosses midnight still reads the concept fixture\'s day',async({page,request})=>{ + await page.setViewportSize({width:1800,height:1100}); + await page.clock.install({time:BEFORE_MIDNIGHT}); + const manifest=await seedConceptAt(page,request,{now:BEFORE_MIDNIGHT/1000}); + expect(manifest.now*1000,'the fixture\'s now').toBe(BEFORE_MIDNIGHT); + const used=(await (await request.get('/prompt_manager/library')).json()).items.map(item=>Date.parse(item.last_used_at)); + expect(used,'every last use at the fixture\'s now').toEqual(Array(CONCEPT_PROMPTS).fill(BEFORE_MIDNIGHT)); + await page.clock.fastForward(PAST_MIDNIGHT_MS); + await openConceptLibrary(page,'dark','no-preference'); + const reference=await conceptInventory(page); + await openProductionLibrary(page,'dark','no-preference'); + const actual=await inventoryOf(page,'#workspace-main'); + expect(actual.library.cards.map(card=>card.footer.facts.text.replace(/^★+ · /,''))).toEqual(Array(CONCEPT_PROMPTS).fill('Used today')); + expect(compare(reference,actual,{sections:['library'],library:['grid','cards','trailing']})).toEqual([]); +}); + // The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the footer // padding) and one DOM mutation (the negative-variants chip removed), each applied after an exact match. test('library comparison detects mutations',async({page})=>{ diff --git a/tests/browser/server.py b/tests/browser/server.py index 0d202da..608b055 100644 --- a/tests/browser/server.py +++ b/tests/browser/server.py @@ -22,6 +22,7 @@ from tests.browser.workspace_activity_fixture import register_activity_commands from tests.browser.workspace_concept_fixture import ( MAX_EXTRA_IMAGES as CONCEPT_MAX_EXTRA_IMAGES, MAX_SEEDLESS_IMAGES as CONCEPT_MAX_SEEDLESS_IMAGES, + MAX_EPOCH_SECONDS as CONCEPT_MAX_EPOCH_SECONDS, seed_workspace_concept, ) from tests.browser.workspace_visual_fixture import seed_workspace_visual @@ -486,8 +487,17 @@ def create_app(root: Path) -> web.Application: if ( request.query or not isinstance(data, dict) - or set(data) - set(limits) - {"short_seedless"} + or set(data) - set(limits) - {"short_seedless", "now"} or not isinstance(data.get("short_seedless", False), bool) + # A chosen "now" (epoch seconds) pins every time the fixture writes. + or ( + "now" in data + and ( + isinstance(data["now"], bool) + or not isinstance(data["now"], (int, float)) + or not 0 < data["now"] < CONCEPT_MAX_EPOCH_SECONDS + ) + ) or any( isinstance(data.get(key, 0), bool) or not isinstance(data.get(key, 0), int) @@ -507,6 +517,7 @@ def create_app(root: Path) -> web.Application: extra_images=data.get("extra_images", 0), seedless_images=data.get("seedless_images", 0), short_seedless=data.get("short_seedless", False), + now=data.get("now"), ) ) return web.json_response({"success": True, **manifest}) diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index bd07349..fe9dce6 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.spec.mjs @@ -902,6 +902,10 @@ test('browser Back closes the open Images dialog and leaves the next page usable test('count line shows images and positive prompts', async ({ page, request }) => { await page.addInitScript(() => { window.IntersectionObserver = undefined; }); + // D17's "Today" is the newest file's day: the page's clock stands at that file's mtime, so the fixture's day and + // the page's agree on either side of midnight. + const { images } = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json(); + await page.clock.setFixedTime(new Date(Math.max(...images.map(image => image.modified_time)) * 1000)); await page.goto(workspace); await expect(cards(page)).toHaveCount(2); await expect(caption(page).locator('#result-count')).toHaveText('2 of 2 images · 2 positive prompts'); diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs index 7055766..5ef235f 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -610,7 +610,9 @@ test('card matches the concept and never wraps the category mid-word',async({pag await expect(await request.post('/prompt_manager/bulk/category',{data:{prompt_ids:[dual.id],category:''}})).toBeOK(); // A long saved title fills the title row, so only the chip's own min-content keeps "Uncategorized" and "asian" whole. const wide='A remarkably long saved positive prompt title that fills the whole card title row'; - await patchShelf(page,item=>item.id===cat.id?{run_count:4,last_used_at:new Date().toISOString(),rating:5}:item.id===dual.id||item.id===dog.id?{title:wide,run_count:1,last_used_at:null,rating:null}:{}); + // One "now" for the patched last use and the page's clock, so it reads "Used today" on either side of midnight. + const usedAt=new Date();await page.clock.setFixedTime(usedAt); + await patchShelf(page,item=>item.id===cat.id?{run_count:4,last_used_at:usedAt.toISOString(),rating:5}:item.id===dual.id||item.id===dog.id?{title:wide,run_count:1,last_used_at:null,rating:null}:{}); for(const [width,height] of [[1600,1100],[390,844]]) { await page.setViewportSize({width,height});await page.goto(route);await expect(cards(page)).toHaveCount(3); const card=page.locator(`article[data-prompt-id="${cat.id}"]`); diff --git a/tests/browser/workspace_concept_fixture.py b/tests/browser/workspace_concept_fixture.py index b22c362..bcb306f 100644 --- a/tests/browser/workspace_concept_fixture.py +++ b/tests/browser/workspace_concept_fixture.py @@ -11,6 +11,7 @@ the same sidebar counts (24 images, 6 prompts, 15 tags). Nothing is mocked. from __future__ import annotations from dataclasses import dataclass +import datetime import json import math import os @@ -157,6 +158,8 @@ CONCEPT_MODEL = "illustrative-model.safetensors" MAX_EXTRA_IMAGES = 120 # Unlinked, seedless images (D11 "W × H · PNG") newer than the concept's, for D3 checks. MAX_SEEDLESS_IMAGES = 2 +# A test's chosen "now" (epoch seconds) stays before the year 3000. +MAX_EPOCH_SECONDS = 32_503_680_000 SEEDLESS_SIZE = (1536, 1152) # The same 4:3 shape with a short "96 × 72 · PNG" subtitle that stays on one line at # 390px beside the card's Info and Tag (the D3 footer check). @@ -342,6 +345,17 @@ def _bounded(name: str, value: int, maximum: int) -> None: raise ValueError(f"{name} is out of range") +def _instant(now: float | None) -> float: + """The fixture's one "now": ``now`` itself, or the wall clock when omitted.""" + if now is None: + return time.time() + if isinstance(now, bool) or not isinstance(now, (int, float)): + raise TypeError("now must be a number of epoch seconds") + if not 0 < now < MAX_EPOCH_SECONDS: + raise ValueError("now is out of range") + return float(now) + + def seed_workspace_concept( api: PromptManagerAPI, root: Path, @@ -349,9 +363,16 @@ def seed_workspace_concept( extra_images: int = 0, seedless_images: int = 0, short_seedless: bool = False, + now: float | None = None, ) -> dict: """Replace this server's disposable rows and files with the concept's data. + Every relative time the fixture writes (the prompts' last use, the files' + mtimes, the runs' observation times) is taken from one instant, ``now`` + (epoch seconds, the wall clock when omitted), returned as ``manifest["now"]``. + A browser whose clock is fixed at that instant reads the concept's "Used + today" and caption day however close to midnight the run starts. + ``extra_images`` continues the concept's rounds (same prompts, dimensions and seed rule) after its 24 images, for bounded-rendering and Load more checks. ``seedless_images`` adds unlinked PNGs without metadata, newest of all; @@ -361,6 +382,10 @@ def seed_workspace_concept( raise TypeError("short_seedless must be a boolean") _bounded("extra_images", extra_images, MAX_EXTRA_IMAGES) _bounded("seedless_images", seedless_images, MAX_SEEDLESS_IMAGES) + now = _instant(now) + used_at = datetime.datetime.fromtimestamp(now, datetime.timezone.utc).isoformat( + timespec="microseconds" + ) _clear(api, root) db = api.db epoch = db.get_execution_epoch() @@ -385,8 +410,15 @@ def seed_workspace_concept( db.update_prompt_pair_metadata(pair_ids[0], title=first_name) for name, text in prompt.negatives[1:]: pair_ids.append(db.save_prompt_pair(prompt.positive, text, title=name)) - # Real queue-hook counting (D23): four runs, the last one now. + # Real queue-hook counting (D23): four runs, the last one now, at the + # fixture's own instant rather than the database's later wall clock. db.record_prompt_use(positive_id, times=CONCEPT_RUN_COUNT) + with db.model.get_connection() as conn: + conn.execute( + "UPDATE prompts SET last_used_at = ? WHERE id = ?", + (used_at, positive_id), + ) + conn.commit() prompts.insert( 0, {"id": prompt.id, "positive_id": positive_id, "pair_ids": pair_ids} ) @@ -400,7 +432,6 @@ def seed_workspace_concept( favorite_root = _favorite_root_key(list(api._get_all_output_dirs())[0]) total = len(CONCEPT_PROMPTS) * CONCEPT_IMAGE_ROUNDS + extra_images rounds = math.ceil(total / len(CONCEPT_PROMPTS)) - now = time.time() images, runs = [], [] slots = [ (round_index, prompt_index, prompt) @@ -484,6 +515,8 @@ def seed_workspace_concept( api.invalidate_gallery_cache() return { "epoch": epoch, + # The fixture's "now" (epoch seconds), for the browser's fixed clock. + "now": now, # The newest file's mtime (epoch seconds), for the D17 caption date. "newest_modified": now - 10 if seedless_images else now - 60, "seedless": seedless, diff --git a/tests/test_workspace_concept_fixture.py b/tests/test_workspace_concept_fixture.py index f301de6..f855211 100644 --- a/tests/test_workspace_concept_fixture.py +++ b/tests/test_workspace_concept_fixture.py @@ -1,7 +1,9 @@ """The concept fixture files carry the concept inspector facts (app.js:400-414).""" import json +import math import tempfile +import time import unittest from pathlib import Path @@ -15,6 +17,8 @@ except ImportError: # discovered with tests/ as the top-level directory from tests.browser.workspace_concept_fixture import ( CONCEPT_MODEL, CONCEPT_PROMPTS, + MAX_EPOCH_SECONDS, + _instant, write_concept_png, ) from utils.prompt_inspector import inspect_prompt_evidence, read_inspection_file @@ -80,5 +84,29 @@ class TestWorkspaceConceptFixture(unittest.TestCase): self.assertEqual((image["width"], image["height"]), size) +class TestConceptFixtureInstant(unittest.TestCase): + """One "now" for every relative time the fixture writes (PR #284's midnight).""" + + def test_a_chosen_instant_is_kept(self): + self.assertEqual(_instant(1_792_713_598), 1_792_713_598.0) + self.assertEqual(_instant(1_792_713_598.25), 1_792_713_598.25) + + def test_the_wall_clock_when_omitted(self): + before = time.time() + now = _instant(None) + self.assertLessEqual(before, now) + self.assertLessEqual(now, time.time()) + + def test_a_non_number_is_refused(self): + for value in (True, "1792713598", [1]): + with self.subTest(value=value), self.assertRaises(TypeError): + _instant(value) + + def test_an_instant_out_of_range_is_refused(self): + for value in (0, -1, MAX_EPOCH_SECONDS, math.inf, math.nan): + with self.subTest(value=value), self.assertRaises(ValueError): + _instant(value) + + if __name__ == "__main__": unittest.main()