Files
LocalAI/core/config/application_config_test.go
T
localai-org-maint-botandlocalai-org-maint-bot 8f56e4e042 fix(vram): persist remote probe metadata (#11487)
* fix(vram): persist remote probe metadata

The startup warmer repeated remote size and GGUF metadata probes after every restart because both caches lived only in memory. Store successful HTTP probes for 24 hours so frequent restarts reuse the prior results.

Bound the cache, reject invalid records, and purge it when gallery data changes. Local model files continue to bypass persistence.

Assisted-by: Codex:gpt-5

* fix(vram): check temporary file cleanup

The lint gate rejects the unchecked cleanup call in the persistent cache writer.

Assisted-by: Codex:gpt-5.6 [golangci-lint]

* fix(vram): make persistent cache optional

Remote metadata probes can transfer enough data that operators need
control over disk reuse and startup warming. Gallery autoload now gates
both behaviors, and the runtime setting applies changes immediately.

Assisted-by: Codex:gpt-5

* fix(ui): expose gallery startup pre-warm

The existing gallery autoload setting also gates the startup metadata warmer. Name both effects in Settings so operators can find the requested boot control.

Assisted-by: Codex:gpt-5

---------

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-23 08:55:13 +02:00

669 lines
20 KiB
Go

package config
import (
"encoding/json"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("ApplicationConfig RuntimeSettings Conversion", func() {
Describe("ToRuntimeSettings", func() {
It("includes the persistent VRAM cache toggle", func() {
encoded, err := json.Marshal(NewApplicationConfig().ToRuntimeSettings())
Expect(err).NotTo(HaveOccurred())
var settings map[string]any
Expect(json.Unmarshal(encoded, &settings)).To(Succeed())
Expect(settings).To(HaveKeyWithValue("vram_persistent_cache", true))
})
It("should convert all fields correctly", func() {
appConfig := &ApplicationConfig{
WatchDog: true,
WatchDogIdle: true,
WatchDogBusy: true,
WatchDogIdleTimeout: 20 * time.Minute,
WatchDogBusyTimeout: 10 * time.Minute,
SingleBackend: false,
MaxActiveBackends: 5,
MemoryReclaimerEnabled: true,
MemoryReclaimerThreshold: 0.85,
Threads: 8,
ContextSize: 4096,
F16: true,
Debug: true,
CORS: true,
DisableCSRF: true,
CORSAllowOrigins: "https://example.com",
P2PToken: "test-token",
P2PNetworkID: "test-network",
Federated: true,
Galleries: []Gallery{{Name: "test-gallery", URL: "https://example.com"}},
BackendGalleries: []Gallery{{Name: "backend-gallery", URL: "https://example.com/backend"}},
AutoloadGalleries: true,
AutoloadBackendGalleries: true,
ApiKeys: []string{"key1", "key2"},
AgentJobRetentionDays: 30,
}
rs := appConfig.ToRuntimeSettings()
Expect(rs.WatchdogEnabled).ToNot(BeNil())
Expect(*rs.WatchdogEnabled).To(BeTrue())
Expect(rs.WatchdogIdleEnabled).ToNot(BeNil())
Expect(*rs.WatchdogIdleEnabled).To(BeTrue())
Expect(rs.WatchdogBusyEnabled).ToNot(BeNil())
Expect(*rs.WatchdogBusyEnabled).To(BeTrue())
Expect(rs.WatchdogIdleTimeout).ToNot(BeNil())
Expect(*rs.WatchdogIdleTimeout).To(Equal("20m0s"))
Expect(rs.WatchdogBusyTimeout).ToNot(BeNil())
Expect(*rs.WatchdogBusyTimeout).To(Equal("10m0s"))
Expect(rs.SingleBackend).ToNot(BeNil())
Expect(*rs.SingleBackend).To(BeFalse())
Expect(rs.MaxActiveBackends).ToNot(BeNil())
Expect(*rs.MaxActiveBackends).To(Equal(5))
Expect(rs.MemoryReclaimerEnabled).ToNot(BeNil())
Expect(*rs.MemoryReclaimerEnabled).To(BeTrue())
Expect(rs.MemoryReclaimerThreshold).ToNot(BeNil())
Expect(*rs.MemoryReclaimerThreshold).To(Equal(0.85))
Expect(rs.Threads).ToNot(BeNil())
Expect(*rs.Threads).To(Equal(8))
Expect(rs.ContextSize).ToNot(BeNil())
Expect(*rs.ContextSize).To(Equal(4096))
Expect(rs.F16).ToNot(BeNil())
Expect(*rs.F16).To(BeTrue())
Expect(rs.Debug).ToNot(BeNil())
Expect(*rs.Debug).To(BeTrue())
Expect(rs.CORS).ToNot(BeNil())
Expect(*rs.CORS).To(BeTrue())
Expect(rs.CSRF).ToNot(BeNil())
Expect(*rs.CSRF).To(BeTrue())
Expect(rs.CORSAllowOrigins).ToNot(BeNil())
Expect(*rs.CORSAllowOrigins).To(Equal("https://example.com"))
Expect(rs.P2PToken).ToNot(BeNil())
Expect(*rs.P2PToken).To(Equal("test-token"))
Expect(rs.P2PNetworkID).ToNot(BeNil())
Expect(*rs.P2PNetworkID).To(Equal("test-network"))
Expect(rs.Federated).ToNot(BeNil())
Expect(*rs.Federated).To(BeTrue())
Expect(rs.Galleries).ToNot(BeNil())
Expect(*rs.Galleries).To(HaveLen(1))
Expect((*rs.Galleries)[0].Name).To(Equal("test-gallery"))
Expect(rs.BackendGalleries).ToNot(BeNil())
Expect(*rs.BackendGalleries).To(HaveLen(1))
Expect((*rs.BackendGalleries)[0].Name).To(Equal("backend-gallery"))
Expect(rs.AutoloadGalleries).ToNot(BeNil())
Expect(*rs.AutoloadGalleries).To(BeTrue())
Expect(rs.AutoloadBackendGalleries).ToNot(BeNil())
Expect(*rs.AutoloadBackendGalleries).To(BeTrue())
Expect(rs.ApiKeys).ToNot(BeNil())
Expect(*rs.ApiKeys).To(HaveLen(2))
Expect(*rs.ApiKeys).To(ContainElements("key1", "key2"))
Expect(rs.AgentJobRetentionDays).ToNot(BeNil())
Expect(*rs.AgentJobRetentionDays).To(Equal(30))
})
It("should include auto_upgrade_backends", func() {
appConfig := &ApplicationConfig{AutoUpgradeBackends: true}
rs := appConfig.ToRuntimeSettings()
Expect(rs.AutoUpgradeBackends).ToNot(BeNil())
Expect(*rs.AutoUpgradeBackends).To(BeTrue())
})
It("should use default timeouts when not set", func() {
appConfig := &ApplicationConfig{}
rs := appConfig.ToRuntimeSettings()
Expect(rs.WatchdogIdleTimeout).ToNot(BeNil())
Expect(*rs.WatchdogIdleTimeout).To(Equal("15m"))
Expect(rs.WatchdogBusyTimeout).ToNot(BeNil())
Expect(*rs.WatchdogBusyTimeout).To(Equal("5m"))
})
})
Describe("ApplyRuntimeSettings", func() {
It("should return false when settings is nil", func() {
appConfig := &ApplicationConfig{}
changed := appConfig.ApplyRuntimeSettings(nil)
Expect(changed).To(BeFalse())
})
It("should only apply non-nil fields", func() {
appConfig := &ApplicationConfig{
WatchDog: false,
Threads: 4,
ContextSize: 2048,
}
watchdogEnabled := true
rs := &RuntimeSettings{
WatchdogEnabled: &watchdogEnabled,
// Leave other fields nil
}
changed := appConfig.ApplyRuntimeSettings(rs)
Expect(changed).To(BeTrue())
Expect(appConfig.WatchDog).To(BeTrue())
// Unchanged fields should remain
Expect(appConfig.Threads).To(Equal(4))
Expect(appConfig.ContextSize).To(Equal(2048))
})
It("should apply watchdog settings and return changed=true", func() {
appConfig := &ApplicationConfig{}
watchdogEnabled := true
watchdogIdle := true
watchdogBusy := true
idleTimeout := "30m"
busyTimeout := "15m"
rs := &RuntimeSettings{
WatchdogEnabled: &watchdogEnabled,
WatchdogIdleEnabled: &watchdogIdle,
WatchdogBusyEnabled: &watchdogBusy,
WatchdogIdleTimeout: &idleTimeout,
WatchdogBusyTimeout: &busyTimeout,
}
changed := appConfig.ApplyRuntimeSettings(rs)
Expect(changed).To(BeTrue())
Expect(appConfig.WatchDog).To(BeTrue())
Expect(appConfig.WatchDogIdle).To(BeTrue())
Expect(appConfig.WatchDogBusy).To(BeTrue())
Expect(appConfig.WatchDogIdleTimeout).To(Equal(30 * time.Minute))
Expect(appConfig.WatchDogBusyTimeout).To(Equal(15 * time.Minute))
})
It("should enable watchdog when idle is enabled", func() {
appConfig := &ApplicationConfig{WatchDog: false}
watchdogIdle := true
rs := &RuntimeSettings{
WatchdogIdleEnabled: &watchdogIdle,
}
appConfig.ApplyRuntimeSettings(rs)
Expect(appConfig.WatchDog).To(BeTrue())
Expect(appConfig.WatchDogIdle).To(BeTrue())
})
It("should enable watchdog when busy is enabled", func() {
appConfig := &ApplicationConfig{WatchDog: false}
watchdogBusy := true
rs := &RuntimeSettings{
WatchdogBusyEnabled: &watchdogBusy,
}
appConfig.ApplyRuntimeSettings(rs)
Expect(appConfig.WatchDog).To(BeTrue())
Expect(appConfig.WatchDogBusy).To(BeTrue())
})
// Residual #9125: the React Settings "Enable Watchdog" master toggle
// manages only watchdog_idle_enabled / watchdog_busy_enabled — it never
// touches the vestigial watchdog_enabled field. On a cold enable the
// body therefore carries watchdog_enabled=false alongside idle/busy=true.
// The derived run-state (WatchDog) must follow idle||busy so the live
// watchdog actually starts, not the stale watchdog_enabled=false.
It("should derive WatchDog from idle||busy on a cold enable even when watchdog_enabled=false", func() {
appConfig := &ApplicationConfig{WatchDog: false}
watchdogEnabled := false
watchdogIdle := true
watchdogBusy := true
rs := &RuntimeSettings{
WatchdogEnabled: &watchdogEnabled,
WatchdogIdleEnabled: &watchdogIdle,
WatchdogBusyEnabled: &watchdogBusy,
}
appConfig.ApplyRuntimeSettings(rs)
Expect(appConfig.WatchDog).To(BeTrue())
Expect(appConfig.WatchdogShouldRun()).To(BeTrue())
})
// The disable direction: the master toggle off sends idle=false,
// busy=false, but watchdog_enabled may still be the stale true loaded
// before the change. WatchDog must follow idle||busy down to false so
// the live watchdog is stopped (it stays stopped unless LRU / memory
// reclaimer keep it alive, which is gated by WatchdogShouldRun).
It("should disable WatchDog when both idle and busy are turned off", func() {
appConfig := &ApplicationConfig{WatchDog: true, WatchDogIdle: true, WatchDogBusy: true}
watchdogEnabled := true
watchdogIdle := false
watchdogBusy := false
rs := &RuntimeSettings{
WatchdogEnabled: &watchdogEnabled,
WatchdogIdleEnabled: &watchdogIdle,
WatchdogBusyEnabled: &watchdogBusy,
}
appConfig.ApplyRuntimeSettings(rs)
Expect(appConfig.WatchDog).To(BeFalse())
Expect(appConfig.WatchdogShouldRun()).To(BeFalse())
})
// Backward compatibility: an API client that posts only watchdog_enabled
// (idle/busy nil) keeps the explicit value — the idle/busy derivation
// only kicks in when those fields are actually present in the body.
It("should preserve explicit watchdog_enabled when idle/busy are absent", func() {
appConfig := &ApplicationConfig{WatchDog: false}
watchdogEnabled := true
rs := &RuntimeSettings{
WatchdogEnabled: &watchdogEnabled,
}
appConfig.ApplyRuntimeSettings(rs)
Expect(appConfig.WatchDog).To(BeTrue())
})
It("should handle MaxActiveBackends and update SingleBackend accordingly", func() {
appConfig := &ApplicationConfig{}
maxBackends := 1
rs := &RuntimeSettings{
MaxActiveBackends: &maxBackends,
}
appConfig.ApplyRuntimeSettings(rs)
Expect(appConfig.MaxActiveBackends).To(Equal(1))
Expect(appConfig.SingleBackend).To(BeTrue())
// Test with multiple backends
maxBackends = 5
rs = &RuntimeSettings{
MaxActiveBackends: &maxBackends,
}
appConfig.ApplyRuntimeSettings(rs)
Expect(appConfig.MaxActiveBackends).To(Equal(5))
Expect(appConfig.SingleBackend).To(BeFalse())
})
It("should handle SingleBackend and update MaxActiveBackends accordingly", func() {
appConfig := &ApplicationConfig{}
singleBackend := true
rs := &RuntimeSettings{
SingleBackend: &singleBackend,
}
appConfig.ApplyRuntimeSettings(rs)
Expect(appConfig.SingleBackend).To(BeTrue())
Expect(appConfig.MaxActiveBackends).To(Equal(1))
// Test disabling single backend
singleBackend = false
rs = &RuntimeSettings{
SingleBackend: &singleBackend,
}
appConfig.ApplyRuntimeSettings(rs)
Expect(appConfig.SingleBackend).To(BeFalse())
Expect(appConfig.MaxActiveBackends).To(Equal(0))
})
It("should enable watchdog when memory reclaimer is enabled", func() {
appConfig := &ApplicationConfig{WatchDog: false}
memoryEnabled := true
threshold := 0.90
rs := &RuntimeSettings{
MemoryReclaimerEnabled: &memoryEnabled,
MemoryReclaimerThreshold: &threshold,
}
changed := appConfig.ApplyRuntimeSettings(rs)
Expect(changed).To(BeTrue())
Expect(appConfig.WatchDog).To(BeTrue())
Expect(appConfig.MemoryReclaimerEnabled).To(BeTrue())
Expect(appConfig.MemoryReclaimerThreshold).To(Equal(0.90))
})
It("should reject invalid memory threshold values", func() {
appConfig := &ApplicationConfig{MemoryReclaimerThreshold: 0.50}
// Test threshold > 1.0
invalidThreshold := 1.5
rs := &RuntimeSettings{
MemoryReclaimerThreshold: &invalidThreshold,
}
appConfig.ApplyRuntimeSettings(rs)
Expect(appConfig.MemoryReclaimerThreshold).To(Equal(0.50)) // Should remain unchanged
// Test threshold <= 0
invalidThreshold = 0.0
rs = &RuntimeSettings{
MemoryReclaimerThreshold: &invalidThreshold,
}
appConfig.ApplyRuntimeSettings(rs)
Expect(appConfig.MemoryReclaimerThreshold).To(Equal(0.50)) // Should remain unchanged
// Test negative threshold
invalidThreshold = -0.5
rs = &RuntimeSettings{
MemoryReclaimerThreshold: &invalidThreshold,
}
appConfig.ApplyRuntimeSettings(rs)
Expect(appConfig.MemoryReclaimerThreshold).To(Equal(0.50)) // Should remain unchanged
})
It("should accept valid memory threshold at boundary", func() {
appConfig := &ApplicationConfig{}
// Test threshold = 1.0 (maximum valid)
threshold := 1.0
rs := &RuntimeSettings{
MemoryReclaimerThreshold: &threshold,
}
appConfig.ApplyRuntimeSettings(rs)
Expect(appConfig.MemoryReclaimerThreshold).To(Equal(1.0))
// Test threshold just above 0
threshold = 0.01
rs = &RuntimeSettings{
MemoryReclaimerThreshold: &threshold,
}
appConfig.ApplyRuntimeSettings(rs)
Expect(appConfig.MemoryReclaimerThreshold).To(Equal(0.01))
})
It("should apply performance settings without triggering watchdog change", func() {
appConfig := &ApplicationConfig{}
threads := 16
contextSize := 8192
f16 := true
debug := true
rs := &RuntimeSettings{
Threads: &threads,
ContextSize: &contextSize,
F16: &f16,
Debug: &debug,
}
changed := appConfig.ApplyRuntimeSettings(rs)
// These settings don't require watchdog restart
Expect(changed).To(BeFalse())
Expect(appConfig.Threads).To(Equal(16))
Expect(appConfig.ContextSize).To(Equal(8192))
Expect(appConfig.F16).To(BeTrue())
Expect(appConfig.Debug).To(BeTrue())
})
It("should apply CORS and security settings", func() {
appConfig := &ApplicationConfig{}
cors := true
csrf := true
origins := "https://example.com,https://other.com"
rs := &RuntimeSettings{
CORS: &cors,
CSRF: &csrf,
CORSAllowOrigins: &origins,
}
appConfig.ApplyRuntimeSettings(rs)
Expect(appConfig.CORS).To(BeTrue())
Expect(appConfig.DisableCSRF).To(BeTrue())
Expect(appConfig.CORSAllowOrigins).To(Equal("https://example.com,https://other.com"))
})
It("should apply P2P settings", func() {
appConfig := &ApplicationConfig{}
token := "p2p-test-token"
networkID := "p2p-test-network"
federated := true
rs := &RuntimeSettings{
P2PToken: &token,
P2PNetworkID: &networkID,
Federated: &federated,
}
appConfig.ApplyRuntimeSettings(rs)
Expect(appConfig.P2PToken).To(Equal("p2p-test-token"))
Expect(appConfig.P2PNetworkID).To(Equal("p2p-test-network"))
Expect(appConfig.Federated).To(BeTrue())
})
It("should apply gallery settings", func() {
appConfig := &ApplicationConfig{}
galleries := []Gallery{
{Name: "gallery1", URL: "https://gallery1.com"},
{Name: "gallery2", URL: "https://gallery2.com"},
}
backendGalleries := []Gallery{
{Name: "backend-gallery", URL: "https://backend.com"},
}
autoload := true
autoloadBackend := true
rs := &RuntimeSettings{
Galleries: &galleries,
BackendGalleries: &backendGalleries,
AutoloadGalleries: &autoload,
AutoloadBackendGalleries: &autoloadBackend,
}
appConfig.ApplyRuntimeSettings(rs)
Expect(appConfig.Galleries).To(HaveLen(2))
Expect(appConfig.Galleries[0].Name).To(Equal("gallery1"))
Expect(appConfig.BackendGalleries).To(HaveLen(1))
Expect(appConfig.AutoloadGalleries).To(BeTrue())
Expect(appConfig.AutoloadBackendGalleries).To(BeTrue())
})
It("should apply auto_upgrade_backends setting", func() {
appConfig := &ApplicationConfig{}
v := true
rs := &RuntimeSettings{AutoUpgradeBackends: &v}
appConfig.ApplyRuntimeSettings(rs)
Expect(appConfig.AutoUpgradeBackends).To(BeTrue())
})
It("should apply agent settings", func() {
appConfig := &ApplicationConfig{}
retentionDays := 14
rs := &RuntimeSettings{
AgentJobRetentionDays: &retentionDays,
}
appConfig.ApplyRuntimeSettings(rs)
Expect(appConfig.AgentJobRetentionDays).To(Equal(14))
})
})
Describe("Round-trip conversion", func() {
It("should maintain values through ToRuntimeSettings -> ApplyRuntimeSettings", func() {
original := &ApplicationConfig{
WatchDog: true,
WatchDogIdle: true,
WatchDogBusy: false,
WatchDogIdleTimeout: 25 * time.Minute,
WatchDogBusyTimeout: 12 * time.Minute,
SingleBackend: false,
MaxActiveBackends: 3,
MemoryReclaimerEnabled: true,
MemoryReclaimerThreshold: 0.92,
Threads: 12,
ContextSize: 6144,
F16: true,
Debug: false,
CORS: true,
DisableCSRF: false,
CORSAllowOrigins: "https://test.com",
P2PToken: "round-trip-token",
P2PNetworkID: "round-trip-network",
Federated: true,
AutoloadGalleries: true,
AutoloadBackendGalleries: false,
AutoUpgradeBackends: true,
AgentJobRetentionDays: 60,
}
// Convert to RuntimeSettings
rs := original.ToRuntimeSettings()
// Apply to a new ApplicationConfig
target := &ApplicationConfig{}
target.ApplyRuntimeSettings(&rs)
// Verify all values match
Expect(target.WatchDog).To(Equal(original.WatchDog))
Expect(target.WatchDogIdle).To(Equal(original.WatchDogIdle))
Expect(target.WatchDogBusy).To(Equal(original.WatchDogBusy))
Expect(target.WatchDogIdleTimeout).To(Equal(original.WatchDogIdleTimeout))
Expect(target.WatchDogBusyTimeout).To(Equal(original.WatchDogBusyTimeout))
Expect(target.MaxActiveBackends).To(Equal(original.MaxActiveBackends))
Expect(target.MemoryReclaimerEnabled).To(Equal(original.MemoryReclaimerEnabled))
Expect(target.MemoryReclaimerThreshold).To(Equal(original.MemoryReclaimerThreshold))
Expect(target.Threads).To(Equal(original.Threads))
Expect(target.ContextSize).To(Equal(original.ContextSize))
Expect(target.F16).To(Equal(original.F16))
Expect(target.Debug).To(Equal(original.Debug))
Expect(target.CORS).To(Equal(original.CORS))
Expect(target.DisableCSRF).To(Equal(original.DisableCSRF))
Expect(target.CORSAllowOrigins).To(Equal(original.CORSAllowOrigins))
Expect(target.P2PToken).To(Equal(original.P2PToken))
Expect(target.P2PNetworkID).To(Equal(original.P2PNetworkID))
Expect(target.Federated).To(Equal(original.Federated))
Expect(target.AutoloadGalleries).To(Equal(original.AutoloadGalleries))
Expect(target.AutoloadBackendGalleries).To(Equal(original.AutoloadBackendGalleries))
Expect(target.AutoUpgradeBackends).To(Equal(original.AutoUpgradeBackends))
Expect(target.AgentJobRetentionDays).To(Equal(original.AgentJobRetentionDays))
})
It("should handle empty galleries correctly in round-trip", func() {
original := &ApplicationConfig{
Galleries: []Gallery{},
BackendGalleries: []Gallery{},
ApiKeys: []string{},
}
rs := original.ToRuntimeSettings()
target := &ApplicationConfig{}
target.ApplyRuntimeSettings(&rs)
Expect(target.Galleries).To(BeEmpty())
Expect(target.BackendGalleries).To(BeEmpty())
})
})
Describe("Edge cases", func() {
It("should handle invalid timeout string in ApplyRuntimeSettings", func() {
appConfig := &ApplicationConfig{
WatchDogIdleTimeout: 10 * time.Minute,
}
invalidTimeout := "not-a-duration"
rs := &RuntimeSettings{
WatchdogIdleTimeout: &invalidTimeout,
}
appConfig.ApplyRuntimeSettings(rs)
// Should remain unchanged due to parse error
Expect(appConfig.WatchDogIdleTimeout).To(Equal(10 * time.Minute))
})
It("should handle zero values in ApplicationConfig", func() {
appConfig := &ApplicationConfig{
// All zero values
}
rs := appConfig.ToRuntimeSettings()
// Should still have non-nil pointers with zero/default values
Expect(rs.WatchdogEnabled).ToNot(BeNil())
Expect(*rs.WatchdogEnabled).To(BeFalse())
Expect(rs.Threads).ToNot(BeNil())
Expect(*rs.Threads).To(Equal(0))
Expect(rs.MemoryReclaimerThreshold).ToNot(BeNil())
Expect(*rs.MemoryReclaimerThreshold).To(Equal(0.0))
})
It("should prefer MaxActiveBackends over SingleBackend when both are set", func() {
appConfig := &ApplicationConfig{}
maxBackends := 3
singleBackend := true
rs := &RuntimeSettings{
MaxActiveBackends: &maxBackends,
SingleBackend: &singleBackend,
}
appConfig.ApplyRuntimeSettings(rs)
// MaxActiveBackends should take precedence
Expect(appConfig.MaxActiveBackends).To(Equal(3))
Expect(appConfig.SingleBackend).To(BeFalse()) // 3 != 1, so single backend is false
})
})
Describe("SetVRAMBudget", func() {
It("stores the VRAM budget via SetVRAMBudget", func() {
o := NewApplicationConfig(SetVRAMBudget("80%"))
Expect(o.VRAMBudget).To(Equal("80%"))
})
})
})