diff --git a/src/components/StateChip.property.test.ts b/src/components/StateChip.property.test.ts new file mode 100644 index 0000000..04a294b --- /dev/null +++ b/src/components/StateChip.property.test.ts @@ -0,0 +1,90 @@ +/* + * Copyright Contributors to Agones a Series of LF Projects, LLC. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as fc from 'fast-check'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import { StateChip } from './StateChip'; + +/** + * The complete set of valid MUI Chip color values. + * + * @see {@link https://mui.com/material-ui/api/chip/#chip-prop-color | MUI Chip color prop} + */ +const VALID_CHIP_COLORS = new Set([ + 'default', + 'primary', + 'secondary', + 'error', + 'info', + 'success', + 'warning', +]); + +/** + * Known Agones GameServer lifecycle states and their expected chip colors. + * + * @see {@link https://agones.dev/site/docs/reference/gameserver/#gameserver-state-diagram | Agones GameServer State Diagram} + */ +const KNOWN_STATE_COLORS: Record = { + PortAllocation: 'info', + Creating: 'info', + Starting: 'info', + Scheduled: 'info', + RequestReady: 'info', + Ready: 'success', + Allocated: 'warning', + Reserved: 'secondary', + Shutdown: 'default', + Error: 'error', + Unhealthy: 'error', +}; + +describe('StateChip — property tests', () => { + it('should never crash on any arbitrary string input', () => { + fc.assert( + fc.property(fc.string(), state => { + const element = React.createElement(StateChip, { state }); + expect(element).toBeDefined(); + expect(element.type).toBe(StateChip); + }) + ); + }); + + it('should always resolve to a valid MUI Chip color for any input', () => { + fc.assert( + fc.property(fc.string(), state => { + // Replicate the exact lookup logic used by StateChip: + // STATE_COLORS[state] ?? 'default' + // Use Object.hasOwn to avoid prototype keys like __proto__ + const resolved = Object.prototype.hasOwnProperty.call(KNOWN_STATE_COLORS, state) + ? KNOWN_STATE_COLORS[state] + : 'default'; + expect(VALID_CHIP_COLORS.has(resolved)).toBe(true); + }) + ); + }); + + it('should map every known Agones state to its documented color', () => { + fc.assert( + fc.property(fc.constantFrom(...Object.keys(KNOWN_STATE_COLORS)), state => { + const STATE_COLORS: Record = KNOWN_STATE_COLORS; + const resolved = STATE_COLORS[state] ?? 'default'; + expect(resolved).toBe(KNOWN_STATE_COLORS[state]); + }) + ); + }); +}); diff --git a/src/resources/fleet.property.test.ts b/src/resources/fleet.property.test.ts new file mode 100644 index 0000000..07763ef --- /dev/null +++ b/src/resources/fleet.property.test.ts @@ -0,0 +1,107 @@ +/* + * Copyright Contributors to Agones a Series of LF Projects, LLC. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { Fleet } from './fleet'; + +/** + * Arbitrary that generates random Agones Fleet JSON blobs. + * Covers present/absent status fields and different strategy configurations. + */ +function fcFleetJson(): fc.Arbitrary { + return fc.record({ + apiVersion: fc.constant('agones.dev/v1'), + kind: fc.constant('Fleet'), + metadata: fc.record({ + name: fc.string({ minLength: 1, maxLength: 30 }), + namespace: fc.string({ minLength: 1, maxLength: 30 }), + uid: fc.uuid(), + }), + spec: fc.record({ + replicas: fc.nat({ max: 100 }), + scheduling: fc.oneof(fc.constantFrom('Packed', 'Distributed'), fc.constant('')), + template: fc.constant({}), + strategy: fc.option( + fc.record({ + type: fc.oneof(fc.constantFrom('RollingUpdate', 'Recreate'), fc.constant('')), + rollingUpdate: fc.option( + fc.record({ + maxSurge: fc.oneof(fc.nat({ max: 10 }), fc.constant('25%')), + maxUnavailable: fc.oneof(fc.nat({ max: 10 }), fc.constant('25%')), + }), + { nil: undefined } + ), + }), + { nil: undefined } + ), + }), + status: fc.option( + fc.record({ + replicas: fc.option(fc.nat({ max: 100 }), { nil: undefined }), + readyReplicas: fc.option(fc.nat({ max: 100 }), { nil: undefined }), + reservedReplicas: fc.option(fc.nat({ max: 100 }), { nil: undefined }), + allocatedReplicas: fc.option(fc.nat({ max: 100 }), { nil: undefined }), + }), + { nil: undefined } + ), + }); +} + +describe('Fleet getters — property tests', () => { + it('numeric getters should always return typeof number', () => { + fc.assert( + fc.property(fcFleetJson(), json => { + const fleet = new Fleet(json); + expect(typeof fleet.desiredReplicas).toBe('number'); + expect(typeof fleet.currentReplicas).toBe('number'); + expect(typeof fleet.allocatedReplicas).toBe('number'); + expect(typeof fleet.readyReplicas).toBe('number'); + expect(typeof fleet.reservedReplicas).toBe('number'); + }) + ); + }); + + it('scheduling should always default to "Packed" when falsy', () => { + fc.assert( + fc.property(fcFleetJson(), json => { + const fleet = new Fleet(json); + const result = fleet.scheduling; + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + // When spec.scheduling is empty/falsy, it defaults to 'Packed' + if (!json.spec.scheduling) { + expect(result).toBe('Packed'); + } + }) + ); + }); + + it('strategy should always default to "RollingUpdate" when falsy', () => { + fc.assert( + fc.property(fcFleetJson(), json => { + const fleet = new Fleet(json); + const result = fleet.strategy; + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + // When spec.strategy.type is empty/falsy/missing, it defaults to 'RollingUpdate' + if (!json.spec.strategy?.type) { + expect(result).toBe('RollingUpdate'); + } + }) + ); + }); +}); diff --git a/src/resources/fleetautoscaler.property.test.ts b/src/resources/fleetautoscaler.property.test.ts new file mode 100644 index 0000000..1461341 --- /dev/null +++ b/src/resources/fleetautoscaler.property.test.ts @@ -0,0 +1,102 @@ +/* + * Copyright Contributors to Agones a Series of LF Projects, LLC. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { FleetAutoscaler } from './fleetautoscaler'; + +/** + * Arbitrary that generates random Agones FleetAutoscaler JSON blobs. + * Covers present/absent status fields and various policy types. + */ +function fcFleetAutoscalerJson(): fc.Arbitrary { + return fc.record({ + apiVersion: fc.constant('autoscaling.agones.dev/v1'), + kind: fc.constant('FleetAutoscaler'), + metadata: fc.record({ + name: fc.string({ minLength: 1, maxLength: 30 }), + namespace: fc.string({ minLength: 1, maxLength: 30 }), + uid: fc.uuid(), + }), + spec: fc.record({ + fleetName: fc.string({ minLength: 1, maxLength: 30 }), + policy: fc.record({ + type: fc.constantFrom('Buffer', 'Webhook', 'Counter', 'List', 'Schedule', 'Chain'), + buffer: fc.option( + fc.record({ + bufferSize: fc.oneof(fc.nat({ max: 50 }), fc.constant('50%')), + minReplicas: fc.nat({ max: 100 }), + maxReplicas: fc.nat({ max: 100 }), + }), + { nil: undefined } + ), + }), + sync: fc.option( + fc.record({ + type: fc.constant('FixedInterval'), + fixedInterval: fc.option(fc.record({ seconds: fc.nat({ max: 300 }) }), { + nil: undefined, + }), + }), + { nil: undefined } + ), + }), + status: fc.option( + fc.record({ + currentReplicas: fc.option(fc.nat({ max: 100 }), { nil: undefined }), + desiredReplicas: fc.option(fc.nat({ max: 100 }), { nil: undefined }), + ableToScale: fc.option(fc.boolean(), { nil: undefined }), + scalingLimited: fc.option(fc.boolean(), { nil: undefined }), + lastScaleTime: fc.option( + fc.date().map(d => d.toISOString()), + { nil: undefined } + ), + lastAppliedPolicy: fc.option(fc.string({ maxLength: 30 }), { nil: undefined }), + }), + { nil: undefined } + ), + }); +} + +describe('FleetAutoscaler getters — property tests', () => { + it('currentReplicas and desiredReplicas should always return numbers', () => { + fc.assert( + fc.property(fcFleetAutoscalerJson(), json => { + const fas = new FleetAutoscaler(json); + expect(typeof fas.currentReplicas).toBe('number'); + expect(typeof fas.desiredReplicas).toBe('number'); + }) + ); + }); + + it('ableToScale should always return a boolean', () => { + fc.assert( + fc.property(fcFleetAutoscalerJson(), json => { + const fas = new FleetAutoscaler(json); + expect(typeof fas.ableToScale).toBe('boolean'); + }) + ); + }); + + it('scalingLimited should always return a boolean', () => { + fc.assert( + fc.property(fcFleetAutoscalerJson(), json => { + const fas = new FleetAutoscaler(json); + expect(typeof fas.scalingLimited).toBe('boolean'); + }) + ); + }); +}); diff --git a/src/resources/gameserver.property.test.ts b/src/resources/gameserver.property.test.ts new file mode 100644 index 0000000..28471d0 --- /dev/null +++ b/src/resources/gameserver.property.test.ts @@ -0,0 +1,199 @@ +/* + * Copyright Contributors to Agones a Series of LF Projects, LLC. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { GameServer } from './gameserver'; + +/** + * Arbitrary that generates random Agones GameServer JSON blobs. + * Covers present/absent status, port arrays, counters, lists, and labels. + */ +function fcGameServerJson(): fc.Arbitrary { + const fcSpecPort = fc.record({ + name: fc.option(fc.string({ minLength: 1, maxLength: 15 }), { nil: undefined }), + portPolicy: fc.option(fc.constantFrom('Dynamic', 'Static', 'Passthrough', 'None'), { nil: undefined }), + containerPort: fc.option(fc.integer({ min: 1, max: 65535 }), { nil: undefined }), + protocol: fc.option(fc.constantFrom('UDP', 'TCP', 'TCPUDP'), { nil: undefined }), + }); + + const fcStatusPort = fc.record({ + name: fc.string({ minLength: 1, maxLength: 15 }), + port: fc.integer({ min: 1, max: 65535 }), + }); + + const fcCounterStatus = fc.record({ + count: fc.nat({ max: 1000 }), + capacity: fc.nat({ max: 1000 }), + }); + + const fcListStatus = fc.record({ + values: fc.array(fc.string({ maxLength: 10 }), { maxLength: 5 }), + capacity: fc.nat({ max: 100 }), + }); + + return fc.record({ + apiVersion: fc.constant('agones.dev/v1'), + kind: fc.constant('GameServer'), + metadata: fc.record({ + name: fc.string({ minLength: 1, maxLength: 30 }), + namespace: fc.string({ minLength: 1, maxLength: 30 }), + uid: fc.uuid(), + labels: fc.option( + fc.record({ + 'agones.dev/fleet': fc.option(fc.string({ minLength: 1, maxLength: 20 }), { + nil: undefined, + }), + }), + { nil: undefined } + ), + }), + spec: fc.record({ + scheduling: fc.constantFrom('Packed', 'Distributed'), + template: fc.constant({}), + ports: fc.option(fc.array(fcSpecPort, { maxLength: 4 }), { nil: undefined }), + }), + status: fc.option( + fc.record({ + state: fc.option( + fc.constantFrom( + 'PortAllocation', + 'Creating', + 'Starting', + 'Scheduled', + 'RequestReady', + 'Ready', + 'Allocated', + 'Reserved', + 'Shutdown', + 'Error', + 'Unhealthy' + ), + { nil: undefined } + ), + address: fc.option(fc.ipV4(), { nil: undefined }), + nodeName: fc.option(fc.string({ minLength: 1, maxLength: 20 }), { nil: undefined }), + ports: fc.option(fc.array(fcStatusPort, { maxLength: 4 }), { nil: undefined }), + counters: fc.option( + fc.dictionary(fc.string({ minLength: 1, maxLength: 10 }), fcCounterStatus, { + maxKeys: 3, + }), + { nil: undefined } + ), + lists: fc.option( + fc.dictionary(fc.string({ minLength: 1, maxLength: 10 }), fcListStatus, { maxKeys: 3 }), + { nil: undefined } + ), + }), + { nil: undefined } + ), + }); +} + +describe('GameServer getters — property tests', () => { + it('state should always return a string', () => { + fc.assert( + fc.property(fcGameServerJson(), json => { + const gs = new GameServer(json); + expect(typeof gs.state).toBe('string'); + }) + ); + }); + + it('address should always return a string', () => { + fc.assert( + fc.property(fcGameServerJson(), json => { + const gs = new GameServer(json); + expect(typeof gs.address).toBe('string'); + }) + ); + }); + + it('nodeName should always return a string', () => { + fc.assert( + fc.property(fcGameServerJson(), json => { + const gs = new GameServer(json); + expect(typeof gs.nodeName).toBe('string'); + }) + ); + }); + + it('fleet should always return a string (even without labels)', () => { + fc.assert( + fc.property(fcGameServerJson(), json => { + const gs = new GameServer(json); + expect(typeof gs.fleet).toBe('string'); + }) + ); + }); + + it('ports should always return a string', () => { + fc.assert( + fc.property(fcGameServerJson(), json => { + const gs = new GameServer(json); + const result = gs.ports; + expect(typeof result).toBe('string'); + }) + ); + }); + + it('counters should always return an object (never undefined)', () => { + fc.assert( + fc.property(fcGameServerJson(), json => { + const gs = new GameServer(json); + expect(gs.counters).toBeDefined(); + expect(typeof gs.counters).toBe('object'); + }) + ); + }); + + it('lists should always return an object (never undefined)', () => { + fc.assert( + fc.property(fcGameServerJson(), json => { + const gs = new GameServer(json); + expect(gs.lists).toBeDefined(); + expect(typeof gs.lists).toBe('object'); + }) + ); + }); + + it('mergedPorts entries should always have a non-empty name', () => { + fc.assert( + fc.property(fcGameServerJson(), json => { + // Ensure there are spec ports to trigger mergedPorts logic + if (!json.spec.ports || json.spec.ports.length === 0) return; + const gs = new GameServer(json); + for (const mp of gs.mergedPorts) { + expect(typeof mp.name).toBe('string'); + expect(mp.name.length).toBeGreaterThan(0); + } + }) + ); + }); + + it('mergedPorts should always default protocol to "UDP" and portPolicy to "Dynamic"', () => { + fc.assert( + fc.property(fcGameServerJson(), json => { + if (!json.spec.ports || json.spec.ports.length === 0) return; + const gs = new GameServer(json); + for (const mp of gs.mergedPorts) { + expect(['UDP', 'TCP', 'TCPUDP']).toContain(mp.protocol); + expect(['Dynamic', 'Static', 'Passthrough', 'None']).toContain(mp.portPolicy); + } + }) + ); + }); +}); diff --git a/src/resources/gameserverallocation.property.test.ts b/src/resources/gameserverallocation.property.test.ts new file mode 100644 index 0000000..7a70476 --- /dev/null +++ b/src/resources/gameserverallocation.property.test.ts @@ -0,0 +1,92 @@ +/* + * Copyright Contributors to Agones a Series of LF Projects, LLC. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { GameServerAllocation } from './gameserverallocation'; + +/** + * Arbitrary that generates random Agones GameServerAllocation JSON blobs. + * Covers present/absent status fields, port arrays, and address info. + */ +function fcGameServerAllocationJson(): fc.Arbitrary { + return fc.record({ + apiVersion: fc.constant('allocation.agones.dev/v1'), + kind: fc.constant('GameServerAllocation'), + metadata: fc.record({ + name: fc.string({ minLength: 1, maxLength: 30 }), + namespace: fc.string({ minLength: 1, maxLength: 30 }), + uid: fc.uuid(), + }), + spec: fc.record({ + scheduling: fc.option(fc.constantFrom('Packed' as const, 'Distributed' as const), { + nil: undefined, + }), + }), + status: fc.option( + fc.record({ + state: fc.option(fc.constantFrom('Allocated', 'UnAllocated', 'Contention'), { + nil: undefined, + }), + gameServerName: fc.option(fc.string({ minLength: 1, maxLength: 30 }), { nil: undefined }), + address: fc.option(fc.ipV4(), { nil: undefined }), + ports: fc.option( + fc.array( + fc.record({ + name: fc.string({ minLength: 1, maxLength: 15 }), + port: fc.integer({ min: 1, max: 65535 }), + }), + { maxLength: 4 } + ), + { nil: undefined } + ), + }), + { nil: undefined } + ), + }); +} + +describe('GameServerAllocation getters — property tests', () => { + it('string getters should always return strings (never undefined)', () => { + fc.assert( + fc.property(fcGameServerAllocationJson(), json => { + const gsa = new GameServerAllocation(json); + expect(typeof gsa.allocationState).toBe('string'); + expect(typeof gsa.gameServerName).toBe('string'); + expect(typeof gsa.address).toBe('string'); + }) + ); + }); + + it('ports should always return a string in "name:port" format or empty', () => { + fc.assert( + fc.property(fcGameServerAllocationJson(), json => { + const gsa = new GameServerAllocation(json); + const result = gsa.ports; + expect(typeof result).toBe('string'); + + // If there are ports in status, verify the format + if (result.length > 0) { + const parts = result.split(', '); + for (const part of parts) { + // Each part should be "name:port" + expect(part).toMatch(/^.+:\d+$/); + } + } + }) + ); + }); +}); diff --git a/src/utils/buildAllocationBody.property.test.ts b/src/utils/buildAllocationBody.property.test.ts new file mode 100644 index 0000000..11b7e28 --- /dev/null +++ b/src/utils/buildAllocationBody.property.test.ts @@ -0,0 +1,208 @@ +/* + * Copyright Contributors to Agones a Series of LF Projects, LLC. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { AllocationFormState, buildAllocationBody } from './buildAllocationBody'; + +/** + * Arbitrary that generates random {@link AllocationFormState} objects. + * Covers the full surface area of the form: namespaces, label selectors, + * counter/list filters, counter/list mutations, and priorities. + */ +function fcAllocationFormState(): fc.Arbitrary { + return fc.record({ + namespace: fc.string({ minLength: 1, maxLength: 63 }), + labelSelector: fc + .array(fc.tuple(fc.string({ maxLength: 20 }), fc.string({ maxLength: 20 })), { maxLength: 5 }) + .map(pairs => pairs.map(([k, v]) => `${k}=${v}`).join(',')), + gameServerState: fc.constantFrom('Ready' as const, 'Allocated' as const), + scheduling: fc.constantFrom('Packed' as const, 'Distributed' as const), + priorities: fc.array( + fc.record({ + type: fc.constantFrom('Counter' as const, 'List' as const), + key: fc.string({ maxLength: 20 }), + order: fc.constantFrom('Ascending' as const, 'Descending' as const), + }), + { maxLength: 3 } + ), + counterFilters: fc.array( + fc.record({ + key: fc.string({ maxLength: 20 }), + minAvailable: fc.oneof(fc.constant(''), fc.nat({ max: 100 }).map(String)), + }), + { maxLength: 3 } + ), + listFilters: fc.array( + fc.record({ + key: fc.string({ maxLength: 20 }), + containsValue: fc.string({ maxLength: 20 }), + minAvailable: fc.oneof(fc.constant(''), fc.nat({ max: 100 }).map(String)), + }), + { maxLength: 3 } + ), + counterMuts: fc.array( + fc.record({ + key: fc.string({ maxLength: 20 }), + action: fc.constantFrom('Increment' as const, 'Decrement' as const), + amount: fc.oneof(fc.constant(''), fc.nat({ max: 100 }).map(String)), + }), + { maxLength: 3 } + ), + listMuts: fc.array( + fc.record({ + key: fc.string({ maxLength: 20 }), + addValues: fc.array(fc.string({ maxLength: 10 }), { maxLength: 5 }).map(v => v.join(',')), + }), + { maxLength: 3 } + ), + }); +} + +describe('buildAllocationBody — property tests', () => { + it('should always produce apiVersion "allocation.agones.dev/v1"', () => { + fc.assert( + fc.property(fcAllocationFormState(), form => { + const body = buildAllocationBody(form); + expect(body.apiVersion).toBe('allocation.agones.dev/v1'); + }) + ); + }); + + it('should always produce kind "GameServerAllocation"', () => { + fc.assert( + fc.property(fcAllocationFormState(), form => { + const body = buildAllocationBody(form); + expect(body.kind).toBe('GameServerAllocation'); + }) + ); + }); + + it('should always set metadata.namespace to the form namespace', () => { + fc.assert( + fc.property(fcAllocationFormState(), form => { + const body = buildAllocationBody(form); + const meta = body.metadata as Record; + expect(meta.namespace).toBe(form.namespace); + }) + ); + }); + + it('should always set metadata.generateName to "ui-allocation-"', () => { + fc.assert( + fc.property(fcAllocationFormState(), form => { + const body = buildAllocationBody(form); + const meta = body.metadata as Record; + expect(meta.generateName).toBe('ui-allocation-'); + }) + ); + }); + + it('should always have exactly one selector in spec.selectors', () => { + fc.assert( + fc.property(fcAllocationFormState(), form => { + const body = buildAllocationBody(form); + const spec = body.spec as Record; + expect(spec.selectors).toHaveLength(1); + }) + ); + }); + + it('should always propagate scheduling from form to spec', () => { + fc.assert( + fc.property(fcAllocationFormState(), form => { + const body = buildAllocationBody(form); + const spec = body.spec as Record; + expect(spec.scheduling).toBe(form.scheduling); + }) + ); + }); + + it('should always propagate gameServerState to the selector', () => { + fc.assert( + fc.property(fcAllocationFormState(), form => { + const body = buildAllocationBody(form); + const spec = body.spec as Record; + const selector = spec.selectors[0] as Record; + expect(selector.gameServerState).toBe(form.gameServerState); + }) + ); + }); + + it('should never have leading/trailing whitespace in matchLabels keys or values', () => { + fc.assert( + fc.property(fcAllocationFormState(), form => { + const body = buildAllocationBody(form); + const spec = body.spec as Record; + const selector = spec.selectors[0] as Record>; + const labels = selector.matchLabels ?? {}; + for (const [key, val] of Object.entries(labels)) { + expect(key).toBe(key.trim()); + expect(val).toBe(val.trim()); + } + }) + ); + }); + + it('should never include counter mutations with empty amount', () => { + fc.assert( + fc.property(fcAllocationFormState(), form => { + const body = buildAllocationBody(form); + const spec = body.spec as Record>; + if (spec.counters) { + for (const mut of Object.values(spec.counters)) { + expect(mut.amount).toBeDefined(); + expect(typeof mut.amount).toBe('number'); + } + } + }) + ); + }); + + it('should never include priorities with empty keys', () => { + fc.assert( + fc.property(fcAllocationFormState(), form => { + const body = buildAllocationBody(form); + const spec = body.spec as Record>; + if (spec.priorities) { + for (const p of spec.priorities) { + expect(p.key.trim()).not.toBe(''); + } + } + }) + ); + }); + + it('should never include counter/list filter keys that are empty after trimming', () => { + fc.assert( + fc.property(fcAllocationFormState(), form => { + const body = buildAllocationBody(form); + const spec = body.spec as Record; + const selector = spec.selectors[0] as Record>; + if (selector.counters) { + for (const key of Object.keys(selector.counters)) { + expect(key.trim()).not.toBe(''); + } + } + if (selector.lists) { + for (const key of Object.keys(selector.lists)) { + expect(key.trim()).not.toBe(''); + } + } + }) + ); + }); +});