Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fa36f5dc1 | ||
|
|
af0e0700f8 | ||
|
|
2f6dad5c16 | ||
|
|
aed901c906 |
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
@@ -85,7 +85,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 x<y?-1:x>y?1:a<b?-1:a>b?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};
|
||||
@@ -318,7 +318,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);
|
||||
@@ -348,7 +348,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);
|
||||
@@ -514,7 +514,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.
|
||||
@@ -530,7 +530,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();
|
||||
@@ -551,7 +551,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});
|
||||
|
||||
@@ -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})=>{
|
||||
|
||||
+12
-1
@@ -23,6 +23,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
|
||||
@@ -489,8 +490,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)
|
||||
@@ -510,6 +520,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})
|
||||
|
||||
@@ -2,6 +2,13 @@ import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { seedConcept, openConcept, openProduction } from './concept-inventory.mjs';
|
||||
|
||||
// The Activity store's periodic list read (activity-store.mjs POLL_MS) and the detail view's read throttle
|
||||
// (activity-job-view.mjs DETAIL_INTERVAL_MS).
|
||||
const POLL_MS = 15_000;
|
||||
const DETAIL_INTERVAL_MS = 1_000;
|
||||
// A request is reported to the test a little after the browser sends it; that delay is not a shorter wait.
|
||||
const REPORT_SLACK_MS = 100;
|
||||
|
||||
// Activity (#249 Activity-1): the concept's Activity page (app.js renderActivity 732–738), its top-bar button
|
||||
// (index.html 51–52) and sidebar row (app.js ~341), with the recorded D52 (counts of running jobs), D53 (the progress
|
||||
// pill) and D54 (real job items). Fake jobs come from tests/browser/workspace_activity_fixture.py.
|
||||
@@ -278,6 +285,8 @@ test('reload during a scan reattaches to the running job', async ({ page, reques
|
||||
});
|
||||
|
||||
test('lost server clears the pill and marks jobs ended', async ({ page, request }) => {
|
||||
// A poll interval after the restart, then failed re-reads backing off for 1 + 2 + 4 s.
|
||||
test.setTimeout(90_000);
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const job = await startJob(request);
|
||||
await step(request, job.id, { processed: 4000 });
|
||||
@@ -294,9 +303,12 @@ test('lost server clears the pill and marks jobs ended', async ({ page, request
|
||||
await expect(items(page).first().locator('p')).toHaveText(LOST);
|
||||
await expect(items(page).first().locator('progress, .row-actions')).toHaveCount(0);
|
||||
await expect(navRow(page).locator('.nav-count')).toHaveCount(0);
|
||||
// One re-read found the restart; there is no retry storm and no finish toast for a job that only vanished.
|
||||
await page.waitForTimeout(2500);
|
||||
expect(reads.length).toBeLessThanOrEqual(2);
|
||||
// One re-read found the restart; there is no retry storm and no finish toast for a job that only vanished. The
|
||||
// read after it is the periodic one, a whole poll interval later: any extra read lands in that gap and fails it.
|
||||
const found = reads.length;
|
||||
expect(found, 'reads that found the restart').toBeGreaterThanOrEqual(1);
|
||||
await expect.poll(() => reads.length, { timeout: POLL_MS + 10_000 }).toBeGreaterThan(found);
|
||||
expect(reads[found] - reads[found - 1], 'the next read waits for the poll').toBeGreaterThanOrEqual(POLL_MS - REPORT_SLACK_MS);
|
||||
await expect(toast(page)).toHaveText('');
|
||||
|
||||
// A server that cannot be read at all also ends the running job, then is read again with a growing backoff.
|
||||
@@ -308,9 +320,12 @@ test('lost server clears the pill and marks jobs ended', async ({ page, request
|
||||
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
|
||||
await expect(pill(page)).toBeHidden();
|
||||
await expect(items(page).filter({ hasText: LOST })).toHaveCount(1);
|
||||
await page.waitForTimeout(3500);
|
||||
// 1 s, then 2 s: at most two re-reads in 3.5 s.
|
||||
expect(failed.length).toBeLessThanOrEqual(2);
|
||||
// 1 s, then 2 s, then 4 s: each failed re-read waits twice as long as the one before. The gaps between the
|
||||
// reads themselves are the evidence, so a slow runner noticing the ended job late cannot add a read to a window.
|
||||
await expect.poll(() => failed.length, { timeout: 20_000 }).toBeGreaterThanOrEqual(3);
|
||||
const gaps = failed.slice(1, 3).map((at, index) => at - failed[index]);
|
||||
expect(gaps[0]).toBeGreaterThanOrEqual(2000 - REPORT_SLACK_MS);
|
||||
expect(gaps[1]).toBeGreaterThanOrEqual(4000 - REPORT_SLACK_MS);
|
||||
await page.unroute('**/prompt_manager/jobs**');
|
||||
expect(second.id).not.toBe(job.id);
|
||||
});
|
||||
@@ -607,6 +622,8 @@ test('filter narrows the selected outcome', async ({ page, request }) => {
|
||||
|
||||
test('job view renders 200-row cap note', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
// The page's timers are Playwright's, running at real speed, so the M12 check below can run a read throttle out.
|
||||
await page.clock.install();
|
||||
const job = await startJob(request);
|
||||
await step(request, job.id, { processed: 260, bulk: { group: 'added', count: 250 }, records: [
|
||||
...Array.from({ length: 10 }, (_, index) => ({ group: 'no_metadata', path: `misc/plain_${index}.png`, detail: 'No prompt metadata in the file' })),
|
||||
@@ -646,11 +663,15 @@ test('job view renders 200-row cap note', async ({ page, request }) => {
|
||||
page.on('request', item => { if (new URL(item.url()).pathname === `/prompt_manager/jobs/${job.id}`) reads.push(Date.now()); });
|
||||
await step(request, job.id, { processed: 300, bulk: { group: 'added', count: 40 } });
|
||||
await expect(tiles(page).first().locator('dd')).toHaveText('290');
|
||||
await page.waitForTimeout(1500);
|
||||
// Every read the counter update could have scheduled is due within one throttle interval: run it out, so any such
|
||||
// read is sent before the log opens. Requests reach the test in the order they were sent, so once the log's read
|
||||
// has answered, a read sent before it would already be listed.
|
||||
await page.clock.runFor(DETAIL_INTERVAL_MS);
|
||||
expect(reads).toEqual([]);
|
||||
await scanLog(page).locator('summary').click();
|
||||
await expect.poll(() => reads.length).toBe(1);
|
||||
await expect(scanLog(page).locator('pre')).toContainText('bulk/file_00039.png');
|
||||
expect(reads, 'only the log\'s own read').toHaveLength(1);
|
||||
});
|
||||
|
||||
test('cancel scan ends cancelled with partial counts', async ({ page, request }) => {
|
||||
@@ -705,14 +726,17 @@ test('a held legacy job shows no Cancel in the list or its view', async ({ page,
|
||||
|
||||
test('busy start names the running job', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
// The page's timers are Playwright's, so the toast's lifetime below is run out rather than waited for.
|
||||
await page.clock.install();
|
||||
const running = await startJob(request, { title: 'Database maintenance', kind: 'maintenance' });
|
||||
await startFromImport(page);
|
||||
await expect(toast(page)).toHaveText('Another job is running: Database maintenance.');
|
||||
await expect(toast(page).getByRole('link', { name: 'Database maintenance', exact: true })).toHaveAttribute('href', `#/activity/${running.id}`);
|
||||
await expect(page).toHaveURL(/#\/images$/);
|
||||
expect((await listed(request)).map(item => item.id)).toEqual([running.id]);
|
||||
// Review M14: a toast with a link outlasts the plain 6.5 s, so the link stays reachable.
|
||||
await page.waitForTimeout(7000);
|
||||
// Review M14: a toast with a link outlasts the plain 6.5 s, so the link stays reachable. Running the page's timers
|
||||
// 7 s on fires a 6.5 s clear for certain, however slowly this runner reached this line.
|
||||
await page.clock.runFor(7000);
|
||||
await expect(toast(page).getByRole('link', { name: 'Database maintenance', exact: true })).toBeVisible();
|
||||
await toast(page).getByRole('link', { name: 'Database maintenance', exact: true }).click();
|
||||
await expect(heading(page).locator('h1')).toHaveText('Database maintenance');
|
||||
|
||||
@@ -907,6 +907,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');
|
||||
|
||||
@@ -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}"]`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user