完善资源画布无限坐标与分页交互

调整资源分页画布 viewport 与复位按钮交互

允许资源元素使用 -1,000,000 到 1,000,000 的持久化坐标

同步 TypeScript、Rust 合同、布局校验与负坐标回归测试

更新资源画布 PRD 与技术方案

保留本地 .env 未提交
This commit is contained in:
2026-08-23 16:33:03 +08:00
parent 52a58f7514
commit f3092c8bbc
13 changed files with 1681 additions and 1016 deletions
@@ -4378,6 +4378,17 @@ fn recover_asset_canvas_transaction_locked(
event,
));
}
if find_superseding_asset_canvas_commit(
root,
&journal,
&current_manifest,
&current_manifest_sha,
&current_revision,
&current_revision_sha,
)? {
return mark_asset_canvas_superseded_locked(root, journal, ledger)
.map(|outcome| (outcome, None));
}
return mark_asset_canvas_reconciliation_locked(root, journal, ledger)
.map(|outcome| (outcome, None));
}
@@ -1060,6 +1060,65 @@ fn recovery_marks_interrupted_refine_commit_superseded_by_verified_later_commit(
commit_asset_canvas_at(fixture.root(), &third_input).expect("commit after superseded recovery");
}
#[test]
fn recovery_marks_completed_refine_commit_superseded_by_verified_later_commit() {
let (fixture, first_commit_id, second_commit_id) =
initialize_refine_fixture_with_later_commit();
// initialize_refine_fixture_with_later_commit only forces the first
// transaction into the unresolved state. Restore both completed ledgers so
// this test models two successful commits followed by a restart.
for commit_id in [&first_commit_id, &second_commit_id] {
let mut journal = read_asset_canvas_journal(fixture.root(), commit_id)
.expect("read completed transaction journal")
.expect("completed transaction journal");
let mut ledger = read_asset_canvas_ledger(fixture.root(), commit_id)
.expect("read completed transaction ledger")
.expect("completed transaction ledger");
journal.stage = AssetCanvasTransactionStage::Committed;
ledger.status = AssetCanvasLedgerStatus::Committed;
write_asset_canvas_journal(fixture.root(), &journal)
.expect("restore completed journal stage");
write_asset_canvas_ledger(fixture.root(), &ledger)
.expect("restore completed ledger status");
}
let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID)
.expect("recover two completed refine transactions");
let first_outcome = recovered
.result
.outcomes
.iter()
.find(|outcome| outcome.commit_id == first_commit_id)
.expect("first completed outcome");
let second_outcome = recovered
.result
.outcomes
.iter()
.find(|outcome| outcome.commit_id == second_commit_id)
.expect("second completed outcome");
assert_eq!(
first_outcome.status,
RecoverAssetCanvasOutcomeStatus::Superseded
);
assert_eq!(
second_outcome.status,
RecoverAssetCanvasOutcomeStatus::AlreadyCommitted
);
assert!(!recovered.result.outcomes.iter().any(|outcome| {
outcome.status == RecoverAssetCanvasOutcomeStatus::ReconciliationRequired
}));
let repeated = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID)
.expect("repeat recovery after supersede");
assert!(repeated.result.outcomes.iter().any(|outcome| {
outcome.commit_id == first_commit_id
&& outcome.status == RecoverAssetCanvasOutcomeStatus::Superseded
}));
assert!(!repeated.result.outcomes.iter().any(|outcome| {
outcome.status == RecoverAssetCanvasOutcomeStatus::ReconciliationRequired
}));
}
#[test]
fn recovery_does_not_supersede_when_later_transaction_identity_is_broken() {
let (fixture, first_commit_id, second_commit_id) =
@@ -12,7 +12,8 @@ const RESOURCE_LAYOUT_LOCK_PATH: &str = ".agent/workbench/resource-layouts/.layo
const RESOURCE_LAYOUT_MAX_BYTES: usize = 2 * 1024 * 1024;
const RESOURCE_LAYOUT_MAX_POSITIONS: usize = 4096;
const RESOURCE_LAYOUT_MAX_RESOURCE_ID_CHARS: usize = 512;
const RESOURCE_LAYOUT_MAX_COORDINATE: u32 = 1_000_000;
const RESOURCE_LAYOUT_MIN_COORDINATE: i32 = -1_000_000;
const RESOURCE_LAYOUT_MAX_COORDINATE: i32 = 1_000_000;
const RESOURCE_LAYOUT_LOCK_WAIT_ATTEMPTS: usize = 100;
const RESOURCE_LAYOUT_LOCK_WAIT_MILLIS: u64 = 10;
@@ -337,11 +338,13 @@ fn validate_resource_layout_positions(
if !resource_ids.insert(resource_id) {
return Err("资源布局 resourceId 不能重复".to_string());
}
if position.x > RESOURCE_LAYOUT_MAX_COORDINATE
if position.x < RESOURCE_LAYOUT_MIN_COORDINATE
|| position.x > RESOURCE_LAYOUT_MAX_COORDINATE
|| position.y < RESOURCE_LAYOUT_MIN_COORDINATE
|| position.y > RESOURCE_LAYOUT_MAX_COORDINATE
{
return Err(format!(
"资源布局坐标不能超过 {RESOURCE_LAYOUT_MAX_COORDINATE}"
"资源布局坐标必须在 {RESOURCE_LAYOUT_MIN_COORDINATE} 到 {RESOURCE_LAYOUT_MAX_COORDINATE} 之间"
));
}
}
@@ -526,7 +529,7 @@ mod tests {
))
}
fn layout_position(id: &str, x: u32) -> ProjectResourceCanvasPosition {
fn layout_position(id: &str, x: i32) -> ProjectResourceCanvasPosition {
ProjectResourceCanvasPosition {
resource_id: id.to_string(),
section: shared_contracts::game_creation_app::ProjectResourceCanvasSection::Art,
@@ -883,11 +886,27 @@ mod tests {
RESOURCE_LAYOUT_MAX_COORDINATE
);
let coordinate_error = update_project_resource_canvas_layout_at(
let lower_boundary = update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Type,
"layout-invalid",
boundary.layout.revision,
vec![layout_position(
"asset-coordinate-lower-boundary",
RESOURCE_LAYOUT_MIN_COORDINATE,
)],
)
.expect("lower boundary coordinate must remain valid");
assert_eq!(
lower_boundary.layout.positions[0].x,
RESOURCE_LAYOUT_MIN_COORDINATE
);
let coordinate_error = update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Type,
"layout-invalid",
lower_boundary.layout.revision,
vec![layout_position(
"asset-coordinate",
RESOURCE_LAYOUT_MAX_COORDINATE + 1,
@@ -895,6 +914,18 @@ mod tests {
)
.expect_err("oversized coordinate must fail");
assert!(coordinate_error.contains("坐标"));
let lower_coordinate_error = update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Type,
"layout-invalid",
lower_boundary.layout.revision,
vec![layout_position(
"asset-coordinate-below-lower-boundary",
RESOURCE_LAYOUT_MIN_COORDINATE - 1,
)],
)
.expect_err("coordinate below lower boundary must fail");
assert!(lower_coordinate_error.contains("坐标"));
let id_error = update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Type,
+215
View File
@@ -4878,6 +4878,11 @@ iframe.preview-frame {
box-shadow: var(--platform-panel-shadow);
}
.game-workbench-stage > .game-resource-manager {
grid-row: 3;
min-height: 0;
}
.game-workbench-chat {
border-color: var(--platform-line-soft);
box-shadow: var(--platform-nav-active-shadow);
@@ -5005,6 +5010,7 @@ iframe.preview-frame {
.game-resource-manager {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
}
@@ -5081,6 +5087,8 @@ iframe.preview-frame {
.game-resource-canvas {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
padding: 12px;
overflow: auto;
@@ -5101,6 +5109,17 @@ iframe.preview-frame {
cursor: grabbing;
}
.game-resource-canvas--paged {
overflow: hidden;
cursor: grab;
overscroll-behavior: none;
touch-action: none;
}
.game-resource-canvas--paged:active {
cursor: grabbing;
}
.game-resource-canvas-content {
position: relative;
display: grid;
@@ -5116,6 +5135,190 @@ iframe.preview-frame {
min-height: 0;
}
.game-resource-canvas-content--paged {
display: block;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
.game-resource-page-shell {
display: grid;
grid-template-columns: 212px minmax(0, 1fr);
gap: 14px;
width: 100%;
height: 100%;
}
.game-resource-outline {
display: flex;
flex-direction: column;
gap: 6px;
min-height: 0;
padding: 12px 2px;
overflow: auto;
border-right: 1px solid #eaded8;
}
.game-resource-outline button {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto;
align-items: center;
gap: 6px;
min-height: 34px;
padding: 0 9px;
border: 0;
border-radius: 8px;
background: transparent;
color: #725348;
cursor: pointer;
text-align: left;
}
.game-resource-outline button:hover,
.game-resource-outline button:focus-visible,
.game-resource-outline button.is-active {
background: #f6ece6;
color: #a9502a;
outline: 0;
}
.game-resource-outline button strong {
overflow: hidden;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-resource-outline button small {
color: #a98a7c;
font-size: 10px;
font-variant-numeric: tabular-nums;
}
.game-resource-page {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 10px;
height: 100%;
min-width: 0;
min-height: 0;
}
.game-resource-page-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 36px;
padding: 0 4px;
}
.game-resource-page-heading span {
display: inline-flex;
align-items: center;
gap: 8px;
color: #6d4b3f;
font-size: 13px;
}
.game-resource-page-heading strong {
font-size: 15px;
}
.game-resource-page-heading small {
color: #a98a7c;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.game-resource-page-canvas {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
border: 1px solid #ecdfd8;
border-radius: 12px;
background:
radial-gradient(#eaded8 0.8px, transparent 0.8px) 0 0 / 18px 18px,
#fffdfa;
}
.game-resource-canvas-reset {
position: absolute;
top: 12px;
right: 12px;
z-index: 3;
display: grid;
width: 30px;
height: 30px;
padding: 0;
border: 1px solid rgb(227 207 197 / 88%);
border-radius: 8px;
background: rgb(255 253 250 / 88%);
color: #8c6252;
cursor: pointer;
place-items: center;
box-shadow: 0 4px 12px rgb(112 70 52 / 10%);
backdrop-filter: blur(4px);
}
.game-resource-canvas-reset:hover,
.game-resource-canvas-reset:focus-visible {
border-color: #ce7650;
color: #bd5f37;
outline: 2px solid rgb(206 118 80 / 22%);
outline-offset: 1px;
}
.game-resource-page-world {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
}
.game-resource-page-empty {
position: absolute;
inset: 0;
display: grid;
padding: 24px;
color: #b0958a;
font-size: 12px;
place-items: center;
pointer-events: none;
}
.game-resource-next-page {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 38px;
padding: 0 14px;
border: 1px solid #e8d6cc;
border-radius: 10px;
background: #fff8f4;
color: #725348;
cursor: pointer;
}
.game-resource-next-page:hover,
.game-resource-next-page:focus-visible {
border-color: #d6946f;
color: #a9502a;
outline: 0;
}
.game-resource-next-page span {
color: #a98a7c;
font-size: 10px;
}
.game-resource-next-page strong {
font-size: 12px;
}
.game-resource-section-stack {
display: contents;
}
@@ -5374,6 +5577,18 @@ iframe.preview-frame {
transform: translate3d(var(--resource-x, 0), var(--resource-y, 0), 0);
}
.game-resource-card {
cursor: grab;
touch-action: none;
}
.game-resource-card.is-dragging {
cursor: grabbing;
box-shadow:
0 14px 32px rgb(195 105 62 / 24%),
0 0 0 2px rgb(216 115 66 / 24%);
}
.game-resource-card:hover,
.game-resource-card:focus-within,
.game-resource-card.is-selected {
File diff suppressed because it is too large Load Diff
@@ -6,6 +6,7 @@ import {
import {
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE,
GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
type ProjectResourceCanvasLayout,
type ProjectResourceCanvasLayoutMode,
@@ -29,15 +30,14 @@ export const RESOURCE_CANVAS_TYPE_COLUMNS = 3;
export const RESOURCE_CANVAS_SECTION_MIN_WIDTH = 620;
export const RESOURCE_CANVAS_SECTION_MIN_HEIGHT = 108;
export const RESOURCE_CANVAS_CLUSTER_GAP = 48;
export const RESOURCE_CANVAS_BOUNDARY_PADDING = 96;
/**
* Initial fit inset only. The resource canvas background itself is infinite;
* this value must not be used to clamp a later pan or zoom.
*/
export const RESOURCE_CANVAS_FIT_PADDING = 96;
const sectionOrder: ProjectResourceCanvasSection[] = [
'code',
'document',
'version',
'art',
'audio',
];
export const RESOURCE_CANVAS_SECTION_ORDER: readonly ProjectResourceCanvasSection[] =
['document', 'art', 'audio', 'code', 'version'];
export type ResourceCanvasCardSize = {
width: number;
@@ -65,76 +65,24 @@ function clampResourceCanvasNumber(
}
/**
* Keeps the dependency canvas inside the invisible boundary formed by every
* resource plus a fixed screen-space safety margin. The minimum zoom is the
* scale at which the whole boundary fits; panning never lets the resource
* union move beyond the safety margin on either axis.
* Normalizes an infinite resource-canvas viewport.
*
* The canvas has no navigational edge: x/y are intentionally left untouched
* so users can pan the background and resources arbitrarily far in any
* direction. Only invalid coordinates and the shared image-canvas scale
* limits are normalized here.
*/
export function constrainResourceCanvasViewport({
bounds,
canvasSize,
padding = RESOURCE_CANVAS_BOUNDARY_PADDING,
viewport,
}: {
bounds: ResourceCanvasNavigationBounds;
canvasSize: { width: number; height: number };
padding?: number;
viewport: CanvasViewport;
}): CanvasViewport {
const canvasWidth = Math.max(
1,
Number.isFinite(canvasSize.width) ? canvasSize.width : 1,
);
const canvasHeight = Math.max(
1,
Number.isFinite(canvasSize.height) ? canvasSize.height : 1,
);
const boundsX = Number.isFinite(bounds.x) ? bounds.x : 0;
const boundsY = Number.isFinite(bounds.y) ? bounds.y : 0;
const boundsWidth = Math.max(
1,
Number.isFinite(bounds.width) ? bounds.width : 1,
);
const boundsHeight = Math.max(
1,
Number.isFinite(bounds.height) ? bounds.height : 1,
);
const normalizedPadding = Math.max(0, Number.isFinite(padding) ? padding : 0);
const horizontalPadding = Math.min(normalizedPadding, (canvasWidth - 1) / 2);
const verticalPadding = Math.min(normalizedPadding, (canvasHeight - 1) / 2);
const availableWidth = Math.max(1, canvasWidth - horizontalPadding * 2);
const availableHeight = Math.max(1, canvasHeight - verticalPadding * 2);
const minimumScale = clampResourceCanvasNumber(
Math.min(1, availableWidth / boundsWidth, availableHeight / boundsHeight),
MIN_SCALE,
MAX_SCALE,
);
const scale = clampResourceCanvasNumber(
Number.isFinite(viewport.scale) ? viewport.scale : minimumScale,
minimumScale,
MAX_SCALE,
);
const minimumX =
canvasWidth - horizontalPadding - (boundsX + boundsWidth) * scale;
const maximumX = horizontalPadding - boundsX * scale;
const minimumY =
canvasHeight - verticalPadding - (boundsY + boundsHeight) * scale;
const maximumY = verticalPadding - boundsY * scale;
const centeredX = canvasWidth / 2 - (boundsX + boundsWidth / 2) * scale;
const centeredY = canvasHeight / 2 - (boundsY + boundsHeight / 2) * scale;
const candidateX = Number.isFinite(viewport.x) ? viewport.x : centeredX;
const candidateY = Number.isFinite(viewport.y) ? viewport.y : centeredY;
export function normalizeInfiniteResourceCanvasViewport(
viewport: CanvasViewport,
): CanvasViewport {
return {
x:
minimumX <= maximumX
? clampResourceCanvasNumber(candidateX, minimumX, maximumX)
: centeredX,
y:
minimumY <= maximumY
? clampResourceCanvasNumber(candidateY, minimumY, maximumY)
: centeredY,
scale,
x: Number.isFinite(viewport.x) ? viewport.x : 0,
y: Number.isFinite(viewport.y) ? viewport.y : 0,
scale: clampResourceCanvasNumber(
Number.isFinite(viewport.scale) ? viewport.scale : 1,
MIN_SCALE,
MAX_SCALE,
),
};
}
@@ -1185,7 +1133,7 @@ export function reconcileResourceCanvasLayout(
resources.map((resource) => [resource.id, resource]),
);
const positionsBySection = new Map(
sectionOrder.map((section) => [
RESOURCE_CANVAS_SECTION_ORDER.map((section) => [
section,
[] as ProjectResourceCanvasPosition[],
]),
@@ -1202,7 +1150,10 @@ export function reconcileResourceCanvasLayout(
preserved.map((position) => position.resourceId),
);
const newResourcesBySection = new Map(
sectionOrder.map((section) => [section, [] as ResourceCanvasItem[]]),
RESOURCE_CANVAS_SECTION_ORDER.map((section) => [
section,
[] as ResourceCanvasItem[],
]),
);
for (const resource of resources) {
if (!preservedIds.has(resource.id)) {
@@ -1210,7 +1161,7 @@ export function reconcileResourceCanvasLayout(
}
}
for (const section of sectionOrder) {
for (const section of RESOURCE_CANVAS_SECTION_ORDER) {
const sectionPositions = positionsBySection.get(section) ?? [];
const sectionResources = resources.filter(
(resource) => resource.category === section,
@@ -1255,7 +1206,7 @@ export function reconcileResourceCanvasLayout(
}
}
const ordered = sectionOrder.flatMap((section) =>
const ordered = RESOURCE_CANVAS_SECTION_ORDER.flatMap((section) =>
(positionsBySection.get(section) ?? []).sort(
(left, right) =>
left.y - right.y ||
@@ -1283,11 +1234,11 @@ export function moveResourceCanvasPosition(
const finiteY = Number.isFinite(y) ? y : 0;
const normalizedX = Math.min(
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
Math.max(0, Math.round(finiteX)),
Math.max(GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE, Math.round(finiteX)),
);
const normalizedY = Math.min(
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
Math.max(0, Math.round(finiteY)),
Math.max(GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE, Math.round(finiteY)),
);
return {
...layout,
@@ -10,6 +10,7 @@ import type {
ProjectResourceCanvasLayoutMode,
ProjectResourceCanvasSection,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { RESOURCE_CANVAS_SECTION_ORDER } from './resourceCanvasLayoutModel';
import {
clampProjectResourceSectionHeight,
clampProjectResourceSectionZoom,
@@ -29,13 +30,7 @@ const RESOURCE_LAYOUT_MODES: readonly ProjectResourceCanvasLayoutMode[] = [
'dependency',
'type',
];
const RESOURCE_SECTIONS: readonly ProjectResourceCanvasSection[] = [
'code',
'document',
'version',
'art',
'audio',
];
const RESOURCE_SECTIONS = RESOURCE_CANVAS_SECTION_ORDER;
export type ProjectResourceSectionHeightState = {
height: number;
File diff suppressed because it is too large Load Diff
@@ -2,14 +2,15 @@ import { describe, expect, it } from 'vitest';
import {
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE,
isSafeProjectResourceCanvasCoordinate,
} from '../../../packages/shared/src/contracts/gameCreationApp';
import {
constrainResourceCanvasViewport,
createEmptyResourceCanvasLayout,
createResourceCanvasCardSizeByResourceId,
DEFAULT_RESOURCE_CANVAS_CARD_SIZE,
moveResourceCanvasPosition,
normalizeInfiniteResourceCanvasViewport,
reconcileResourceCanvasLayout,
RESOURCE_CANVAS_CARD_HEIGHT,
RESOURCE_CANVAS_CARD_WIDTH,
@@ -57,44 +58,42 @@ function positionById(
}
describe('resource canvas layout model', () => {
it('clamps dependency-canvas panning to the resource union and safety margin', () => {
const common = {
bounds: { x: 0, y: 0, width: 1_000, height: 800 },
canvasSize: { width: 800, height: 600 },
};
it('allows infinite background panning while keeping zoom within shared limits', () => {
expect(
constrainResourceCanvasViewport({
...common,
viewport: { x: 2_000, y: 2_000, scale: 1 },
normalizeInfiniteResourceCanvasViewport({
x: 2_000,
y: 2_000,
scale: 1,
}),
).toEqual({ x: 96, y: 96, scale: 1 });
).toEqual({ x: 2_000, y: 2_000, scale: 1 });
expect(
constrainResourceCanvasViewport({
...common,
viewport: { x: -2_000, y: -2_000, scale: 1 },
normalizeInfiniteResourceCanvasViewport({
x: -2_000,
y: -2_000,
scale: 0.001,
}),
).toEqual({ x: -296, y: -296, scale: 1 });
).toEqual({ x: -2_000, y: -2_000, scale: 0.025 });
});
it('stops zooming out once all resources and the hidden margin fit', () => {
expect(
constrainResourceCanvasViewport({
bounds: { x: 0, y: 0, width: 1_000, height: 800 },
canvasSize: { width: 800, height: 600 },
viewport: { x: -800, y: 900, scale: 0.001 },
}),
).toEqual({ x: 145, y: 96, scale: 0.51 });
it('does not pull the viewport back when resource extents change', () => {
const before = normalizeInfiniteResourceCanvasViewport({
x: 12_000,
y: -9_000,
scale: 0.75,
});
const after = normalizeInfiniteResourceCanvasViewport(before);
expect(after).toEqual(before);
});
it('centers a resource boundary smaller than the viewport instead of allowing blank panning', () => {
it('repairs invalid viewport coordinates without adding a canvas boundary', () => {
expect(
constrainResourceCanvasViewport({
bounds: { x: 20, y: 30, width: 200, height: 100 },
canvasSize: { width: 800, height: 600 },
viewport: { x: -5_000, y: 5_000, scale: 1 },
normalizeInfiniteResourceCanvasViewport({
x: Number.NaN,
y: Number.POSITIVE_INFINITY,
scale: Number.NaN,
}),
).toEqual({ x: 280, y: 220, scale: 1 });
).toEqual({ x: 0, y: 0, scale: 1 });
});
it('creates non-overlapping defaults and keeps the two modes independent', () => {
@@ -179,6 +178,32 @@ describe('resource canvas layout model', () => {
).toBe(false);
});
it('preserves negative manual positions within the symmetric canvas range', () => {
const base = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout('project-negative', 'type'),
[resource('a', 'document')],
).layout;
const moved = moveResourceCanvasPosition(
base,
'a',
'document',
-123.4,
-45.6,
);
expect(moved.positions[0]).toMatchObject({ x: -123, y: -46 });
expect(
isSafeProjectResourceCanvasCoordinate(
GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE,
),
).toBe(true);
expect(
isSafeProjectResourceCanvasCoordinate(
GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE - 1,
),
).toBe(false);
});
it('drops a persisted position whose section no longer matches the resource', () => {
const layout = createEmptyResourceCanvasLayout('project-1', 'dependency');
layout.positions = [
@@ -555,6 +580,16 @@ describe('resource canvas layout model', () => {
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE + 1,
),
).toBe(false);
expect(
isSafeProjectResourceCanvasCoordinate(
GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE,
),
).toBe(true);
expect(
isSafeProjectResourceCanvasCoordinate(
GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE - 1,
),
).toBe(false);
expect(deepPositions.map((position) => position.x)).toEqual([
lastLegalColumn,
lastLegalColumn,