From a6547462fca5a503151c73bb415e30565e460b04 Mon Sep 17 00:00:00 2001 From: masterdraco Date: Thu, 24 Sep 2026 01:54:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(lagre):=20v=C3=A6lg=20bygning=20->=20stati?= =?UTF-8?q?stik=20pr.=20vare:=20v=C3=A6rdi=20nu/ved=20toppris,=20g=C3=A5rd?= =?UTF-8?q?ens=20beholdning=20i=20d=C3=B8gn,=20forbrug=20og=20produktion?= =?UTF-8?q?=20pr.=20d=C3=B8gn=20(v1.15.0.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FS25_ADSmartPickup/adSources.lua | 86 ++++++++++++++ FS25_ADSmartPickup/adStorageStats.lua | 67 +++++++++++ FS25_ADSmartPickup/gui/SmartPickupFrame.lua | 1 + .../gui/SmartPickupOutboundTab.lua | 4 + .../gui/SmartPickupStorageTab.lua | 110 +++++++++++++++++- FS25_ADSmartPickup/l10n/l10n_da.xml | 10 ++ FS25_ADSmartPickup/l10n/l10n_en.xml | 10 ++ FS25_ADSmartPickup/modDesc.xml | 3 +- tests/test_adSources.lua | 22 ++++ tests/test_adStorageStats.lua | 46 ++++++++ 10 files changed, 356 insertions(+), 3 deletions(-) create mode 100644 FS25_ADSmartPickup/adStorageStats.lua create mode 100644 tests/test_adStorageStats.lua diff --git a/FS25_ADSmartPickup/adSources.lua b/FS25_ADSmartPickup/adSources.lua index 4a332d8..e61ac47 100644 --- a/FS25_ADSmartPickup/adSources.lua +++ b/FS25_ADSmartPickup/adSources.lua @@ -624,6 +624,92 @@ function ADSources.getFarmDemand(adEnv, fillType, farmId, excludedPlaceables) return {liters = total, consumers = consumers} end +-- ---- forbrug og produktion pr. døgn (fanen Lagre) ---- + +local function addRate(rates, fillType, field, liters) + if fillType == nil or liters == nil or liters <= 0 then return end + rates[fillType] = rates[fillType] or {consumption = 0, production = 0} + rates[fillType][field] = rates[fillType][field] + liters +end + +local function addFactoryRates(rates, placeable) + local productionPoint = getProductionPoint(placeable) + for _, production in ipairs(productionPoint ~= nil and productionPoint.productions or {}) do + if isProductionWanted(productionPoint, production) then + local perDay = (production.cyclesPerMinute or ((production.cyclesPerHour or 0) / 60)) * 1440 + for _, input in ipairs(production.inputs or {}) do addRate(rates, input.type, "consumption", (input.amount or 0) * perDay) end + for _, output in ipairs(production.outputs or {}) do addRate(rates, output.type, "production", (output.amount or 0) * perDay) end + end + end +end + +local function getFoodWeights(placeable) + local foodSpec = placeable.spec_husbandryFood + local system = g_currentMission ~= nil and g_currentMission.animalFoodSystem or nil + local weights = {} + local food = system ~= nil and system.getAnimalFood ~= nil and foodSpec ~= nil and system:getAnimalFood(foodSpec.animalTypeIndex) or nil + for _, group in pairs(food ~= nil and food.groups or {}) do + local weight = tonumber(group.productionWeight) or tonumber(group.eatWeight) or 1 + for _, fillType in pairs(group.fillTypes or {}) do weights[fillType] = math.max(weights[fillType] or 0, weight) end + end + return weights +end + +local function addHusbandryRates(rates, placeable) + local food, straw, water = placeable.spec_husbandryFood, placeable.spec_husbandryStraw, placeable.spec_husbandryWater + local byName = function(name) return g_fillTypeManager ~= nil and g_fillTypeManager.getFillTypeIndexByName ~= nil and g_fillTypeManager:getFillTypeIndexByName(name) or nil end + local nameToIndex = function(name) + local index = byName(name) + if index == nil and g_fillTypeManager ~= nil then + for i = 1, 256 do if g_fillTypeManager:getFillTypeNameByIndex(i) == name then return i end end + end + return index + end + if food ~= nil and (food.litersPerHour or 0) > 0 then + for fillType, liters in pairs(ADStorageStats.splitTroughConsumption(food.litersPerHour * 24, food.fillLevels, getFoodWeights(placeable))) do + addRate(rates, fillType, "consumption", liters) + end + end + if straw ~= nil and (straw.inputLitersPerHour or 0) > 0 then + addRate(rates, nameToIndex("STRAW"), "consumption", straw.inputLitersPerHour * 24) + end + if water ~= nil and not water.automaticWaterSupply and (water.litersPerHour or 0) > 0 then + addRate(rates, nameToIndex("WATER"), "consumption", water.litersPerHour * 24) + end +end + +-- {[fillType] = {consumption, production}} i liter pr. døgn for gårdens fabrikker og stalde. +function ADSources.getFarmRates(adEnv, farmId) + local placeables = g_currentMission ~= nil and g_currentMission.placeableSystem ~= nil and g_currentMission.placeableSystem.placeables or {} + local rates = {} + for _, placeable in pairs(placeables) do + if isOwnedBy(placeable, farmId) then + if getProductionPoint(placeable) ~= nil then + addFactoryRates(rates, placeable) + elseif placeable.getHusbandryFillLevel ~= nil then + addHusbandryRates(rates, placeable) + end + end + end + return rates +end + +-- Bedste pris lige nu blandt alle spillets salgssteder der tager varen (uanset markør/vejnet): +-- {price, top} pr. liter; top = prisen ved årets højeste periode. nil når ingen tager varen. +function ADSources.getMarketQuote(fillType) + local storage = g_currentMission ~= nil and g_currentMission.storageSystem or nil + local best = nil + for _, station in pairs(storage ~= nil and storage:getUnloadingStations() or {}) do + if ADPrices.isSellingStation(station) and ADSources.stationAccepts(station, fillType) then + local quote = ADPrices.getQuote(station, fillType) + if quote ~= nil and (best == nil or quote.price > best.price) then + best = {price = quote.price, top = quote.top} + end + end + end + return best +end + -- ---- inventar: alt gården har liggende, pr. bygning ---- local function addEntry(entries, fillType, level, capacity, extra) diff --git a/FS25_ADSmartPickup/adStorageStats.lua b/FS25_ADSmartPickup/adStorageStats.lua new file mode 100644 index 0000000..a457a07 --- /dev/null +++ b/FS25_ADSmartPickup/adStorageStats.lua @@ -0,0 +1,67 @@ +-- AD Storage Stats +-- Rene beregninger (ingen spil-API) til fanen Lagre: pr. vare i en bygning fyldning, værdi nu og +-- ved årets toppris, gårdens samlede beholdning og hvor mange døgn den rækker ved gårdens forbrug. + +ADStorageStats = {} + +-- item = {fillType, level, capacity}; farmTotal = liter af varen på hele gården; +-- rate = {consumption, production} liter pr. døgn (eller nil); quote = {price, top} pr. liter (eller nil). +function ADStorageStats.itemStats(item, farmTotal, rate, quote) + local level = item.level or 0 + local capacity = item.capacity or 0 + local consumption = rate ~= nil and rate.consumption or 0 + local production = rate ~= nil and rate.production or 0 + return { + fillType = item.fillType, + level = level, + capacity = capacity, + percent = capacity > 0 and math.floor(100 * level / capacity + 0.5) or nil, + valueNow = quote ~= nil and quote.price ~= nil and math.floor(level * quote.price + 0.5) or nil, + valueTop = quote ~= nil and quote.top ~= nil and math.floor(level * quote.top + 0.5) or nil, + farmTotal = farmTotal or level, + consumption = consumption, + production = production, + net = production - consumption, + days = consumption > 0 and (farmTotal or level) / consumption or nil, + } +end + +function ADStorageStats.buildingTotals(stats) + local now, top = 0, 0 + for _, stat in ipairs(stats) do + now = now + (stat.valueNow or 0) + top = top + (stat.valueTop or 0) + end + return {valueNow = now, valueTop = top} +end + +-- inventory = { {entries = {{fillType, level}}} } -> {[fillType] = liter} +function ADStorageStats.farmTotals(inventory) + local totals = {} + for _, building in ipairs(inventory or {}) do + for _, entry in ipairs(building.entries or {}) do + totals[entry.fillType] = (totals[entry.fillType] or 0) + (entry.level or 0) + end + end + return totals +end + +-- Et stald-trug æder `perDay` liter i alt; fordel på fodertyperne efter hvad truget indeholder. +-- Tomt trug: alt på foderet med højest vægt (det der hentes først). +function ADStorageStats.splitTroughConsumption(perDay, levels, weights) + local total = 0 + for _, level in pairs(levels or {}) do total = total + (level or 0) end + local result = {} + if total > 0 then + for fillType, level in pairs(levels) do + if (level or 0) > 0 then result[fillType] = perDay * level / total end + end + return result + end + local best, bestWeight = nil, -1 + for fillType, weight in pairs(weights or {}) do + if weight > bestWeight or (weight == bestWeight and fillType < best) then best, bestWeight = fillType, weight end + end + if best ~= nil then result[best] = perDay end + return result +end diff --git a/FS25_ADSmartPickup/gui/SmartPickupFrame.lua b/FS25_ADSmartPickup/gui/SmartPickupFrame.lua index 5385b81..7f1d091 100644 --- a/FS25_ADSmartPickup/gui/SmartPickupFrame.lua +++ b/FS25_ADSmartPickup/gui/SmartPickupFrame.lua @@ -288,6 +288,7 @@ function SmartPickupFrame:clearRows() self.overviewRows = {} self.productRows, self.outboundStatusRows = {}, {} self.storageRows = {} + self.storageDetailRows = {} self.boardRows = {} self.ingredientModeRowOption, self.collectRoundRowOption, self.directionRowOption = nil, nil, nil end diff --git a/FS25_ADSmartPickup/gui/SmartPickupOutboundTab.lua b/FS25_ADSmartPickup/gui/SmartPickupOutboundTab.lua index b365c24..6c33959 100644 --- a/FS25_ADSmartPickup/gui/SmartPickupOutboundTab.lua +++ b/FS25_ADSmartPickup/gui/SmartPickupOutboundTab.lua @@ -178,6 +178,10 @@ function SmartPickupFrame:onClickSource(state, element) end function SmartPickupFrame:onClickProduct(state, element) + local clicked = self.rowData[element] + if clicked ~= nil and clicked.kind == "storageBuilding" then + return self:safe(function(frame) frame:onClickStorageBuilding(state) end) + end self:safe(function(frame) local run, data = frame:getRun(), frame.rowData[element] if run == nil or data == nil or data.kind ~= "product" then return end diff --git a/FS25_ADSmartPickup/gui/SmartPickupStorageTab.lua b/FS25_ADSmartPickup/gui/SmartPickupStorageTab.lua index 02472f3..8a6b018 100644 --- a/FS25_ADSmartPickup/gui/SmartPickupStorageTab.lua +++ b/FS25_ADSmartPickup/gui/SmartPickupStorageTab.lua @@ -26,10 +26,114 @@ function SmartPickupFrame:getInventory(adEnv) return ADSources.getFarmInventory(adEnv, g_currentMission:getFarmId()) end +-- ---- detaljer for den valgte bygning ---- + +function SmartPickupFrame.formatMoney(value) + if value == nil then return "-" end + if g_i18n ~= nil and g_i18n.formatMoney ~= nil then + local ok, text = pcall(g_i18n.formatMoney, g_i18n, value, 0, true, true) + if ok and text ~= nil then return text end + end + return string.format("%d", math.floor(value + 0.5)) +end + +function SmartPickupFrame:fillTypeTitle(fillType) + local title = g_fillTypeManager.getFillTypeTitleByIndex ~= nil and g_fillTypeManager:getFillTypeTitleByIndex(fillType) or nil + return tostring(title or g_fillTypeManager:getFillTypeNameByIndex(fillType)) +end + +-- Beregner stats for alle varer i bygningen (gårdens totaler og rater regnes én gang pr. opdatering). +function SmartPickupFrame:computeBuildingStats(adEnv, entry, inventory) + local farmId = g_currentMission:getFarmId() + local totals = ADStorageStats.farmTotals(inventory) + local rates = ADSources.getFarmRates ~= nil and ADSources.getFarmRates(adEnv, farmId) or {} + local stats = {} + for _, item in ipairs(entry.entries) do + local quote = ADSources.getMarketQuote ~= nil and ADSources.getMarketQuote(item.fillType) or nil + table.insert(stats, ADStorageStats.itemStats(item, totals[item.fillType], rates[item.fillType], quote)) + end + return stats +end + +-- "Sukker — 150.0k/180.0k (83 %) · nu 240.300 kr · top 300.400 kr · gården 412.0k = 3.2 døgn · forbrug 130.0k/døgn · prod. 48.0k/døgn" +function SmartPickupFrame:describeStat(stat) + local i18n = self.i18n + local fc = SmartPickupFrame.formatCompact + local parts = {string.format("%s — %s/%s%s", self:fillTypeTitle(stat.fillType), fc(stat.level), fc(stat.capacity), stat.percent ~= nil and string.format(" (%d %%)", stat.percent) or "")} + if stat.valueNow ~= nil then + table.insert(parts, string.format(i18n:getText("spu_stat_now"), SmartPickupFrame.formatMoney(stat.valueNow))) + table.insert(parts, string.format(i18n:getText("spu_stat_top"), SmartPickupFrame.formatMoney(stat.valueTop))) + else + table.insert(parts, i18n:getText("spu_stat_noSell")) + end + local days = stat.days ~= nil and string.format(i18n:getText("spu_stat_days"), stat.days) or i18n:getText("spu_stat_noUse") + table.insert(parts, string.format(i18n:getText("spu_stat_farm"), fc(stat.farmTotal), days)) + if stat.consumption > 0 then table.insert(parts, string.format(i18n:getText("spu_stat_use"), fc(stat.consumption))) end + if stat.production > 0 then table.insert(parts, string.format(i18n:getText("spu_stat_prod"), fc(stat.production))) end + return " " .. table.concat(parts, " · ") +end + +function SmartPickupFrame:describeStatTotals(stats) + local totals = ADStorageStats.buildingTotals(stats) + return string.format(" " .. self.i18n:getText("spu_stat_total"), SmartPickupFrame.formatMoney(totals.valueNow), SmartPickupFrame.formatMoney(totals.valueTop)) +end + +-- Navne til vælgeren; ens navne (40 Farma-siloer) får løbenummer. +function SmartPickupFrame:storageLabels(inventory) + local counts, seen, labels = {}, {}, {} + for _, entry in ipairs(inventory) do counts[entry.name] = (counts[entry.name] or 0) + 1 end + for index, entry in ipairs(inventory) do + seen[entry.name] = (seen[entry.name] or 0) + 1 + labels[index] = counts[entry.name] > 1 and string.format("%s #%d", entry.name, seen[entry.name]) or entry.name + end + return labels +end + +function SmartPickupFrame:fillStorageDetail(adEnv, inventory) + self.storageDetailRows = {} + if #inventory == 0 then + return + end + self.storageIndex = math.max(1, math.min(self.storageIndex or 1, #inventory)) + local row = self:addRow(self.productPrefab, {kind = "storageBuilding"}) + local option = row:getDescendantByName("option") + option:setTexts(self:storageLabels(inventory)) + option:setState(self.storageIndex, false) + row:getDescendantByName("title"):setText(self.i18n:getText("spu_storage_select")) + local entry = inventory[self.storageIndex] + self.storageDetailPlaceable = entry.placeable + local stats = self:computeBuildingStats(adEnv, entry, inventory) + for _, stat in ipairs(stats) do + table.insert(self.storageDetailRows, self:addTextRow(self:describeStat(stat))) + end + table.insert(self.storageDetailRows, self:addTextRow(self:describeStatTotals(stats))) +end + +function SmartPickupFrame:refreshStorageDetail(adEnv, inventory) + local entry = nil + for _, candidate in ipairs(inventory) do + if candidate.placeable == self.storageDetailPlaceable then entry = candidate end + end + if entry == nil or #(self.storageDetailRows or {}) ~= #entry.entries + 1 then + return + end + local stats = self:computeBuildingStats(adEnv, entry, inventory) + for index, stat in ipairs(stats) do + self.storageDetailRows[index]:setText(self:describeStat(stat)) + end + self.storageDetailRows[#stats + 1]:setText(self:describeStatTotals(stats)) +end + +function SmartPickupFrame:onClickStorageBuilding(state) + self.storageIndex = state + self:requestRebuild() +end + function SmartPickupFrame:fillStorageRows(adEnv) self.storageRows = {} - self:addTextRow(self.i18n:getText("spu_section_storage")) local inventory = self:getInventory(adEnv) + self:fillStorageDetail(adEnv, inventory) + self:addTextRow(self.i18n:getText("spu_section_storage")) for _, entry in ipairs(inventory) do table.insert(self.storageRows, {placeable = entry.placeable, title = self:addTextRow(self:describeInventory(entry))}) end @@ -42,10 +146,12 @@ function SmartPickupFrame:refreshStorageRows(adEnv) if self.currentTab ~= SmartPickupFrame.TAB_STORAGE or #(self.storageRows or {}) == 0 then return end + local inventory = self:getInventory(adEnv) local byPlaceable = {} - for _, entry in ipairs(self:getInventory(adEnv)) do + for _, entry in ipairs(inventory) do byPlaceable[entry.placeable] = entry end + pcall(self.refreshStorageDetail, self, adEnv, inventory) for _, row in ipairs(self.storageRows) do local entry = byPlaceable[row.placeable] if entry ~= nil then diff --git a/FS25_ADSmartPickup/l10n/l10n_da.xml b/FS25_ADSmartPickup/l10n/l10n_da.xml index e751e91..b1a1387 100644 --- a/FS25_ADSmartPickup/l10n/l10n_da.xml +++ b/FS25_ADSmartPickup/l10n/l10n_da.xml @@ -124,5 +124,15 @@ + + + + + + + + + + diff --git a/FS25_ADSmartPickup/l10n/l10n_en.xml b/FS25_ADSmartPickup/l10n/l10n_en.xml index c579027..4a4c3e8 100644 --- a/FS25_ADSmartPickup/l10n/l10n_en.xml +++ b/FS25_ADSmartPickup/l10n/l10n_en.xml @@ -124,5 +124,15 @@ + + + + + + + + + + diff --git a/FS25_ADSmartPickup/modDesc.xml b/FS25_ADSmartPickup/modDesc.xml index 534a112..e5efe2b 100644 --- a/FS25_ADSmartPickup/modDesc.xml +++ b/FS25_ADSmartPickup/modDesc.xml @@ -1,7 +1,7 @@ masterdraco - 1.14.3.0 + 1.15.0.0 <en>AD Smart Pickup</en> @@ -20,6 +20,7 @@ + diff --git a/tests/test_adSources.lua b/tests/test_adSources.lua index cef8805..23bf8b9 100644 --- a/tests/test_adSources.lua +++ b/tests/test_adSources.lua @@ -20,6 +20,7 @@ SellingStation = {} dofile("FS25_ADSmartPickup/adSupplyPlanner.lua") dofile("FS25_ADSmartPickup/adPrices.lua") +dofile("FS25_ADSmartPickup/adStorageStats.lua") dofile("FS25_ADSmartPickup/adSources.lua") local failures = 0 @@ -383,5 +384,26 @@ check("S17 gylle-enden: gylle", slurryEnd[1].fillType, SLURRY) twoEndStation.loadTriggers[1].fillTypes = nil check("S17 uden punkt-varer: stationens varer", #ADSources.getProducts(adEnv, ADSources.identify(adEnv, 40, 1), 1), 2) +-- S18: gårdens forbrug og produktion pr. døgn (fabrikker + stalde) og bedste markedspris +tmrFactory.spec_productionPoint.productionPoint.getIsProductionEnabled = function(self, id) return id == "tmr3" end +tmrFactory.spec_productionPoint.productionPoint.productions[1].outputs = {{type = FORAGE, amount = 240}} +cowBarn.spec_husbandryWater.automaticWaterSupply = false +local rates = ADSources.getFarmRates(adEnv, 1) +-- TMR-fabrik: 45 l roer × 200/60 cyklus/min × 1440 = 216.000 l/døgn; TMR ud 240 × 200/60 × 1440 = 1.152.000 +check("S18 roer forbrug", math.floor(rates[BEET].consumption + 0.5), 216000) +check("S18 TMR produktion", math.floor(rates[FORAGE].production + 0.5), 1152000) +check("S18 halm i stald 500 l/t", rates[STRAW].consumption, 12000) +check("S18 vand 100 l/t", rates[WATER].consumption, 2400) +check("S18 anden farm tæller ikke", rates[STRAW].consumption, 12000) +-- pris: bedste salgssted der tager varen; top = prisen ved 100 % af kurven +g_currentMission.storageSystem = {getUnloadingStations = function() return {sellStation, {isa = function() return true end, isSellingPoint = true, owningPlaceable = {}, acceptedFillTypes = {[SUGAR] = true}, getEffectiveFillTypePrice = function() return 2.0 end}} end} +sellStation.getEffectiveFillTypePrice = function() return 1.5 end +g_currentMission.environment = {currentPeriod = 1} +DESC[SUGAR].economy = {factors = {0.8, 1.0}} +local quote = ADSources.getMarketQuote(SUGAR) +check("S18 bedste pris", quote.price, 2.0) +check("S18 top ved 80 % af kurven", quote.top, 2.5) +check("S18 intet salgssted -> nil", ADSources.getMarketQuote(FORAGE), nil) + print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL")) os.exit(failures == 0 and 0 or 1) diff --git a/tests/test_adStorageStats.lua b/tests/test_adStorageStats.lua new file mode 100644 index 0000000..d0b48e8 --- /dev/null +++ b/tests/test_adStorageStats.lua @@ -0,0 +1,46 @@ +-- Kør: luajit tests/test_adStorageStats.lua (fra repo-roden) +-- Lagerstatistik pr. vare: fyldning, værdi nu / ved toppris, gårdens samlede beholdning, dage tilbage. + +dofile("FS25_ADSmartPickup/adStorageStats.lua") +local S = ADStorageStats + +local failures = 0 +local function check(name, actual, expected) + if actual == expected then print("OK " .. name) else failures = failures + 1; print(string.format("FAIL %s: forventede %s, fik %s", name, tostring(expected), tostring(actual))) end +end + +-- ST1: fuld beregning +local st = S.itemStats({fillType = 5, level = 150000, capacity = 180000}, 412000, {consumption = 130000, production = 48000}, {price = 1.6, top = 2.0}) +check("ST1 procent", st.percent, 83) +check("ST1 værdi nu", st.valueNow, 240000) +check("ST1 værdi top", st.valueTop, 300000) +check("ST1 gårdens beholdning", st.farmTotal, 412000) +check("ST1 dage", math.floor(st.days * 10 + 0.5) / 10, 3.2) +check("ST1 netto pr. døgn", st.net, -82000) + +-- ST2: ingen salgssted, intet forbrug, ingen kapacitet +local st2 = S.itemStats({fillType = 6, level = 5000, capacity = 0}, 5000, nil, nil) +check("ST2 ingen pris -> nil", st2.valueNow, nil) +check("ST2 intet forbrug -> dage nil", st2.days, nil) +check("ST2 kapacitet 0 -> procent nil", st2.percent, nil) + +-- ST3: bygningens samlede værdi (kun varer med pris) +local total = S.buildingTotals({st, st2, S.itemStats({fillType = 7, level = 1000, capacity = 2000}, 1000, nil, {price = 2, top = 3})}) +check("ST3 samlet nu", total.valueNow, 242000) +check("ST3 samlet top", total.valueTop, 303000) + +-- ST4: gårdens totaler fra inventar +local totals = S.farmTotals({{entries = {{fillType = 5, level = 100}, {fillType = 6, level = 5}}}, {entries = {{fillType = 5, level = 50}}}}) +check("ST4 sum pr. vare", totals[5], 150) +check("ST4 anden vare", totals[6], 5) + +-- ST5: foder-forbrug i et fælles trug fordeles efter truget indhold; tomt trug -> bedste foder +local split = S.splitTroughConsumption(2400, {[116] = 3000, [7] = 1000}, {[116] = 1.0, [7] = 0.8}) +check("ST5 TMR 75 %", split[116], 1800) +check("ST5 ensilage 25 %", split[7], 600) +local empty = S.splitTroughConsumption(2400, {}, {[116] = 1.0, [7] = 0.8}) +check("ST5 tomt trug -> bedste foder", empty[116], 2400) +check("ST5 tomt trug -> intet på ensilage", empty[7], nil) + +print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL")) +os.exit(failures == 0 and 0 or 1)