diff --git a/src/renderer/Clipboard.js b/src/renderer/Clipboard.js index 4368e62b..fc0202cf 100644 --- a/src/renderer/Clipboard.js +++ b/src/renderer/Clipboard.js @@ -7,7 +7,8 @@ const canCopy = id => ID.isLayerId(id) || ID.isFeatureId(id) || ID.isMarkerId(id) || - ID.isTileServiceId(id) + ID.isTileServiceId(id) || + ID.isSSEServiceId(id) /** * @async implicit diff --git a/src/renderer/components/map/SSEVectorSource.js b/src/renderer/components/map/SSEVectorSource.js index bd5b66a8..aa4bece5 100644 --- a/src/renderer/components/map/SSEVectorSource.js +++ b/src/renderer/components/map/SSEVectorSource.js @@ -84,7 +84,7 @@ class SSEVectorSource extends VectorSource { strategy: function (extent, resolution) { if (state.resolution !== resolution) { state.resolution = resolution - this.getFeatures().map(feature => feature.$.centerResolution(resolution)) + this.getFeatures().forEach(feature => feature.$?.centerResolution(resolution)) } return [extent] } @@ -128,6 +128,30 @@ class SSEVectorSource extends VectorSource { */ this.useFeatureIds = options.useFeatureIds !== false + /** + * Render mode: 'vector' for normal rendering, 'heatmap' for density visualization + * @type {string} + */ + this.renderMode = options.renderMode || 'vector' + + /** + * Rate limiting interval for heatmap accumulation in milliseconds + * @type {number} + */ + this.heatmapInterval = options.heatmapInterval || 1000 + + /** + * Timestamp of last heatmap accumulation (ms since epoch) + * @type {number} + */ + this.lastHeatmapUpdate = 0 + + /** + * Maximum number of accumulated heatmap features before oldest are dropped + * @type {number} + */ + this.maxHeatmapFeatures = options.maxHeatmapFeatures || 50000 + /** * Prefix added to feature IDs * @type {string} @@ -233,7 +257,13 @@ class SSEVectorSource extends VectorSource { try { /** @type {GeoJSONData} */ const data = JSON.parse(event.data) - if (data.id) { + if (data.type === 'FeatureCollection' && data.features) { + data.features.forEach(feature => { + if (feature.id) { + feature.id = `${this.idPrefix}${feature.id}` + } + }) + } else if (data.id) { data.id = `${this.idPrefix}${data.id}` } if (this.onMessage) { @@ -279,6 +309,8 @@ class SSEVectorSource extends VectorSource { this.timerId = null } + this.lastHeatmapUpdate = 0 + this.dispatchEvent({ type: 'sse-disconnected', source: this @@ -330,7 +362,13 @@ class SSEVectorSource extends VectorSource { console.warn('Unknown feature type ' + this.pendingData.type) } - if (this.useFeatureIds) { + if (this.renderMode === 'heatmap') { + const now = Date.now() + if (now - this.lastHeatmapUpdate >= this.heatmapInterval) { + this.accumulateFeatures(features) + this.lastHeatmapUpdate = now + } + } else if (this.useFeatureIds) { this.updateFeaturesById(features) } else { this.replaceAllFeatures(features) @@ -395,6 +433,37 @@ class SSEVectorSource extends VectorSource { this.addFeatures(olFeatures) } + /** + * Accumulates features without clearing existing ones (for heatmap mode). + * Uses lightweight format.readFeature() instead of signal-based featureReader. + * Maps properties.confidence to a 'weight' property for heatmap rendering. + * + * @param {GeoJSONFeature[]} features - Array of GeoJSON features + * @returns {void} + */ + accumulateFeatures (features) { + const olFeatures = features.map(feature => { + const reprojectedFeature = { + ...feature, + geometry: this.reprojectGeometry(feature.geometry) + } + const olFeature = format.readFeature(reprojectedFeature) + const confidence = feature.properties?.confidence + olFeature.set('weight', confidence != null ? confidence : 0.5) + return olFeature + }) + this.addFeatures(olFeatures) + + // Evict oldest features when cap is exceeded + const all = this.getFeatures() + const excess = all.length - this.maxHeatmapFeatures + if (excess > 0) { + for (let i = 0; i < excess; i++) { + this.removeFeature(all[i]) + } + } + } + /** * Updates existing features or adds new ones based on feature ID. * Features without IDs are skipped with a warning. diff --git a/src/renderer/components/properties/SSEServiceProperties.css b/src/renderer/components/properties/SSEServiceProperties.css index 10dbc887..e3e80f97 100644 --- a/src/renderer/components/properties/SSEServiceProperties.css +++ b/src/renderer/components/properties/SSEServiceProperties.css @@ -1,3 +1,32 @@ +.sse-section-label { + font-size: 0.85rem; + font-weight: 500; + color: #666; +} + +.sse-render-mode { + display: flex; + flex-direction: column; + gap: 4px; + padding: 4px 0; +} + +.sse-radio-group { + display: flex; + gap: 16px; +} + +.sse-radio-label { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; +} + +.sse-radio-label input:disabled + span { + color: #999; +} + .sse-option-row { display: flex; align-items: center; diff --git a/src/renderer/components/properties/SSEServiceProperties.js b/src/renderer/components/properties/SSEServiceProperties.js index ce099b88..938c036c 100644 --- a/src/renderer/components/properties/SSEServiceProperties.js +++ b/src/renderer/components/properties/SSEServiceProperties.js @@ -1,30 +1,112 @@ /* eslint-disable react/prop-types */ import React from 'react' import TextField from './TextField' +import Range from './Range' import FlexColumnGap from './FlexColumnGap' import Name from './Name' import { useServices } from '../hooks' import { Tooltip } from 'react-tooltip' import './SSEServiceProperties.css' +/** + * Displays the connection status indicator (green/gray dot with text). + * Polls independently so re-renders don't affect the parent form. + */ +const SSEConnectionStatus = ({ sseLayerStore, serviceKey }) => { + const [connected, setConnected] = React.useState(false) + + React.useEffect(() => { + const check = () => { + if (sseLayerStore) { + const stats = sseLayerStore.getServiceStats(serviceKey) + setConnected(!!stats.isConnected) + } + } + check() + const interval = setInterval(check, 1000) + return () => clearInterval(interval) + }, [serviceKey, sseLayerStore]) + + const className = connected + ? 'sse-status sse-status--connected' + : 'sse-status sse-status--disconnected' + + return ( +
+ + {connected ? 'Connected' : 'Disconnected'} +
+ ) +} + +/** + * Displays live stats (feature count, messages, updates). + * Polls independently so re-renders don't affect the parent form. + */ +const SSEServiceStatsPanel = ({ sseLayerStore, serviceKey }) => { + const [stats, setStats] = React.useState(null) + + React.useEffect(() => { + const update = () => { + if (sseLayerStore) { + setStats(sseLayerStore.getServiceStats(serviceKey)) + } + } + update() + const interval = setInterval(update, 1000) + return () => clearInterval(interval) + }, [serviceKey, sseLayerStore]) + + if (!stats?.isConnected) return null + + return ( +
+
+ Features: + {stats.featureCount} +
+
+ Messages: + {stats.messagesReceived} +
+
+ Updates: + {stats.mapUpdates} +
+
+ ) +} + const SSEServiceProperties = props => { const { sseLayerStore, store } = useServices() const [key, service] = (Object.entries(props.features))[0] const [url, setUrl] = React.useState({ dirty: false, value: service.url || '' }) const [eventType, setEventType] = React.useState({ dirty: false, value: service.eventType || 'message' }) const [updateInterval, setUpdateInterval] = React.useState({ dirty: false, value: service.updateInterval || 100 }) - const [stats, setStats] = React.useState({ isConnected: false, featureCount: 0 }) + const [heatmapRadius, setHeatmapRadius] = React.useState({ dirty: false, value: service.heatmapRadius ?? 20 }) + const [heatmapBlur, setHeatmapBlur] = React.useState({ dirty: false, value: service.heatmapBlur ?? 15 }) + const [heatmapOpacity, setHeatmapOpacity] = React.useState({ dirty: false, value: service.heatmapOpacity ?? 0.75 }) + const [heatmapInterval, setHeatmapInterval] = React.useState({ dirty: false, value: service.heatmapInterval ?? 1000 }) + const [maxHeatmapFeatures, setMaxHeatmapFeatures] = React.useState({ dirty: false, value: service.maxHeatmapFeatures ?? 50000 }) + const [vectorOpacity, setVectorOpacity] = React.useState({ dirty: false, value: service.vectorOpacity ?? 1 }) + const [connected, setConnected] = React.useState(false) + + const renderMode = service.renderMode || 'vector' + const isHeatmap = renderMode === 'heatmap' - // Update stats periodically + // Track connection status as a primitive boolean. + // React skips re-renders when the value hasn't changed, + // so this won't cause re-renders during steady streaming. React.useEffect(() => { - const updateStats = () => { + const checkConnection = () => { if (sseLayerStore) { - setStats(sseLayerStore.getServiceStats(key)) + const stats = sseLayerStore.getServiceStats(key) + setConnected(!!stats.isConnected) } } - updateStats() - const interval = setInterval(updateStats, 1000) + checkConnection() + const interval = setInterval(checkConnection, 1000) return () => clearInterval(interval) }, [key, sseLayerStore]) @@ -33,6 +115,12 @@ const SSEServiceProperties = props => { setUrl({ dirty: false, value: service.url || '' }) setEventType({ dirty: false, value: service.eventType || 'message' }) setUpdateInterval({ dirty: false, value: service.updateInterval || 100 }) + setHeatmapRadius({ dirty: false, value: service.heatmapRadius ?? 20 }) + setHeatmapBlur({ dirty: false, value: service.heatmapBlur ?? 15 }) + setHeatmapOpacity({ dirty: false, value: service.heatmapOpacity ?? 0.75 }) + setHeatmapInterval({ dirty: false, value: service.heatmapInterval ?? 1000 }) + setMaxHeatmapFeatures({ dirty: false, value: service.maxHeatmapFeatures ?? 50000 }) + setVectorOpacity({ dirty: false, value: service.vectorOpacity ?? 1 }) }, [key]) // eslint-disable-line react-hooks/exhaustive-deps const updateService = async (updates) => { @@ -65,15 +153,85 @@ const SSEServiceProperties = props => { } const handleUpdateIntervalChange = ({ target }) => { - const value = parseInt(target.value, 10) || 100 - if (updateInterval.value === value) return - setUpdateInterval({ dirty: true, value }) + if (updateInterval.value === target.value) return + setUpdateInterval({ dirty: true, value: target.value }) } const handleUpdateIntervalBlur = () => { if (!updateInterval.dirty) return - setUpdateInterval({ dirty: false, value: updateInterval.value }) - updateService({ updateInterval: updateInterval.value }) + const parsed = parseInt(updateInterval.value, 10) + const value = Number.isFinite(parsed) && parsed > 0 ? parsed : 100 + setUpdateInterval({ dirty: false, value }) + updateService({ updateInterval: value }) + } + + const handleRenderModeChange = (mode) => { + if (mode === renderMode) return + updateService({ renderMode: mode }) + } + + const handleHeatmapRadiusChange = ({ target }) => { + if (heatmapRadius.value === target.value) return + setHeatmapRadius({ dirty: true, value: target.value }) + } + + const handleHeatmapRadiusBlur = () => { + if (!heatmapRadius.dirty) return + const parsed = parseInt(heatmapRadius.value, 10) + const value = Number.isFinite(parsed) && parsed > 0 ? parsed : 20 + setHeatmapRadius({ dirty: false, value }) + updateService({ heatmapRadius: value }) + } + + const handleHeatmapBlurChange = ({ target }) => { + if (heatmapBlur.value === target.value) return + setHeatmapBlur({ dirty: true, value: target.value }) + } + + const handleHeatmapBlurBlur = () => { + if (!heatmapBlur.dirty) return + const parsed = parseInt(heatmapBlur.value, 10) + const value = Number.isFinite(parsed) && parsed >= 0 ? parsed : 15 + setHeatmapBlur({ dirty: false, value }) + updateService({ heatmapBlur: value }) + } + + const handleHeatmapOpacityChange = ({ target }) => { + const value = Number.parseFloat(target.value) + setHeatmapOpacity({ dirty: false, value }) + updateService({ heatmapOpacity: value }) + } + + const handleHeatmapIntervalChange = ({ target }) => { + if (heatmapInterval.value === target.value) return + setHeatmapInterval({ dirty: true, value: target.value }) + } + + const handleHeatmapIntervalBlur = () => { + if (!heatmapInterval.dirty) return + const parsed = parseInt(heatmapInterval.value, 10) + const value = Number.isFinite(parsed) && parsed > 0 ? parsed : 1000 + setHeatmapInterval({ dirty: false, value }) + updateService({ heatmapInterval: value }) + } + + const handleMaxHeatmapFeaturesChange = ({ target }) => { + if (maxHeatmapFeatures.value === target.value) return + setMaxHeatmapFeatures({ dirty: true, value: target.value }) + } + + const handleMaxHeatmapFeaturesBlur = () => { + if (!maxHeatmapFeatures.dirty) return + const parsed = parseInt(maxHeatmapFeatures.value, 10) + const value = Number.isFinite(parsed) && parsed > 0 ? parsed : 50000 + setMaxHeatmapFeatures({ dirty: false, value }) + updateService({ maxHeatmapFeatures: value }) + } + + const handleVectorOpacityChange = ({ target }) => { + const value = Number.parseFloat(target.value) + setVectorOpacity({ dirty: false, value }) + updateService({ vectorOpacity: value }) } const handleEnabledChange = ({ target }) => { @@ -84,12 +242,7 @@ const SSEServiceProperties = props => { updateService({ useFeatureIds: target.checked }) } - const connectionStatusClass = stats.isConnected - ? 'sse-status sse-status--connected' - : 'sse-status sse-status--disconnected' - - const connectionStatusText = stats.isConnected ? 'Connected' : 'Disconnected' - const isConnected = service.enabled && stats.isConnected + const isConnected = service.enabled && connected return ( @@ -126,18 +279,123 @@ const SSEServiceProperties = props => { /> -
- +
+ Render Mode +
+ + +
- + + + {isHeatmap && ( + <> + + + + + + + + + + + + + + + + + + + )} + + {!isHeatmap && ( + <> + + + + + + +
+ +
+ + + )}
-
- - {connectionStatusText} -
+
- {stats.isConnected && ( -
-
- Features: - {stats.featureCount} -
-
- Messages: - {stats.messagesReceived} -
-
- Updates: - {stats.mapUpdates} -
-
- )} + ) } diff --git a/src/renderer/ids.js b/src/renderer/ids.js index 9168db98..815d4135 100644 --- a/src/renderer/ids.js +++ b/src/renderer/ids.js @@ -219,12 +219,16 @@ export const associatedId = id => { : id.substring(indexStart + 1) } +// Entity types must sort before their dependent entries (link, tags, style), +// because clone() rewrites dependent keys via a keymap populated when the +// owning entity is processed. export const ord = R.cond([ [isLayerId, R.always(0)], [isFeatureId, R.always(1)], [isMarkerId, R.always(2)], - [isLinkId, R.always(3)], - [isTagsId, R.always(4)], - [isStyleId, R.always(5)], - [isTileServiceId, R.always(6)] + [isTileServiceId, R.always(3)], + [isSSEServiceId, R.always(4)], + [isLinkId, R.always(5)], + [isTagsId, R.always(6)], + [isStyleId, R.always(7)] ]) diff --git a/src/renderer/model/Import.js b/src/renderer/model/Import.js index 1a7e8020..3d734fa8 100644 --- a/src/renderer/model/Import.js +++ b/src/renderer/model/Import.js @@ -1,5 +1,6 @@ import * as R from 'ramda' import * as ID from '../ids' +import uuid from '../../shared/uuid' export const clone = async (defaultLayerId, unsorted) => { const entries = unsorted.sort(([a], [b]) => ID.ord(a) - ID.ord(b)) @@ -45,13 +46,22 @@ export const clone = async (defaultLayerId, unsorted) => { [ID.isTagsId, key => replace(key)(ID.tagsId(keymap[ID.associatedId(key)]))], [ID.isMarkerId, key => replace(key)(ID.markerId())], [ID.isTileServiceId, key => replace(key)(ID.tileServiceId())], + [ID.isSSEServiceId, key => replace(key)(ID.sseServiceId())], [ID.isStyleId, key => replace(key)(ID.styleId(keymap[ID.associatedId(key)]))], [R.T, key => key] ]) + // Rewrite values where a plain key remap is not sufficient. + // A duplicated live data source must not share the original's + // feature namespace and must not auto-connect. + + const rewriteValue = (key, value) => ID.isSSEServiceId(key) + ? { ...value, enabled: false, featureIdPrefix: `feature:${uuid()}/` } + : value + // Nasty side-effect: adds tuples (aka acc): entries.reduce((acc, [key, value]) => { - acc.push([rewrite(key), value]) + acc.push([rewrite(key), rewriteValue(key, value)]) return acc }, tuples) diff --git a/src/renderer/model/commands/CreationCommands.js b/src/renderer/model/commands/CreationCommands.js index 70d6e43e..c919584b 100644 --- a/src/renderer/model/commands/CreationCommands.js +++ b/src/renderer/model/commands/CreationCommands.js @@ -104,7 +104,14 @@ CreateSSEService.prototype.execute = function () { dataProjection: 'EPSG:4326', updateInterval: 100, featureIdPrefix: `feature:${layerId}/`, - useFeatureIds: true + useFeatureIds: true, + renderMode: 'vector', + heatmapRadius: 20, + heatmapBlur: 15, + heatmapOpacity: 0.75, + heatmapInterval: 1000, + maxHeatmapFeatures: 50000, + vectorOpacity: 1 }]]) this.selection.focus(key) } diff --git a/src/renderer/store/SSELayerStore.js b/src/renderer/store/SSELayerStore.js index 29bb700c..426978ee 100644 --- a/src/renderer/store/SSELayerStore.js +++ b/src/renderer/store/SSELayerStore.js @@ -1,5 +1,6 @@ import Collection from 'ol/Collection' import LayerGroup from 'ol/layer/Group' +import HeatmapLayer from 'ol/layer/Heatmap' import VectorLayer from 'ol/layer/Vector' import * as ID from '../ids' import SSEVectorSource from '../components/map/SSEVectorSource' @@ -28,7 +29,7 @@ SSELayerStore.prototype.bootstrap = async function () { } /** - * Create LayerGroup with VectorLayers for enabled services. + * Create LayerGroup with layers for enabled services. */ SSELayerStore.prototype.sseLayers = async function () { const services = await this.store.tuples(ID.SSE_SERVICE_SCOPE) @@ -39,32 +40,60 @@ SSELayerStore.prototype.sseLayers = async function () { } } - this.layerCollection = new Collection(Array.from(this.layers.values()).map(({ layer }) => layer)) + const heatmapLayers = [] + const vectorLayers = [] + for (const { layer } of this.layers.values()) { + if (layer instanceof HeatmapLayer) { + heatmapLayers.push(layer) + } else { + vectorLayers.push(layer) + } + } + + this.layerCollection = new Collection([...heatmapLayers, ...vectorLayers]) this.layerGroup = new LayerGroup({ layers: this.layerCollection }) return [this.layerGroup] } /** - * Create VectorLayer with SSEVectorSource for a service. + * Create a VectorLayer or HeatmapLayer with SSEVectorSource for a service. */ SSELayerStore.prototype.createLayer = function (key, service) { if (this.layers.has(key)) { return this.layers.get(key) } + const renderMode = service.renderMode || 'vector' + const source = new SSEVectorSource({ sseUrl: service.url, eventType: service.eventType || 'message', dataProjection: service.dataProjection || 'EPSG:4326', updateInterval: service.updateInterval || 100, idPrefix: service.featureIdPrefix || 'feature:', - useFeatureIds: service.useFeatureIds !== false + useFeatureIds: service.useFeatureIds !== false, + renderMode, + heatmapInterval: service.heatmapInterval ?? 1000, + maxHeatmapFeatures: service.maxHeatmapFeatures ?? 50000 }) - const layer = new VectorLayer({ - source, - id: key - }) + let layer + if (renderMode === 'heatmap') { + layer = new HeatmapLayer({ + source, + id: key, + blur: service.heatmapBlur ?? 15, + radius: service.heatmapRadius ?? 20, + opacity: service.heatmapOpacity ?? 0.75, + weight: feature => feature.get('weight') ?? 0.5 + }) + } else { + layer = new VectorLayer({ + source, + id: key, + opacity: service.vectorOpacity ?? 1 + }) + } this.layers.set(key, { layer, source }) @@ -95,7 +124,29 @@ SSELayerStore.prototype.updateOrCreateService = function (key, service) { const existing = this.layers.get(key) if (existing) { - const { source } = existing + const { source, layer } = existing + + // Render mode change requires full layer recreate + const renderMode = service.renderMode || 'vector' + if (source.renderMode !== renderMode) { + this.removeLayer(key) + if (service.enabled) { + const { layer: newLayer } = this.createLayer(key, service) + this.insertLayerOrdered(newLayer) + } + return + } + + // Dynamically update layer properties based on mode + if (renderMode === 'heatmap' && layer instanceof HeatmapLayer) { + layer.setBlur(service.heatmapBlur ?? 15) + layer.setRadius(service.heatmapRadius ?? 20) + layer.setOpacity(service.heatmapOpacity ?? 0.75) + source.heatmapInterval = service.heatmapInterval ?? 1000 + source.maxHeatmapFeatures = service.maxHeatmapFeatures ?? 50000 + } else { + layer.setOpacity(service.vectorOpacity ?? 1) + } // Check if we need to reconnect due to config changes const configChanged = source.sseUrl !== service.url || @@ -126,9 +177,24 @@ SSELayerStore.prototype.updateOrCreateService = function (key, service) { } else if (service.enabled) { // Create new layer for enabled service const { layer } = this.createLayer(key, service) - if (this.layerCollection) { + this.insertLayerOrdered(layer) + } +} + +/** + * Insert a layer at the correct position: heatmap layers before all vector layers. + */ +SSELayerStore.prototype.insertLayerOrdered = function (layer) { + if (!this.layerCollection) return + if (layer instanceof HeatmapLayer) { + const index = this.layerCollection.getArray().findIndex(l => !(l instanceof HeatmapLayer)) + if (index === -1) { this.layerCollection.push(layer) + } else { + this.layerCollection.insertAt(index, layer) } + } else { + this.layerCollection.push(layer) } } diff --git a/src/renderer/store/documents/marker.js b/src/renderer/store/documents/marker.js index 534c73e4..82ac669d 100644 --- a/src/renderer/store/documents/marker.js +++ b/src/renderer/store/documents/marker.js @@ -5,6 +5,8 @@ export default async function (id) { const keys = [R.identity, ID.tagsId] const [marker, tags] = await this.store.collect(id, keys) + if (!marker) return null + return { id, scope: ID.MARKER, diff --git a/src/renderer/store/documents/sse-service.js b/src/renderer/store/documents/sse-service.js index 3c6e7ce8..78b01c5a 100644 --- a/src/renderer/store/documents/sse-service.js +++ b/src/renderer/store/documents/sse-service.js @@ -5,6 +5,8 @@ export default async function (id) { const keys = [R.identity, ID.tagsId] const [service, tags] = await this.store.collect(id, keys) + if (!service) return null + const document = { id, scope: ID.SSE_SERVICE, diff --git a/test/renderer/model/Import-test.js b/test/renderer/model/Import-test.js new file mode 100644 index 00000000..b2a95dec --- /dev/null +++ b/test/renderer/model/Import-test.js @@ -0,0 +1,67 @@ +import assert from 'assert' +import * as ID from '../../../src/renderer/ids' +import { clone } from '../../../src/renderer/model/Import' + +describe('Import.clone', function () { + + describe('sse-service (live data source)', function () { + + it('rewrites the service key to a new id', async function () { + const original = ID.sseServiceId() + const entries = [[original, { name: 'Source', enabled: true }]] + + const tuples = await clone(null, entries) + + assert.equal(tuples.length, 1) + const [key] = tuples[0] + assert(ID.isSSEServiceId(key)) + assert.notEqual(key, original, 'must not overwrite the original service') + }) + + it('rewrites the associated tags key to the new service id', async function () { + const original = ID.sseServiceId() + const entries = [ + [original, { name: 'Source' }], + [ID.tagsId(original), ['Live Data']] + ] + + const tuples = await clone(null, entries) + const map = Object.fromEntries(tuples) + + const newServiceKey = Object.keys(map).find(ID.isSSEServiceId) + const tagsKey = Object.keys(map).find(k => k.startsWith('tags+')) + assert.equal(tagsKey, ID.tagsId(newServiceKey), 'tags must follow the new service id') + assert.deepEqual(map[tagsKey], ['Live Data']) + }) + + it('inserts the copy disabled', async function () { + const entries = [[ID.sseServiceId(), { name: 'Source', enabled: true }]] + + const tuples = await clone(null, entries) + + assert.equal(tuples[0][1].enabled, false) + }) + + it('assigns the copy a fresh feature namespace', async function () { + const original = ID.sseServiceId() + const featureIdPrefix = 'feature:11111111-1111-1111-1111-111111111111/' + const entries = [[original, { name: 'Source', featureIdPrefix }]] + + const tuples = await clone(null, entries) + + const value = tuples[0][1] + assert.notEqual(value.featureIdPrefix, featureIdPrefix, 'must not share the original namespace') + const uuid = value.featureIdPrefix.replace(/^feature:/, '').replace(/\/$/, '') + assert(ID.isUUID(uuid), 'featureIdPrefix must reference a valid uuid') + }) + + it('does not mutate the original value', async function () { + const value = { name: 'Source', enabled: true } + const entries = [[ID.sseServiceId(), value]] + + await clone(null, entries) + + assert.equal(value.enabled, true) + }) + }) +})