Govern task context and advance execution pointer
This commit is contained in:
BIN
web/app/public/vendor/blender/web_engine.wasm
vendored
BIN
web/app/public/vendor/blender/web_engine.wasm
vendored
Binary file not shown.
@@ -873,7 +873,7 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea
|
||||
</div>
|
||||
<div className="property-section"><h3>Transform</h3><label>位置 <output>0.000, 0.000, 0.000</output></label><label>旋转 <output>0°, 0°, 0°</output></label><label>缩放 <output>1.000, 1.000, 1.000</output></label></div>
|
||||
{activeNode ? <div className="property-section"><h3>Object & Hierarchy</h3><label>名称 <input aria-label="对象名称" value={renameValue} onChange={(event) => setRenameValue(event.target.value)} /></label><div className="property-actions"><button type="button" onClick={() => renameValue && onCommand({ type: "renameId", id: activeNode.id, name: renameValue })}>重命名</button><button type="button" onClick={() => onCommand({ type: "applyObjectTransform", objectId: activeNode.id })}>应用变换</button><button type="button" onClick={() => onCommand({ type: "setObjectOrigin", objectId: activeNode.id, mode: "GEOMETRY" })}>原点到几何体</button></div><label>Collection <select aria-label="移动到 Collection" value="" onChange={(event) => event.target.value && onCommand({ type: "moveObjectToCollection", objectId: activeNode.id, collectionId: event.target.value })}><option value="">选择...</option>{snapshot?.collections.map((collection) => <option key={collection.id} value={collection.id}>{collection.name}</option>)}</select></label></div> : null}
|
||||
<div className="property-section"><h3>Viewport Display</h3><label>显示颜色 <span className="swatch" /></label><label>可见性 <input type="checkbox" defaultChecked /></label></div>
|
||||
<div className="property-section"><h3>Viewport Display</h3><label>显示颜色 <span className="swatch" /></label><label>可见性 <input aria-label="可见性" type="checkbox" defaultChecked /></label></div>
|
||||
{activeCurve && curveTopologyOperatorGate("TOGGLE_CYCLIC").status === "READY" ? <div className="property-section" data-testid="curve-topology-editor" data-ready-operators="TOGGLE_CYCLIC" data-curve-id={activeCurve.id} data-cyclic-u={(activeCurve.cyclicU ?? []).join(",")} data-spline-types={(activeCurve.splineTypes ?? []).join(",")} data-point-count={activeCurve.pointCount}><h3>Curve Topology</h3><label>Spline <select aria-label="Curve spline" value={Math.min(curveSplineIndex, Math.max(0, activeCurve.splineCount - 1))} onChange={(event) => setCurveSplineIndex(Number(event.target.value))}>{Array.from({ length: activeCurve.splineCount }, (_, index) => <option key={index} value={index}>{index + 1} {activeCurve.splineTypes?.[index] ?? "UNKNOWN"}</option>)}</select></label><label>Cyclic U <input aria-label="Curve cyclic U" type="checkbox" checked={activeCurve.cyclicU?.[curveSplineIndex] ?? false} onChange={toggleCurveCyclic} /></label><output data-testid="curve-cyclic-state">{activeCurve.cyclicU?.[curveSplineIndex] ? "Cyclic" : "Open"}</output>{curveTopologyError ? <output role="alert">{curveTopologyError}</output> : null}</div> : null}
|
||||
{activeMesh ? <div className="property-section"><h3>UV Maps</h3><label>活动 UV <select aria-label="活动 UV Map" value={activeMesh.activeUVMap ?? ""} onChange={(event) => event.target.value && onCommand({ type: "setActiveUVMap", meshId: activeMesh.id, name: event.target.value })}><option value="">None</option>{activeMesh.uvLayers?.map((layer) => <option key={layer.name} value={layer.name}>{layer.name}</option>)}</select></label><div className="property-actions"><button type="button" onClick={() => onCommand({ type: "createUVMap", meshId: activeMesh.id, name: `UVMap.${(activeMesh.uvLayers?.length ?? 0) + 1}` })}>新增 UV</button><button type="button" disabled={selectedFaceIndices.length === 0} onClick={() => onCommand({ type: "unwrapUV", meshId: activeMesh.id, faceIndices: selectedFaceIndices, method: "PLANAR" })}>Planar</button><button type="button" disabled={selectedFaceIndices.length === 0} onClick={() => onCommand({ type: "unwrapUV", meshId: activeMesh.id, faceIndices: selectedFaceIndices, method: "CUBE" })}>Cube</button></div></div> : null}
|
||||
{activeGreasePencil ? <div
|
||||
|
||||
@@ -80,7 +80,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
|
||||
.operator-results { display: grid; gap: 2px; margin-top: 6px; }.operator-results button { padding: 8px 10px; text-align: left; color: #d9dce1; background: transparent; border-radius: 3px; }.operator-results button:hover { color: #fff; background: #a55325; }
|
||||
.outliner-tools input:focus-visible, .operator-search input:focus-visible { outline: 2px solid #f2a15b; outline-offset: 1px; }
|
||||
.file-button:has(input:focus-visible) { outline: 2px solid #f2a15b; outline-offset: 1px; }
|
||||
@media (max-width: 800px) {
|
||||
@media (max-width: 900px) {
|
||||
.topbar { min-width: 0; overflow-x: auto; scrollbar-width: none; }
|
||||
.topbar::-webkit-scrollbar { display: none; }
|
||||
.topbar > *, .topbar-actions button { flex: none; white-space: nowrap; }
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { OffscreenViewportRequest, OffscreenViewportResponse } from "./offs
|
||||
import { PBR_PROFILE, PBR_SHADOW_PROFILE, PBR_TONE_MAPPING } from "./pbr";
|
||||
import { resolveViewportPixelMetrics, viewportNDC } from "../../../protocol/viewport-dpr";
|
||||
import { observePointerEvent } from "../../../protocol/pointer-contract";
|
||||
import { createInputModalState, reduceInputModal, type InputModalState } from "../../../protocol/input-modal";
|
||||
import type { NonMeshElementKind } from "./nonmesh";
|
||||
import type { GreasePencilPointPreview, GreasePencilPointRef } from "./grease-pencil";
|
||||
import type { CurveGizmoFrameIR, CurveGizmoHandleIR } from "../../../protocol/nonmesh-interaction";
|
||||
@@ -88,6 +89,8 @@ export class OffscreenViewportRenderer implements ViewportBackend {
|
||||
private readonly onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean, baseSelectionRevision: number) => void;
|
||||
private readonly onGreasePencilMarqueeSelect?: (result: GreasePencilMarqueeResultIR, additive: boolean) => void;
|
||||
private pointer: { id: number; x: number; y: number; moved: boolean } | null = null;
|
||||
private inputModal: InputModalState = createInputModalState();
|
||||
private touchNavigationActive = false;
|
||||
private lastSnapshot: SceneSnapshotIR | null = null;
|
||||
private lastGeometryBuffers: MeshGeometryBuffer[] | null = null;
|
||||
private lastNonMeshGeometryBuffers: NonMeshGeometryChunk[] | null = null;
|
||||
@@ -134,6 +137,7 @@ export class OffscreenViewportRenderer implements ViewportBackend {
|
||||
canvas.dataset.pbrProfile = PBR_PROFILE;
|
||||
canvas.dataset.toneMapping = PBR_TONE_MAPPING;
|
||||
canvas.dataset.shadowMap = PBR_SHADOW_PROFILE;
|
||||
this.publishInputModal();
|
||||
}
|
||||
|
||||
setSnapshot(snapshot: SceneSnapshotIR, geometryBuffers: MeshGeometryBuffer[] = [], nonMeshGeometryBuffers: NonMeshGeometryChunk[] = []): void {
|
||||
@@ -252,8 +256,8 @@ export class OffscreenViewportRenderer implements ViewportBackend {
|
||||
}
|
||||
|
||||
private pointerDown = (event: PointerEvent): void => {
|
||||
try { this.canvas.dataset.lastPointer = JSON.stringify(observePointerEvent(event)); }
|
||||
catch { this.canvas.dataset.lastPointer = "BLOCKED"; }
|
||||
const observation = this.recordInputModalEvent(event);
|
||||
if (observation?.pointerType === "touch" && this.inputModal.activePointerIds.length >= 2) this.touchNavigationActive = true;
|
||||
this.pointer = { id: event.pointerId, x: event.clientX, y: event.clientY, moved: false };
|
||||
try {
|
||||
this.canvas.setPointerCapture(event.pointerId);
|
||||
@@ -274,8 +278,17 @@ export class OffscreenViewportRenderer implements ViewportBackend {
|
||||
};
|
||||
|
||||
private pointerUp = (event: PointerEvent): void => {
|
||||
try { this.canvas.dataset.lastPointer = JSON.stringify(observePointerEvent(event)); }
|
||||
catch { this.canvas.dataset.lastPointer = "BLOCKED"; }
|
||||
const observation = this.recordInputModalEvent(event);
|
||||
const wasTouchNavigation = this.touchNavigationActive;
|
||||
if (observation?.pointerType === "touch" && this.inputModal.activePointerIds.length === 0) this.touchNavigationActive = false;
|
||||
if (!observation) {
|
||||
this.pointer = null;
|
||||
return;
|
||||
}
|
||||
if (event.type === "pointercancel" || observation.pointerType === "pen" || wasTouchNavigation) {
|
||||
this.pointer = null;
|
||||
return;
|
||||
}
|
||||
if (!this.pointer || this.pointer.id !== event.pointerId) return;
|
||||
if (!this.pointer.moved) {
|
||||
const bounds = this.canvas.getBoundingClientRect();
|
||||
@@ -285,6 +298,37 @@ export class OffscreenViewportRenderer implements ViewportBackend {
|
||||
this.pointer = null;
|
||||
};
|
||||
|
||||
private recordInputModalEvent(event: PointerEvent): ReturnType<typeof observePointerEvent> | null {
|
||||
try {
|
||||
const observation = observePointerEvent(event);
|
||||
this.canvas.dataset.lastPointer = JSON.stringify(observation);
|
||||
if (observation.pointerType === "touch" || observation.pointerType === "pen") {
|
||||
this.inputModal = reduceInputModal(this.inputModal, {
|
||||
type: event.type as "pointerdown" | "pointerup" | "pointercancel",
|
||||
pointerType: observation.pointerType,
|
||||
pointerId: observation.pointerId,
|
||||
});
|
||||
this.publishInputModal();
|
||||
}
|
||||
return observation;
|
||||
}
|
||||
catch {
|
||||
this.canvas.dataset.lastPointer = "BLOCKED";
|
||||
this.canvas.dataset.inputModalError = "INPUT_MODAL_EVENT_BLOCKED";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private publishInputModal(): void {
|
||||
this.canvas.dataset.inputModalSchemaVersion = String(this.inputModal.schemaVersion);
|
||||
this.canvas.dataset.inputModalKind = this.inputModal.kind;
|
||||
this.canvas.dataset.inputModalActivePointerIds = this.inputModal.activePointerIds.join(",");
|
||||
this.canvas.dataset.inputModalCancelled = this.inputModal.cancelled ? "1" : "0";
|
||||
this.canvas.dataset.inputModalNavigationRevision = String(this.inputModal.navigationRevision);
|
||||
this.canvas.dataset.inputModalMainCommitCount = String(this.inputModal.mainCommitCount);
|
||||
this.canvas.dataset.inputModalError = "";
|
||||
}
|
||||
|
||||
private wheel = (event: WheelEvent): void => {
|
||||
event.preventDefault();
|
||||
this.worker.postMessage({ type: "orbit", deltaX: 0, deltaY: 0, zoom: event.deltaY } satisfies OffscreenViewportRequest);
|
||||
|
||||
@@ -71,6 +71,7 @@ import { validatePaintDepthVisibilityRequest, type PaintDepthVisibilityRequestIR
|
||||
import { samplePaintDepthVisibilityGPU } from "./paint-depth-visibility";
|
||||
import { resolveViewportPixelMetrics, viewportNDC } from "../../../protocol/viewport-dpr";
|
||||
import { observePointerEvent } from "../../../protocol/pointer-contract";
|
||||
import { createInputModalState, reduceInputModal, type InputModalState } from "../../../protocol/input-modal";
|
||||
|
||||
export function collectMeshInstanceGroups(snapshot: SceneSnapshotIR, minimumSize = 2): Map<string, string[]> {
|
||||
const groups = new Map<string, string[]>();
|
||||
@@ -104,6 +105,7 @@ export class ViewportRenderer {
|
||||
private readonly textureStore = new GPUTextureStore("THREE_WEBGL2");
|
||||
private readonly raycaster = new Raycaster();
|
||||
private readonly pointer = new Vector2();
|
||||
private inputModal: InputModalState = createInputModalState();
|
||||
private readonly onSelect?: (objectId: string, additive: boolean) => void;
|
||||
private readonly onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void;
|
||||
private readonly onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean, baseSelectionRevision: number) => void;
|
||||
@@ -173,12 +175,14 @@ export class ViewportRenderer {
|
||||
this.resizeObserver.observe(canvas);
|
||||
this.canvas.addEventListener("click", this.handleClick);
|
||||
this.canvas.addEventListener("pointerdown", this.handlePointerObservation);
|
||||
this.canvas.addEventListener("pointerup", this.handlePointerObservation);
|
||||
this.canvas.addEventListener("pointercancel", this.handlePointerObservation);
|
||||
this.canvas.addEventListener("webglcontextlost", this.handleContextLost);
|
||||
this.canvas.addEventListener("webglcontextrestored", this.handleContextRestored);
|
||||
this.resize();
|
||||
this.controls.update();
|
||||
this.publishCameraState();
|
||||
this.publishInputModal();
|
||||
this.renderLoop();
|
||||
}
|
||||
|
||||
@@ -821,6 +825,8 @@ export class ViewportRenderer {
|
||||
if ("pointerType" in event) {
|
||||
try { this.canvas.dataset.lastPointer = JSON.stringify(observePointerEvent(event as MouseEvent & { pointerType?: string; pointerId?: number; pressure?: number; tiltX?: number; tiltY?: number; buttons?: number; type?: string })); }
|
||||
catch { this.canvas.dataset.lastPointer = "BLOCKED"; }
|
||||
const pointerType = (event as MouseEvent & { pointerType?: string }).pointerType;
|
||||
if ((pointerType === "touch" || pointerType === "pen") && this.inputModal.cancelled) return;
|
||||
}
|
||||
const bounds = this.canvas.getBoundingClientRect();
|
||||
if (bounds.width <= 0 || bounds.height <= 0) return;
|
||||
@@ -897,10 +903,34 @@ export class ViewportRenderer {
|
||||
};
|
||||
|
||||
private handlePointerObservation = (event: PointerEvent): void => {
|
||||
try { this.canvas.dataset.lastPointer = JSON.stringify(observePointerEvent(event)); }
|
||||
catch { this.canvas.dataset.lastPointer = "BLOCKED"; }
|
||||
try {
|
||||
const observation = observePointerEvent(event);
|
||||
this.canvas.dataset.lastPointer = JSON.stringify(observation);
|
||||
if (observation.pointerType === "touch" || observation.pointerType === "pen") {
|
||||
this.inputModal = reduceInputModal(this.inputModal, {
|
||||
type: event.type as "pointerdown" | "pointerup" | "pointercancel",
|
||||
pointerType: observation.pointerType,
|
||||
pointerId: observation.pointerId,
|
||||
});
|
||||
this.publishInputModal();
|
||||
}
|
||||
}
|
||||
catch {
|
||||
this.canvas.dataset.lastPointer = "BLOCKED";
|
||||
this.canvas.dataset.inputModalError = "INPUT_MODAL_EVENT_BLOCKED";
|
||||
}
|
||||
};
|
||||
|
||||
private publishInputModal(): void {
|
||||
this.canvas.dataset.inputModalSchemaVersion = String(this.inputModal.schemaVersion);
|
||||
this.canvas.dataset.inputModalKind = this.inputModal.kind;
|
||||
this.canvas.dataset.inputModalActivePointerIds = this.inputModal.activePointerIds.join(",");
|
||||
this.canvas.dataset.inputModalCancelled = this.inputModal.cancelled ? "1" : "0";
|
||||
this.canvas.dataset.inputModalNavigationRevision = String(this.inputModal.navigationRevision);
|
||||
this.canvas.dataset.inputModalMainCommitCount = String(this.inputModal.mainCommitCount);
|
||||
this.canvas.dataset.inputModalError = "";
|
||||
}
|
||||
|
||||
private renderLoop = (): void => {
|
||||
if (this.disposed) return;
|
||||
if (!this.contextLost) {
|
||||
@@ -965,6 +995,7 @@ export class ViewportRenderer {
|
||||
this.resizeObserver.disconnect();
|
||||
this.canvas.removeEventListener("click", this.handleClick);
|
||||
this.canvas.removeEventListener("pointerdown", this.handlePointerObservation);
|
||||
this.canvas.removeEventListener("pointerup", this.handlePointerObservation);
|
||||
this.canvas.removeEventListener("pointercancel", this.handlePointerObservation);
|
||||
this.canvas.removeEventListener("webglcontextlost", this.handleContextLost);
|
||||
this.canvas.removeEventListener("webglcontextrestored", this.handleContextRestored);
|
||||
|
||||
BIN
web/app/src/vendor/blender/web_engine.wasm
vendored
BIN
web/app/src/vendor/blender/web_engine.wasm
vendored
Binary file not shown.
@@ -59,6 +59,9 @@
|
||||
"test:physics-cache-family": "node --test tests/unit/physics-cache-family.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/physics-cache-family.spec.ts",
|
||||
"test:m10-domain-gates": "node --test tests/unit/geometry-nodes.test.mjs tests/unit/shader-compiler.test.mjs tests/unit/nla.test.mjs tests/unit/simulation-cache.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/m10-domain-browser-gates.spec.ts",
|
||||
"test:browser": "playwright test --config playwright.release.config.ts",
|
||||
"test:task-context": "node --test tests/unit/task-context.test.mjs && node ../tools/web/check-task-context.mjs",
|
||||
"test:context-governance": "node --test tests/unit/task-context.test.mjs tests/unit/context-governance.test.mjs && node ../tools/web/check-context-governance.mjs",
|
||||
"task:context": "node ../tools/web/print-task-context.mjs",
|
||||
"test:cross-browser": "npm run test:browser",
|
||||
"test:golden": "node ../tools/web/run-blender-golden.mjs",
|
||||
"test:collapse-ratios": "node ../tools/web/check-collapse-ratios.mjs",
|
||||
@@ -213,6 +216,26 @@
|
||||
"test:chromium-ime-guard": "node --test tests/unit/ime-composition.test.mjs && node ../tools/web/check-chromium-ime-guard.mjs",
|
||||
"test:chromium-keymap-fixture": "node --test tests/unit/keyboard-contract.test.mjs && node ../tools/web/check-chromium-keymap-fixture.mjs",
|
||||
"test:chromium-input-modal": "node --test tests/unit/input-modal.test.mjs && node ../tools/web/check-chromium-input-modal.mjs",
|
||||
"test:chromium-layout": "node ../tools/web/check-chromium-layout.mjs",
|
||||
"test:chromium-accessibility": "node ../tools/web/check-chromium-accessibility.mjs",
|
||||
"test:blender-rna-inventory": "node ../tools/web/check-blender-rna-datablock-inventory.mjs",
|
||||
"test:blender-operator-inventory": "node ../tools/web/check-blender-operator-inventory.mjs",
|
||||
"test:blender-family-inventory": "node ../tools/web/check-blender-family-inventory.mjs",
|
||||
"test:blender-format-inventory": "node ../tools/web/check-blender-format-inventory.mjs",
|
||||
"test:blender-editor-inventory": "node ../tools/web/check-blender-editor-inventory.mjs",
|
||||
"test:blender-core-inventory": "node ../tools/web/check-blender-core-inventory.mjs",
|
||||
"test:blender-parity-map": "node ../tools/web/check-blender-parity-map.mjs",
|
||||
"test:blender-gap-audit": "node ../tools/web/check-blender-gap-audit.mjs",
|
||||
"test:blender-local-exact-audit": "node ../tools/web/check-blender-contract-audit.mjs M15-02C",
|
||||
"test:blender-local-equivalent-audit": "node ../tools/web/check-blender-contract-audit.mjs M15-02D",
|
||||
"test:blender-server-exact-audit": "node ../tools/web/check-blender-contract-audit.mjs M15-02E",
|
||||
"test:blender-unknown-data-audit": "node ../tools/web/check-blender-contract-audit.mjs M15-02F",
|
||||
"test:blender-next-task-plan": "node ../tools/web/check-blender-next-task-plan.mjs",
|
||||
"test:blender-next-task-spec": "node ../tools/web/check-blender-next-task-spec.mjs",
|
||||
"test:blender-task-graph": "node ../tools/web/check-blender-task-graph.mjs",
|
||||
"test:blender-task-stats": "node ../tools/web/check-blender-task-stats.mjs",
|
||||
"test:blender-zero-gap-gate": "node ../tools/web/check-blender-zero-gap-gate.mjs",
|
||||
"test:generated-gap": "node ../tools/web/check-generated-gap.mjs",
|
||||
"test:firefox-quick": "npm run typecheck && node ../tools/web/check-firefox-quick.mjs",
|
||||
"test:script-main-reader": "node ../tools/web/check-script-main-reader.mjs",
|
||||
"test:scripting-isolation": "node ../tools/web/check-scripting-isolation.mjs",
|
||||
|
||||
@@ -11,6 +11,12 @@ export interface InputModalState {
|
||||
mainCommitCount: number;
|
||||
}
|
||||
|
||||
export interface InputModalEvent {
|
||||
type: "pointerdown" | "pointerup" | "pointercancel";
|
||||
pointerType: "touch" | "pen";
|
||||
pointerId: number;
|
||||
}
|
||||
|
||||
export function createInputModalState(): InputModalState {
|
||||
return { schemaVersion: 1, kind: "NONE", activePointerIds: [], cancelled: false, navigationRevision: 0, mainCommitCount: 0 };
|
||||
}
|
||||
@@ -39,3 +45,15 @@ export function commitPenStroke(state: InputModalState, pointerId: number): Inpu
|
||||
if (state.kind !== "PEN_STROKE" || !state.activePointerIds.includes(pointerId) || state.cancelled) return state;
|
||||
return { ...state, kind: "NONE", activePointerIds: [], mainCommitCount: state.mainCommitCount + 1 };
|
||||
}
|
||||
|
||||
export function reduceInputModal(state: InputModalState, event: InputModalEvent): InputModalState {
|
||||
if (!Number.isSafeInteger(event.pointerId) || event.pointerId < 0) throw new Error("POINTER_ID_INVALID");
|
||||
if (event.pointerType === "touch") {
|
||||
if (event.type === "pointerdown") return beginTouch(state, event.pointerId);
|
||||
if (event.type === "pointerup") return endTouch(state, event.pointerId);
|
||||
return cancelInputModal(state);
|
||||
}
|
||||
if (event.type === "pointerdown") return beginPenStroke(state, event.pointerId);
|
||||
if (event.type === "pointerup") return commitPenStroke(state, event.pointerId);
|
||||
return cancelInputModal(state);
|
||||
}
|
||||
|
||||
@@ -377,6 +377,68 @@ export interface VFontResourceIR {
|
||||
sha256?: string;
|
||||
}
|
||||
|
||||
export interface SoundResourceIR {
|
||||
id: string;
|
||||
name: string;
|
||||
sourcePath: string;
|
||||
packed: boolean;
|
||||
packedByteLength?: number;
|
||||
volume: number;
|
||||
pitch: number;
|
||||
audioChannels: number;
|
||||
sampleRate: number;
|
||||
}
|
||||
|
||||
export interface SpeakerResourceIR {
|
||||
id: string;
|
||||
name: string;
|
||||
soundId?: string;
|
||||
volumeMax: number;
|
||||
volumeMin: number;
|
||||
distanceMax: number;
|
||||
distanceReference: number;
|
||||
attenuation: number;
|
||||
coneAngleOuter: number;
|
||||
coneAngleInner: number;
|
||||
coneVolumeOuter: number;
|
||||
volume: number;
|
||||
pitch: number;
|
||||
}
|
||||
|
||||
export interface TextResourceIR {
|
||||
id: string;
|
||||
name: string;
|
||||
source: string;
|
||||
sourceSha256: string;
|
||||
byteLength: number;
|
||||
lineCount: number;
|
||||
internal: boolean;
|
||||
sourcePath?: string;
|
||||
isDirty: boolean;
|
||||
useModule: boolean;
|
||||
}
|
||||
|
||||
export interface TextureResourceIR {
|
||||
id: string;
|
||||
name: string;
|
||||
type: number;
|
||||
noiseScale: number;
|
||||
noiseDepth: number;
|
||||
intensity: number;
|
||||
contrast: number;
|
||||
saturation: number;
|
||||
}
|
||||
|
||||
export interface WindowManagerResourceIR {
|
||||
id: string;
|
||||
name: string;
|
||||
presetName: string;
|
||||
windowCount: number;
|
||||
interfaceLocked: boolean;
|
||||
}
|
||||
|
||||
export interface WorkspaceResourceIR { id: string; name: string; screenCount: number }
|
||||
|
||||
export interface NonMeshVolumePropertiesIR {
|
||||
displayDensity: number;
|
||||
interpolation: "NEAREST" | "LINEAR";
|
||||
@@ -488,6 +550,9 @@ export interface SceneSnapshotIR {
|
||||
lights: LightIR[];
|
||||
worlds: WorldIR[];
|
||||
images: ImageIR[];
|
||||
brushes?: BrushIR[];
|
||||
lineStyles?: FreestyleLineStyleIR[];
|
||||
lattices?: LatticeIR[];
|
||||
nonMeshData?: NonMeshDataIR[];
|
||||
vfonts?: VFontResourceIR[];
|
||||
greasePencils?: GreasePencilDataIR[];
|
||||
@@ -499,6 +564,13 @@ export interface SceneSnapshotIR {
|
||||
scriptSources?: ScriptSourceInventoryIR;
|
||||
scriptSourceStatus?: "AVAILABLE" | "BLOCKED";
|
||||
physicsSimulation?: PhysicsSimulationManifestIR;
|
||||
particleSettings?: ParticleSettingsIR[];
|
||||
sounds?: SoundResourceIR[];
|
||||
speakers?: SpeakerResourceIR[];
|
||||
texts?: TextResourceIR[];
|
||||
textures?: TextureResourceIR[];
|
||||
windowManager?: WindowManagerResourceIR;
|
||||
workspaces?: WorkspaceResourceIR[];
|
||||
geometryNodeGraphs?: GeometryNodeGraphIR[];
|
||||
libraries?: Array<{
|
||||
id: string;
|
||||
@@ -520,6 +592,66 @@ export interface SceneSnapshotIR {
|
||||
frame: { current: number; start: number; end: number };
|
||||
}
|
||||
|
||||
export interface ParticleSettingsIR {
|
||||
id: string;
|
||||
name: string;
|
||||
type: number;
|
||||
from: number;
|
||||
distribution: number;
|
||||
physicsType: number;
|
||||
totalParticles: number;
|
||||
start: number;
|
||||
end: number;
|
||||
lifetime: number;
|
||||
size: number;
|
||||
drawSize: number;
|
||||
}
|
||||
|
||||
export interface BrushIR {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
alpha: number;
|
||||
hardness: number;
|
||||
spacing: number;
|
||||
jitter: number;
|
||||
sculptTool: number;
|
||||
}
|
||||
|
||||
export interface FreestyleLineStyleIR {
|
||||
id: string;
|
||||
name: string;
|
||||
color: [number, number, number];
|
||||
alpha: number;
|
||||
thickness: number;
|
||||
chaining: number;
|
||||
caps: number;
|
||||
dashed: boolean;
|
||||
modifierCounts: { color: number; alpha: number; thickness: number; geometry: number };
|
||||
}
|
||||
|
||||
export type LatticeInterpolationIR = "KEY_LINEAR" | "KEY_CARDINAL" | "KEY_BSPLINE" | "KEY_CATMULL_ROM";
|
||||
|
||||
export interface LatticePointIR {
|
||||
coDeform: [number, number, number];
|
||||
weight: number;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export interface LatticeIR {
|
||||
id: string;
|
||||
name: string;
|
||||
dimensions: [number, number, number];
|
||||
pointCount: number;
|
||||
interpolation: [LatticeInterpolationIR, LatticeInterpolationIR, LatticeInterpolationIR];
|
||||
useOutside: boolean;
|
||||
activePoint: number;
|
||||
vertexGroup: string;
|
||||
status: "AVAILABLE" | "BLOCKED";
|
||||
points?: LatticePointIR[];
|
||||
errorCode?: "LATTICE_DIMENSIONS_INVALID" | "LATTICE_POINTS_UNREADABLE";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -564,7 +696,218 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
requireArray(value.lights, "lights");
|
||||
requireArray(value.worlds, "worlds");
|
||||
requireArray(value.images, "images");
|
||||
if (value.brushes !== undefined) {
|
||||
const brushes = requireArray(value.brushes, "brushes");
|
||||
for (const [index, brush] of brushes.entries()) {
|
||||
if (!isRecord(brush)) throw new Error(`SceneIR.brushes[${index}] must be an object`);
|
||||
requireString(brush.id, `brushes[${index}].id`);
|
||||
requireString(brush.name, `brushes[${index}].name`);
|
||||
const size = requireNumber(brush.size, `brushes[${index}].size`);
|
||||
const alpha = requireNumber(brush.alpha, `brushes[${index}].alpha`);
|
||||
const hardness = requireNumber(brush.hardness, `brushes[${index}].hardness`);
|
||||
const spacing = requireNumber(brush.spacing, `brushes[${index}].spacing`);
|
||||
const jitter = requireNumber(brush.jitter, `brushes[${index}].jitter`);
|
||||
const sculptTool = requireNumber(brush.sculptTool, `brushes[${index}].sculptTool`);
|
||||
if (!Number.isSafeInteger(size) || size < 0 || size > 100000 || alpha < 0 || alpha > 1 || hardness < 0 || hardness > 1 || !Number.isSafeInteger(spacing) || spacing < 1 || spacing > 1000 || jitter < 0 || jitter > 1 || !Number.isSafeInteger(sculptTool) || sculptTool < 0) {
|
||||
throw new Error(`SceneIR.brushes[${index}] is outside the bounded brush contract`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (value.lineStyles !== undefined) {
|
||||
const lineStyles = requireArray(value.lineStyles, "lineStyles");
|
||||
for (const [index, lineStyle] of lineStyles.entries()) {
|
||||
if (!isRecord(lineStyle)) throw new Error(`SceneIR.lineStyles[${index}] must be an object`);
|
||||
requireString(lineStyle.id, `lineStyles[${index}].id`);
|
||||
requireString(lineStyle.name, `lineStyles[${index}].name`);
|
||||
requireTuple(lineStyle.color, 3, `lineStyles[${index}].color`);
|
||||
const alpha = requireNumber(lineStyle.alpha, `lineStyles[${index}].alpha`);
|
||||
const thickness = requireNumber(lineStyle.thickness, `lineStyles[${index}].thickness`);
|
||||
requireNumber(lineStyle.chaining, `lineStyles[${index}].chaining`);
|
||||
requireNumber(lineStyle.caps, `lineStyles[${index}].caps`);
|
||||
requireBoolean(lineStyle.dashed, `lineStyles[${index}].dashed`);
|
||||
if (!isRecord(lineStyle.modifierCounts)) throw new Error(`SceneIR.lineStyles[${index}].modifierCounts must be an object`);
|
||||
for (const field of ["color", "alpha", "thickness", "geometry"] as const) {
|
||||
const count = requireNumber(lineStyle.modifierCounts[field], `lineStyles[${index}].modifierCounts.${field}`);
|
||||
if (!Number.isSafeInteger(count) || count < 0 || count > 1024) throw new Error(`SceneIR.lineStyles[${index}].modifierCounts.${field} is outside budget`);
|
||||
}
|
||||
if (alpha < 0 || alpha > 1 || thickness < 0 || thickness > 10000) throw new Error(`SceneIR.lineStyles[${index}] is outside bounds`);
|
||||
}
|
||||
}
|
||||
if (value.lattices !== undefined) {
|
||||
const lattices = requireArray(value.lattices, "lattices");
|
||||
if (lattices.length > 4096) throw new Error("SceneIR.lattices exceeds the data-block budget");
|
||||
const ids = new Set<string>();
|
||||
for (const [index, lattice] of lattices.entries()) {
|
||||
if (!isRecord(lattice)) throw new Error(`SceneIR.lattices[${index}] must be an object`);
|
||||
const id = requireString(lattice.id, `lattices[${index}].id`);
|
||||
if (!id.startsWith("lattice:") || ids.has(id)) throw new Error(`SceneIR.lattices[${index}].id is invalid`);
|
||||
ids.add(id);
|
||||
requireString(lattice.name, `lattices[${index}].name`);
|
||||
if (!Array.isArray(lattice.dimensions) || lattice.dimensions.length !== 3 || lattice.dimensions.some((item) => !Number.isSafeInteger(item) || item < 1 || item > 64)) throw new Error(`SceneIR.lattices[${index}].dimensions is invalid`);
|
||||
const pointCount = requireNumber(lattice.pointCount, `lattices[${index}].pointCount`);
|
||||
const expectedCount = (lattice.dimensions as number[]).reduce((product, item) => product * item, 1);
|
||||
if (!Number.isSafeInteger(pointCount) || pointCount !== expectedCount || pointCount > 64 * 64 * 64) throw new Error(`SceneIR.lattices[${index}].pointCount is invalid`);
|
||||
const interpolation = ["KEY_LINEAR", "KEY_CARDINAL", "KEY_BSPLINE", "KEY_CATMULL_ROM"];
|
||||
if (!Array.isArray(lattice.interpolation) || lattice.interpolation.length !== 3 || lattice.interpolation.some((item) => !interpolation.includes(item as string))) throw new Error(`SceneIR.lattices[${index}].interpolation is invalid`);
|
||||
requireBoolean(lattice.useOutside, `lattices[${index}].useOutside`);
|
||||
const activePoint = requireNumber(lattice.activePoint, `lattices[${index}].activePoint`);
|
||||
if (!Number.isSafeInteger(activePoint) || activePoint < -1 || activePoint >= pointCount) throw new Error(`SceneIR.lattices[${index}].activePoint is invalid`);
|
||||
requireString(lattice.vertexGroup, `lattices[${index}].vertexGroup`);
|
||||
if (lattice.status !== "AVAILABLE" && lattice.status !== "BLOCKED") throw new Error(`SceneIR.lattices[${index}].status is invalid`);
|
||||
if (lattice.status === "AVAILABLE") {
|
||||
if (!Array.isArray(lattice.points) || lattice.points.length !== pointCount) throw new Error(`SceneIR.lattices[${index}].points is incomplete`);
|
||||
for (const [pointIndex, point] of lattice.points.entries()) {
|
||||
if (!isRecord(point)) throw new Error(`SceneIR.lattices[${index}].points[${pointIndex}] is invalid`);
|
||||
requireTuple(point.coDeform, 3, `lattices[${index}].points[${pointIndex}].coDeform`);
|
||||
const weight = requireNumber(point.weight, `lattices[${index}].points[${pointIndex}].weight`);
|
||||
if (weight < 0.01 || weight > 100) throw new Error(`SceneIR.lattices[${index}].points[${pointIndex}].weight is invalid`);
|
||||
requireBoolean(point.selected, `lattices[${index}].points[${pointIndex}].selected`);
|
||||
}
|
||||
if (lattice.errorCode !== undefined) throw new Error(`SceneIR.lattices[${index}] available data cannot contain errorCode`);
|
||||
}
|
||||
else if (!lattice.errorCode || lattice.points !== undefined) {
|
||||
throw new Error(`SceneIR.lattices[${index}] blocked data is incomplete`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (value.nonMeshData !== undefined) requireArray(value.nonMeshData, "nonMeshData");
|
||||
if (value.particleSettings !== undefined) {
|
||||
const settings = requireArray(value.particleSettings, "particleSettings");
|
||||
if (settings.length > 4096) throw new Error("SceneIR.particleSettings exceeds the data-block budget");
|
||||
const ids = new Set<string>();
|
||||
for (const [index, setting] of settings.entries()) {
|
||||
if (!isRecord(setting)) throw new Error(`SceneIR.particleSettings[${index}] must be an object`);
|
||||
const id = requireString(setting.id, `particleSettings[${index}].id`);
|
||||
if (!id.startsWith("particle-settings:") || ids.has(id)) throw new Error(`SceneIR.particleSettings[${index}].id is invalid`);
|
||||
ids.add(id);
|
||||
requireString(setting.name, `particleSettings[${index}].name`);
|
||||
for (const field of ["type", "from", "distribution", "physicsType", "totalParticles"] as const) {
|
||||
const number = requireNumber(setting[field], `particleSettings[${index}].${field}`);
|
||||
if (!Number.isSafeInteger(number) || number < 0) throw new Error(`SceneIR.particleSettings[${index}].${field} is invalid`);
|
||||
}
|
||||
for (const field of ["start", "end", "lifetime", "size", "drawSize"] as const) {
|
||||
const number = requireNumber(setting[field], `particleSettings[${index}].${field}`);
|
||||
if (number < 0 || number > 1_000_000) throw new Error(`SceneIR.particleSettings[${index}].${field} is invalid`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (value.sounds !== undefined) {
|
||||
const sounds = requireArray(value.sounds, "sounds");
|
||||
if (sounds.length > 4096) throw new Error("SceneIR.sounds exceeds the data-block budget");
|
||||
const ids = new Set<string>();
|
||||
for (const [index, sound] of sounds.entries()) {
|
||||
if (!isRecord(sound)) throw new Error(`SceneIR.sounds[${index}] must be an object`);
|
||||
const id = requireString(sound.id, `sounds[${index}].id`);
|
||||
if (!id.startsWith("sound:") || ids.has(id)) throw new Error(`SceneIR.sounds[${index}].id is invalid`);
|
||||
ids.add(id);
|
||||
requireString(sound.name, `sounds[${index}].name`);
|
||||
requireString(sound.sourcePath, `sounds[${index}].sourcePath`);
|
||||
requireBoolean(sound.packed, `sounds[${index}].packed`);
|
||||
for (const field of ["volume", "pitch"] as const) {
|
||||
const number = requireNumber(sound[field], `sounds[${index}].${field}`);
|
||||
if (number < 0 || number > 1000) throw new Error(`SceneIR.sounds[${index}].${field} is outside bounds`);
|
||||
}
|
||||
for (const field of ["audioChannels", "sampleRate"] as const) {
|
||||
const number = requireNumber(sound[field], `sounds[${index}].${field}`);
|
||||
if (!Number.isSafeInteger(number) || number < 0 || number > 1_000_000) throw new Error(`SceneIR.sounds[${index}].${field} is invalid`);
|
||||
}
|
||||
if (sound.packedByteLength !== undefined && (!Number.isSafeInteger(sound.packedByteLength) || (sound.packedByteLength as number) < 0)) throw new Error(`SceneIR.sounds[${index}].packedByteLength is invalid`);
|
||||
if (sound.packed && sound.packedByteLength === undefined) throw new Error(`SceneIR.sounds[${index}] packed identity is incomplete`);
|
||||
}
|
||||
}
|
||||
if (value.speakers !== undefined) {
|
||||
const speakers = requireArray(value.speakers, "speakers");
|
||||
if (speakers.length > 4096) throw new Error("SceneIR.speakers exceeds the data-block budget");
|
||||
const ids = new Set<string>();
|
||||
for (const [index, speaker] of speakers.entries()) {
|
||||
if (!isRecord(speaker)) throw new Error(`SceneIR.speakers[${index}] must be an object`);
|
||||
const id = requireString(speaker.id, `speakers[${index}].id`);
|
||||
if (!id.startsWith("speaker:") || ids.has(id)) throw new Error(`SceneIR.speakers[${index}].id is invalid`);
|
||||
ids.add(id);
|
||||
requireString(speaker.name, `speakers[${index}].name`);
|
||||
if (speaker.soundId !== undefined && (!Array.isArray(value.sounds) || typeof speaker.soundId !== "string" || !speaker.soundId.startsWith("sound:"))) {
|
||||
throw new Error(`SceneIR.speakers[${index}].soundId is invalid`);
|
||||
}
|
||||
for (const field of ["volumeMax", "volumeMin", "distanceMax", "distanceReference", "attenuation"] as const) {
|
||||
const number = requireNumber(speaker[field], `speakers[${index}].${field}`);
|
||||
if (number < 0 || number > 1_000_000_000) throw new Error(`SceneIR.speakers[${index}].${field} is outside bounds`);
|
||||
}
|
||||
for (const field of ["coneAngleOuter", "coneAngleInner"] as const) {
|
||||
const number = requireNumber(speaker[field], `speakers[${index}].${field}`);
|
||||
if (number < 0 || number > 360) throw new Error(`SceneIR.speakers[${index}].${field} is outside bounds`);
|
||||
}
|
||||
const coneVolumeOuter = requireNumber(speaker.coneVolumeOuter, `speakers[${index}].coneVolumeOuter`);
|
||||
if (coneVolumeOuter < 0 || coneVolumeOuter > 1) throw new Error(`SceneIR.speakers[${index}].coneVolumeOuter is outside bounds`);
|
||||
for (const field of ["volume", "pitch"] as const) {
|
||||
const number = requireNumber(speaker[field], `speakers[${index}].${field}`);
|
||||
if (number < 0 || number > 1000) throw new Error(`SceneIR.speakers[${index}].${field} is outside bounds`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (value.texts !== undefined) {
|
||||
const texts = requireArray(value.texts, "texts");
|
||||
if (texts.length > 4096) throw new Error("SceneIR.texts exceeds the data-block budget");
|
||||
const ids = new Set<string>();
|
||||
for (const [index, text] of texts.entries()) {
|
||||
if (!isRecord(text)) throw new Error(`SceneIR.texts[${index}] must be an object`);
|
||||
const id = requireString(text.id, `texts[${index}].id`);
|
||||
if (!id.startsWith("text:") || ids.has(id)) throw new Error(`SceneIR.texts[${index}].id is invalid`);
|
||||
ids.add(id);
|
||||
requireString(text.name, `texts[${index}].name`);
|
||||
const source = requireString(text.source, `texts[${index}].source`);
|
||||
const sourceSha256 = requireString(text.sourceSha256, `texts[${index}].sourceSha256`);
|
||||
if (!/^[a-f0-9]{64}$/.test(sourceSha256)) throw new Error(`SceneIR.texts[${index}].sourceSha256 is invalid`);
|
||||
const byteLength = requireNumber(text.byteLength, `texts[${index}].byteLength`);
|
||||
const lineCount = requireNumber(text.lineCount, `texts[${index}].lineCount`);
|
||||
if (!Number.isSafeInteger(byteLength) || byteLength !== new TextEncoder().encode(source).byteLength || byteLength > 1_048_576) throw new Error(`SceneIR.texts[${index}].byteLength is invalid`);
|
||||
if (!Number.isSafeInteger(lineCount) || lineCount < 1 || lineCount > 65_536) throw new Error(`SceneIR.texts[${index}].lineCount is invalid`);
|
||||
requireBoolean(text.internal, `texts[${index}].internal`);
|
||||
if (text.sourcePath !== undefined) requireString(text.sourcePath, `texts[${index}].sourcePath`);
|
||||
requireBoolean(text.isDirty, `texts[${index}].isDirty`);
|
||||
requireBoolean(text.useModule, `texts[${index}].useModule`);
|
||||
}
|
||||
}
|
||||
if (value.textures !== undefined) {
|
||||
const textures = requireArray(value.textures, "textures");
|
||||
if (textures.length > 4096) throw new Error("SceneIR.textures exceeds the data-block budget");
|
||||
const ids = new Set<string>();
|
||||
for (const [index, texture] of textures.entries()) {
|
||||
if (!isRecord(texture)) throw new Error(`SceneIR.textures[${index}] must be an object`);
|
||||
const id = requireString(texture.id, `textures[${index}].id`);
|
||||
if (!id.startsWith("texture:") || ids.has(id)) throw new Error(`SceneIR.textures[${index}].id is invalid`);
|
||||
ids.add(id);
|
||||
requireString(texture.name, `textures[${index}].name`);
|
||||
const type = requireNumber(texture.type, `textures[${index}].type`);
|
||||
const noiseDepth = requireNumber(texture.noiseDepth, `textures[${index}].noiseDepth`);
|
||||
if (!Number.isSafeInteger(type) || type < 0 || !Number.isSafeInteger(noiseDepth) || noiseDepth < 0 || noiseDepth > 30) throw new Error(`SceneIR.textures[${index}] integer fields are invalid`);
|
||||
for (const field of ["noiseScale", "intensity", "contrast", "saturation"] as const) {
|
||||
const number = requireNumber(texture[field], `textures[${index}].${field}`);
|
||||
if (number < 0 || number > 1000) throw new Error(`SceneIR.textures[${index}].${field} is outside bounds`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (value.windowManager !== undefined) {
|
||||
if (!isRecord(value.windowManager)) throw new Error("SceneIR.windowManager must be an object");
|
||||
const manager = value.windowManager;
|
||||
const id = requireString(manager.id, "windowManager.id");
|
||||
if (id !== "window-manager:WinMan") throw new Error("SceneIR.windowManager.id is invalid");
|
||||
requireString(manager.name, "windowManager.name");
|
||||
requireString(manager.presetName, "windowManager.presetName");
|
||||
const windowCount = requireNumber(manager.windowCount, "windowManager.windowCount");
|
||||
if (!Number.isSafeInteger(windowCount) || windowCount < 0 || windowCount > 1024) throw new Error("SceneIR.windowManager.windowCount is invalid");
|
||||
requireBoolean(manager.interfaceLocked, "windowManager.interfaceLocked");
|
||||
}
|
||||
if (value.workspaces !== undefined) {
|
||||
const workspaces = requireArray(value.workspaces, "workspaces");
|
||||
const ids = new Set<string>();
|
||||
for (const [index, workspace] of workspaces.entries()) {
|
||||
if (!isRecord(workspace)) throw new Error(`SceneIR.workspaces[${index}] must be an object`);
|
||||
const id = requireString(workspace.id, `workspaces[${index}].id`);
|
||||
if (!id.startsWith("workspace:") || ids.has(id)) throw new Error(`SceneIR.workspaces[${index}].id is invalid`); ids.add(id);
|
||||
requireString(workspace.name, `workspaces[${index}].name`); const count = requireNumber(workspace.screenCount, `workspaces[${index}].screenCount`);
|
||||
if (!Number.isSafeInteger(count) || count < 0 || count > 64) throw new Error(`SceneIR.workspaces[${index}].screenCount is invalid`);
|
||||
}
|
||||
}
|
||||
if (value.greasePencils !== undefined) requireArray(value.greasePencils, "greasePencils");
|
||||
if (value.libraries !== undefined) requireArray(value.libraries, "libraries");
|
||||
requireArray(value.animations, "animations");
|
||||
|
||||
47
web/tests/unit/context-governance.test.mjs
Normal file
47
web/tests/unit/context-governance.test.mjs
Normal file
@@ -0,0 +1,47 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildTaskContext } from "../../../tools/web/task-context-lib.mjs";
|
||||
import { validateContextBundle } from "../../../tools/web/context-governance.mjs";
|
||||
|
||||
test("current context satisfies document, task-card, and pointer budgets", () => {
|
||||
const bundle = buildTaskContext();
|
||||
const result = validateContextBundle(bundle);
|
||||
assert.deepEqual(result.violations, []);
|
||||
assert.ok(result.report.totalTokens <= 3500);
|
||||
});
|
||||
|
||||
test("task cards cannot smuggle full plans or history into the default context", () => {
|
||||
const bundle = buildTaskContext();
|
||||
const taskSource = `${bundle.sources.taskSource}\nnext-task-plan.json\n`;
|
||||
const result = validateContextBundle({
|
||||
...bundle,
|
||||
sources: { ...bundle.sources, taskSource },
|
||||
});
|
||||
assert.ok(result.violations.some(({ code }) => code === "FORBIDDEN_CONTEXT_REFERENCE"));
|
||||
});
|
||||
|
||||
test("governance rejects forged selection totals and unaudited exclusions", () => {
|
||||
const bundle = buildTaskContext();
|
||||
const forged = {
|
||||
...bundle,
|
||||
context: {
|
||||
...bundle.context,
|
||||
inputPaths: ["tools/web/generated/M16-GAP-00022.py"],
|
||||
inputSelection: {
|
||||
...bundle.context.inputSelection,
|
||||
selected: [{ path: "tools/web/generated/M16-GAP-00022.py", bytes: 9000, tokens: 1 }],
|
||||
excluded: [
|
||||
{ path: "tests/files/web/generated/M16-GAP-00022-datablock-Sound.blend", reason: "FORGED_REASON" },
|
||||
{ path: "tests/golden/M16-GAP-00022/" },
|
||||
],
|
||||
totals: { files: 1, bytes: 9000, tokens: 2250 },
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = validateContextBundle(forged);
|
||||
assert.ok(result.violations.some(({ code }) => code === "EVIDENCE_BYTES_OVER_BUDGET"));
|
||||
assert.ok(result.violations.some(({ code }) => code === "CONTEXT_REMAINING_BYTES_OVER_BUDGET"));
|
||||
assert.ok(result.violations.some(({ code }) => code === "INPUT_EXCLUSION_REASON_MISSING"));
|
||||
assert.ok(result.violations.some(({ code }) => code === "INPUT_EXCLUSION_REASON_UNKNOWN"));
|
||||
assert.ok(result.violations.some(({ code }) => code === "INPUT_SELECTION_ENTRY_INVALID"));
|
||||
});
|
||||
@@ -25,3 +25,13 @@ test("M14-04F commits pen stroke once and ignores late up/cancel", () => {
|
||||
state = modal.commitPenStroke(state, 9);
|
||||
assert.deepEqual([state.kind, state.mainCommitCount, state.activePointerIds], ["NONE", 1, []]);
|
||||
});
|
||||
|
||||
test("M14-04F reduces production pointer events and rejects invalid IDs", () => {
|
||||
let state = modal.createInputModalState();
|
||||
state = modal.reduceInputModal(state, { type: "pointerdown", pointerType: "touch", pointerId: 2 });
|
||||
state = modal.reduceInputModal(state, { type: "pointerdown", pointerType: "touch", pointerId: 4 });
|
||||
assert.equal(state.navigationRevision, 1);
|
||||
state = modal.reduceInputModal(state, { type: "pointercancel", pointerType: "touch", pointerId: 2 });
|
||||
assert.deepEqual([state.kind, state.activePointerIds, state.cancelled], ["NONE", [], true]);
|
||||
assert.throws(() => modal.reduceInputModal(state, { type: "pointerdown", pointerType: "pen", pointerId: -1 }), /POINTER_ID_INVALID/);
|
||||
});
|
||||
|
||||
72
web/tests/unit/task-context.test.mjs
Normal file
72
web/tests/unit/task-context.test.mjs
Normal file
@@ -0,0 +1,72 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildTaskContext, contextSizeReport, CONTEXT_LIMITS, readIndexedTask, selectInputPaths } from "../../../tools/web/task-context-lib.mjs";
|
||||
|
||||
test("current task context is bounded and follows the parent pointer", () => {
|
||||
const bundle = buildTaskContext();
|
||||
const report = contextSizeReport(bundle);
|
||||
assert.match(bundle.context.task, /^M\d+-GAP-\d{5}$/);
|
||||
assert.match(bundle.context.parentTask, /^M\d+-GAP-\d{5}$/);
|
||||
assert.equal(bundle.context.parent.manifest.nextTask, bundle.context.task);
|
||||
assert.ok(bundle.context.commands.length >= 1);
|
||||
assert.equal(bundle.context.sourceDocuments.taskCard, `docs/tasks/${bundle.context.task}.md`);
|
||||
assert.equal(bundle.context.sourceDocuments.taskIndex, "tests/golden/M15-03A/task-index.json");
|
||||
assert.ok(!JSON.stringify(bundle.context).includes("next-task-plan.json"));
|
||||
assert.equal(bundle.context.scope.ownerFamily, readIndexedTask(bundle.context.task).ownerFamily);
|
||||
assert.equal(report.withinBudget, true);
|
||||
assert.equal(report.totalTokens, report.sourceTokens + report.evidenceTokens);
|
||||
assert.equal(report.evidenceTokens, bundle.context.inputSelection.totals.tokens);
|
||||
assert.ok(report.totalTokens < CONTEXT_LIMITS.contextTokens);
|
||||
});
|
||||
|
||||
test("the indexed task record remains available beside the compact card", () => {
|
||||
const bundle = buildTaskContext("M16-GAP-00011");
|
||||
assert.equal(bundle.context.sourceDocuments.taskCard, "docs/tasks/M16-GAP-00011.md");
|
||||
assert.equal(bundle.context.parent.manifest.status, "done");
|
||||
assert.equal(bundle.context.nextTask, "M16-GAP-00012");
|
||||
});
|
||||
|
||||
test("input selection excludes evidence before it can enter the read list", () => {
|
||||
const fixture = "tests/files/web/generated/M16-GAP-00022-datablock-Sound.blend";
|
||||
const generator = "tools/web/generated/M16-GAP-00022.py";
|
||||
const result = selectInputPaths([fixture, generator], {
|
||||
sourceBytes: 100,
|
||||
sourceTokens: 25,
|
||||
limits: { ...CONTEXT_LIMITS, evidenceBytes: 1024 },
|
||||
});
|
||||
assert.deepEqual(result.inputPaths, [generator]);
|
||||
assert.equal(result.inputSelection.excluded[0].reason, "EVIDENCE_BYTE_BUDGET");
|
||||
assert.equal(result.inputSelection.totals.bytes, result.inputSelection.selected[0].bytes);
|
||||
});
|
||||
|
||||
test("input selection audits remaining context and file-count exclusions", () => {
|
||||
const paths = [
|
||||
"tools/web/generated/M16-GAP-00022.py",
|
||||
"tools/web/generated/M16-GAP-00021.py",
|
||||
];
|
||||
const remaining = selectInputPaths(paths, {
|
||||
sourceBytes: 0,
|
||||
sourceTokens: CONTEXT_LIMITS.contextTokens - 1,
|
||||
limits: { ...CONTEXT_LIMITS, evidenceBytes: 8192 },
|
||||
});
|
||||
assert.deepEqual(remaining.inputPaths, []);
|
||||
assert.equal(remaining.inputSelection.excluded[0].reason, "CONTEXT_REMAINING_SPACE");
|
||||
|
||||
const count = selectInputPaths(paths, {
|
||||
limits: { ...CONTEXT_LIMITS, evidenceFiles: 1, evidenceBytes: 8192 },
|
||||
});
|
||||
assert.equal(count.inputPaths.length, 1);
|
||||
assert.equal(count.inputSelection.excluded[0].reason, "EVIDENCE_FILE_COUNT");
|
||||
});
|
||||
|
||||
test("generated task outputs are excluded before their size can affect selection", () => {
|
||||
const result = selectInputPaths(["tests/golden/M16-GAP-00022/"], {
|
||||
generatedPaths: ["tests/golden/M16-GAP-00022/"],
|
||||
});
|
||||
assert.deepEqual(result.inputPaths, []);
|
||||
assert.deepEqual(result.inputSelection.excluded, [{
|
||||
path: "tests/golden/M16-GAP-00022/",
|
||||
bytes: null,
|
||||
reason: "GENERATED_EVIDENCE_OUTPUT",
|
||||
}]);
|
||||
});
|
||||
Reference in New Issue
Block a user